Files
accounted/lib/reports/kassaflodesanalys.ts
T
Jakob WennbergandClaude Opus 4.8 88f49c0ccc fix(bookkeeping): harden correction flow and align VAT/cashflow reports (#726)
Bundles a set of bookkeeping-correctness fixes developed together.

Correction / storno flow
- correctEntry resolves (and seeds standard BAS) accounts for the
  corrected lines BEFORE writing the storno. The old order created and
  posted the storno first, then hit AccountsNotInChartError on the
  corrected lines and had to cancel it again — leaving a voided 0 kr
  storno in the chain and permanently burning a voucher number (an
  unexplained BFNAR 2013:2 gap). It now fails fast with nothing written.
- correctEntry re-points the bank transaction and underlag from the
  reversed original to the live corrected entry, so the transaction keeps
  reading as booked (and stays correctable) and the underlag travels with
  it. recordateEntry delegates both relinks to correctEntry.
- reverseEntry (engine) clears transactions.journal_entry_id for rows
  booked by the reversed entry, so a plain storno returns the bank row to
  "Att bokföra" with a re-booking affordance. The agent paths did this
  manually; the dashboard reverse route did not.
- findUnresolvableAccounts replaces findMissingActiveAccounts in the
  categorize routes: a standard BAS account merely absent from the chart
  is seeded on demand by the engine, so pre-validation must not 400 on it
  — only unknown numbers or deactivated accounts block.
- CorrectionChain dims cancelled (0 kr) entries and labels them so they
  no longer render like a live storno.

Report accuracy
- calculateVatLiability() (lib/reports/kpi.ts) is shared by the KPI route,
  the KPI xlsx export and the MCP period-summary tool, and uses the same
  26xx accounts as the momsdeklaration (ruta 49). Reverse-charge and
  import pairs (e.g. 2614 credit + 2645 debit) net to zero instead of
  inflating the receivable (#715). VAT_OUTPUT_ACCOUNTS / VAT_INPUT_ACCOUNTS
  are derived from ACCOUNT_RUTA so the widget can never drift from the
  declaration.
- Kassaflödesanalys records erhållna aktieägartillskott (2093) as a
  financing inflow and counts överkursfond (2086/2097) toward nyemission.
  2093 was previously unmapped, so any contribution broke the 19xx
  reconciliation by exactly the contributed amount (#716). Wired through
  the report type, both PDF templates, the K3 PDF, the dashboard client
  and the årsredovisning summary type.

Agent guidance
- shared-rules: describe the real Accounted correction flow (Rätta rader /
  Rätta datum / Radera verifikat, on-demand BAS backfill) so the assistant
  stops inventing flows that don't exist.
- verifikation-draft: clearer locked-period guidance.

Tests cover all of the above (storno fail-fast + seeding + relink,
reverseEntry unlink, findUnresolvableAccounts, VAT netting and the
cashflow reconciliation cases).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 10:17:44 +02:00

363 lines
16 KiB
TypeScript

import type { SupabaseClient } from '@supabase/supabase-js'
import { generateTrialBalance } from './trial-balance'
import { generateIncomeStatement } from './income-statement'
import type { TrialBalanceRow } from '@/types'
/**
* Kassaflödesanalys (Cash Flow Statement) — indirect method per BFNAR 2012:1 ch 7.
*
* Three sections:
* - Löpande verksamhet (Operating activities)
* - Investeringsverksamhet (Investing activities)
* - Finansieringsverksamhet (Financing activities)
*
* The indirect method starts from "Resultat efter finansiella poster", adds
* back non-cash items (avskrivningar, periodiseringar), and adjusts for
* working-capital movements. The sum across all three sections must equal
* the actual change in cash & bank (19xx) balance over the period.
*
* Account-class mapping (BAS 2026):
* 14xx Lager / varulager → operating (Δ inventory)
* 15xx Kortfristiga fordringar (kundfordringar) → operating (Δ receivables)
* 24xx Kortfristiga skulder (leverantörsskulder) → operating (Δ payables)
* 26xx Moms och punktskatter → operating (Δ VAT)
* 29xx Upplupna kostnader/förutbetalda intäkter → operating (Δ accruals)
* 2510 Skatteskuld (income tax) → operating (skatt betald)
*
* 10xx-13xx Anläggningstillgångar (capital goods) → investing
*
* 20xx Eget kapital (nyemission, utdelning,
* erhållna aktieägartillskott 2093) → financing
* 23xx Långfristiga skulder (lån) → financing
*
* 19xx Kassa och bank → reconciliation (target)
*
* The reconciliation invariant: total_cash_flow MUST equal
* closing(19xx) - opening(19xx)
* within 1 öre. Any mismatch signals a bookkeeping invariant violation
* (e.g., journal entry posted to an account class we haven't mapped) and is
* surfaced as a warning in the report so a human can investigate.
*/
export type KassaflodesanalysReport = {
fiscal_period_id: string
period_start: string
period_end: string
lopande: {
resultat_efter_finansiella_poster: number
avskrivningar: number
ovriga_ej_kassaflodesposter: number
delta_kortfristiga_fordringar: number
delta_varulager: number
delta_kortfristiga_skulder: number
skatt_betald: number
total: number
}
investerings: {
forvarv_anlaggningar: number
avyttring_anlaggningar: number
total: number
}
finansierings: {
delta_lan: number
utdelningar: number
nyemission: number
erhallna_aktieagartillskott: number
total: number
}
total_cash_flow: number
reconciliation: {
opening_cash_1xxx: number
closing_cash_1xxx: number
delta_actual: number
delta_calculated: number
mismatch_amount: number
is_reconciled: boolean
}
}
// Normalize -0 → 0 so callers (and tests) never observe a signed zero.
// Math.round(0 * 100) / 100 happens to be 0, but Math.round(-0.001 * 100) / 100
// returns -0 because Math.round preserves the sign of zero.
const r2 = (n: number) => {
const rounded = Math.round(n * 100) / 100
return rounded === 0 ? 0 : rounded
}
/**
* Returns the signed balance change for an account between IB and UB.
*
* For asset accounts (debit-normal): positive = increase, negative = decrease
* For liability/equity accounts (credit-normal): positive = increase
*
* We always compute `(closing_debit - closing_credit) - (opening_debit - opening_credit)`,
* which gives the signed *debit-side* movement. Callers negate as needed for
* credit-normal accounts.
*/
function debitSideDelta(row: TrialBalanceRow): number {
const opening = (row.opening_debit || 0) - (row.opening_credit || 0)
const closing = (row.closing_debit || 0) - (row.closing_credit || 0)
return closing - opening
}
/**
* Sum the debit-side delta for all accounts whose number starts with one of
* the given prefixes. Useful for grouping by BAS account class/range.
*/
function sumDeltaByPrefix(rows: TrialBalanceRow[], prefixes: string[]): number {
return rows
.filter((r) => prefixes.some((p) => r.account_number.startsWith(p)))
.reduce((sum, r) => sum + debitSideDelta(r), 0)
}
/**
* Sum *period activity* (not delta) on the debit side for the given account
* prefixes. Used for avskrivningar where the depreciation expense for the
* period is the relevant figure, not the cumulative change in the contra
* account (which would also reflect disposals).
*/
function sumPeriodDebitByPrefix(rows: TrialBalanceRow[], prefixes: string[]): number {
return rows
.filter((r) => prefixes.some((p) => r.account_number.startsWith(p)))
.reduce((sum, r) => sum + ((r.period_debit || 0) - (r.period_credit || 0)), 0)
}
export async function generateKassaflodesanalys(
supabase: SupabaseClient,
companyId: string,
fiscalPeriodId: string
): Promise<KassaflodesanalysReport> {
// Fetch period info for the report header.
const { data: period, error: periodError } = await supabase
.from('fiscal_periods')
.select('period_start, period_end')
.eq('id', fiscalPeriodId)
.eq('company_id', companyId)
.single()
if (periodError) throw new Error(periodError.message)
if (!period) throw new Error('Fiscal period not found')
// Trial balance gives us opening + closing per account for the period.
// We pass excludeYearEndClosing=true so that the working-capital movements
// reflect actual transactional activity, not the year-end reclassification
// entries that move resultaträkning balances into equity (8999 → 2099).
// Without this filter, the closing entry for class 3-8 would inflate
// "övriga ej-kassaflödesposter" and break the reconciliation.
const { rows } = await generateTrialBalance(supabase, companyId, fiscalPeriodId, {
excludeYearEndClosing: true,
})
// Net result before tax (resultat efter finansiella poster) comes from the
// P&L generator, which already excludes 8999 (year-end closing account)
// and applies the K2/K3 sign convention. We then subtract any tax expense
// (8910, periodiseringsfond moves, etc.) to land at *before-tax* result.
const incomeStatement = await generateIncomeStatement(supabase, companyId, fiscalPeriodId)
// Resultat efter finansiella poster = total_revenue - total_expenses + total_financial
// EXCEPT we want to keep tax (89xx) out — net_result already nets tax in.
// Use the same formula as net_result but without subtracting 89xx items:
// net_result = revenue - expenses + financial (where financial includes 89xx)
// We want: revenue - expenses + (financial - tax_portion)
//
// To keep this simple: scan financial_sections, separate tax (89xx) from
// rest, and assemble resultat efter finansiella poster.
// Filter ROWS by 89xx prefix (not just the section's first row) — a single
// section can mix tax and non-tax accounts, and the old first-row heuristic
// silently misclassified the rest.
const taxAmount = incomeStatement.financial_sections.reduce((sum, s) => {
const sectionTax = s.rows
.filter((r) => r.account_number.startsWith('89'))
.reduce((acc, r) => acc + r.amount, 0)
return sum + sectionTax
}, 0)
const nonTaxFinancial = incomeStatement.total_financial - taxAmount
const resultatEfterFinansiella = r2(
incomeStatement.total_revenue - incomeStatement.total_expenses + nonTaxFinancial
)
// ─── Löpande verksamhet ────────────────────────────────────────────────
// Avskrivningar (depreciation): 78xx debit movements in the period.
// Sign convention: depreciation is an expense that reduced result but did
// not consume cash, so we add it BACK to result. period_debit on 78xx is
// positive; we report it as a positive number to be added.
const avskrivningar = r2(sumPeriodDebitByPrefix(rows, ['78']))
// Övriga ej-kassaflödesposter: this category is used for non-cash items
// beyond depreciation (e.g., reversals of provisions, unrealized FX).
// v1 places it at 0 — extensions can compute it from specific account
// patterns. Kept in the type so the structure is stable.
const ovrigaEjKassaflodesposter = 0
// Δ Kortfristiga fordringar (15xx). Increase in receivables = cash NOT
// received yet → cash outflow → NEGATE the debit-side delta.
// Positive delta on a debit-normal account means asset grew → subtract.
const deltaKortfristigaFordringar = r2(-sumDeltaByPrefix(rows, ['15']))
// Δ Varulager (14xx). Same sign as receivables: stock grew → cash out.
const deltaVarulager = r2(-sumDeltaByPrefix(rows, ['14']))
// Δ Kortfristiga skulder (24xx, 26xx, 29xx) EXCLUDING tax skulder (2510).
// 24xx = leverantörsskulder; 26xx = moms; 29xx = upplupna kostnader.
// These are credit-normal accounts: increase → cash retained → ADD the
// credit-side delta. debitSideDelta returns the *debit*-side delta which
// is the inverse, so we negate.
//
// 25xx is excluded because we handle skatt separately (line below).
const deltaKortfristigaSkulder = r2(
-sumDeltaByPrefix(rows, ['24', '26', '29'])
)
// Skatt betald: actual cash tax outflow over the period. Approximated as
// the negative of the change in 2510 (income tax payable). If 2510 went
// down, tax was paid → negative cash flow. The current-year tax expense
// (8910) was already netted into resultat efter finansiella; here we only
// capture the cash side.
// Sign: 2510 is credit-normal. Decrease in liability = cash outflow.
// debitSideDelta(2510): if liability dropped (UB credit < IB credit),
// delta is positive. We want that as a negative cash flow.
const skattBetald = r2(-sumDeltaByPrefix(rows, ['2510']))
const totalLopande = r2(
resultatEfterFinansiella +
avskrivningar +
ovrigaEjKassaflodesposter +
deltaKortfristigaFordringar +
deltaVarulager +
deltaKortfristigaSkulder +
skattBetald
)
// ─── Investeringsverksamhet ────────────────────────────────────────────
// Förvärv av anläggningstillgångar: net debit movement on 10xx-13xx.
// An increase in fixed assets (positive debit-side delta) is a cash
// outflow → negate to surface as negative.
//
// We exclude accumulated-depreciation contra-asset accounts because their
// movement is non-cash (it's already added back to löpande as avskrivningar).
// Without this filter, depreciation would show up twice — once as an
// add-back in löpande and once as a phantom "avyttring" in investeringar —
// breaking the reconciliation against 19xx.
//
// Note: this naive netting can blend purchases with disposals when a
// disposal credits the same account. Item #2 in the plan (asset disposal)
// will refine this by linking disposal proceeds to specific entries; for
// now, the net figure is the best we can derive from balances alone.
const ACCUMULATED_DEPRECIATION_ACCOUNTS = [
'1119', // ack avskr balanserade utgifter
'1129', // ack avskr koncessioner
'1139', // ack avskr hyresrätter
'1149', // ack avskr goodwill
'1159', // ack avskr förskott immateriella
'1219', // ack avskr maskiner och inventarier
'1229', // ack avskr inventarier och verktyg
'1239', // ack avskr installationer
'1249', // ack avskr bilar
'1259', // ack avskr datorer
'1269', // ack avskr leasade tillgångar
'1279', // ack avskr byggn. inventarier
'1289', // ack avskr övriga maskiner
]
const fixedAssetDelta = rows
.filter((r) => {
if (!['10', '11', '12', '13'].some((p) => r.account_number.startsWith(p))) return false
return !ACCUMULATED_DEPRECIATION_ACCOUNTS.includes(r.account_number)
})
.reduce((sum, r) => sum + debitSideDelta(r), 0)
const forvarv = r2(fixedAssetDelta > 0 ? -fixedAssetDelta : 0)
const avyttring = r2(fixedAssetDelta < 0 ? -fixedAssetDelta : 0)
const totalInvesterings = r2(forvarv + avyttring)
// ─── Finansieringsverksamhet ───────────────────────────────────────────
// Δ Lån (23xx — långfristiga skulder). Credit-normal: increase in loan
// = cash inflow → ADD credit-side delta = negate debit-side delta.
const deltaLan = r2(-sumDeltaByPrefix(rows, ['23']))
// Utdelningar: capture as the debit movements on 2898 (decided dividends)
// and 8910 isn't a dividend (it's tax). Better marker is 2091 / 2898.
// v1: scan for 2898 period_debit. Conservative — better to under-report
// than to mis-classify. Report as negative cash flow.
const utdelningar = r2(-sumPeriodDebitByPrefix(rows, ['2898']))
// Nyemission: increase in 20xx equity (excluding result-of-the-year and
// dividends). Credit-normal: positive credit-side delta = cash inflow.
// We sum 2081 (share capital) + 2082 (ej registrerat aktiekapital) + 2083
// (medlemsinsatser) + 2086/2097 (bunden/fri överkursfond — the premium on
// an emission lands there under K2/K3) + 2087 (pågående nyemission),
// specifically avoiding 2099 (årets resultat is non-cash).
const nyemissionDebit = sumDeltaByPrefix(rows, ['2081', '2082', '2083', '2086', '2087', '2097'])
const nyemission = r2(-nyemissionDebit)
// Erhållna aktieägartillskott (2093, villkorade + ovillkorade): a cash
// contribution from shareholders booked straight to equity. Credit-normal:
// increase = cash inflow → negate the debit-side delta. Issue #716: this
// account was previously unmapped, so any tillskott during the period
// showed 0 under finansiering and broke the 19xx reconciliation by exactly
// the contributed amount.
const erhallnaAktieagartillskott = r2(-sumDeltaByPrefix(rows, ['2093']))
const totalFinansierings = r2(
deltaLan + utdelningar + nyemission + erhallnaAktieagartillskott
)
// ─── Total cash flow ───────────────────────────────────────────────────
const totalCashFlow = r2(totalLopande + totalInvesterings + totalFinansierings)
// ─── Reconciliation against 19xx ───────────────────────────────────────
const cash1xxxRows = rows.filter((r) => r.account_number.startsWith('19'))
const openingCash = r2(
cash1xxxRows.reduce(
(sum, r) => sum + ((r.opening_debit || 0) - (r.opening_credit || 0)),
0
)
)
const closingCash = r2(
cash1xxxRows.reduce(
(sum, r) => sum + ((r.closing_debit || 0) - (r.closing_credit || 0)),
0
)
)
const deltaActual = r2(closingCash - openingCash)
const mismatchAmount = r2(deltaActual - totalCashFlow)
const isReconciled = Math.abs(mismatchAmount) < 0.01
return {
fiscal_period_id: fiscalPeriodId,
period_start: period.period_start,
period_end: period.period_end,
lopande: {
resultat_efter_finansiella_poster: resultatEfterFinansiella,
avskrivningar,
ovriga_ej_kassaflodesposter: ovrigaEjKassaflodesposter,
delta_kortfristiga_fordringar: deltaKortfristigaFordringar,
delta_varulager: deltaVarulager,
delta_kortfristiga_skulder: deltaKortfristigaSkulder,
skatt_betald: skattBetald,
total: totalLopande,
},
investerings: {
forvarv_anlaggningar: forvarv,
avyttring_anlaggningar: avyttring,
total: totalInvesterings,
},
finansierings: {
delta_lan: deltaLan,
utdelningar,
nyemission,
erhallna_aktieagartillskott: erhallnaAktieagartillskott,
total: totalFinansierings,
},
total_cash_flow: totalCashFlow,
reconciliation: {
opening_cash_1xxx: openingCash,
closing_cash_1xxx: closingCash,
delta_actual: deltaActual,
delta_calculated: totalCashFlow,
mismatch_amount: mismatchAmount,
is_reconciled: isReconciled,
},
}
}