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>
This commit is contained in:
Jakob Wennberg
2026-06-15 10:17:44 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 7af4b96d10
commit 88f49c0ccc
33 changed files with 774 additions and 136 deletions
@@ -296,6 +296,87 @@ describe('generateKassaflodesanalys', () => {
expect(report.reconciliation.is_reconciled).toBe(true)
})
it('records erhållna aktieägartillskott (2093) as financing inflow and reconciles (#716)', async () => {
// Repro from issue #716: debit 1930, credit 2093 (10 000 kr shareholder
// contribution). Before the fix, 2093 was unmapped — financing showed 0
// and the reconciliation failed by exactly the contributed amount.
mockTrialBalance.mockResolvedValue({
rows: [
makeRow({
account_number: '1930',
account_class: 1,
period_debit: 10000,
closing_debit: 10000,
}),
makeRow({
account_number: '2093',
account_class: 2,
period_credit: 10000,
closing_credit: 10000,
}),
],
totalDebit: 10000,
totalCredit: 10000,
isBalanced: true,
})
mockIncomeStatement.mockResolvedValue(makeIs())
const report = await generateKassaflodesanalys(
makeSupabase(PERIOD),
'company-1',
'period-1'
)
expect(report.finansierings.erhallna_aktieagartillskott).toBe(10000)
expect(report.finansierings.nyemission).toBe(0)
expect(report.finansierings.total).toBe(10000)
expect(report.reconciliation.delta_actual).toBe(10000)
expect(report.reconciliation.delta_calculated).toBe(10000)
expect(report.reconciliation.is_reconciled).toBe(true)
})
it('records nyemission premium on överkursfond (2097) as financing inflow and reconciles', async () => {
// A 100 000 kr emission: 25 000 to 2081 (aktiekapital), 75 000 premium to
// 2097 (fri överkursfond). 2097 was previously outside the nyemission
// prefixes — same reconciliation-failure class as #716.
mockTrialBalance.mockResolvedValue({
rows: [
makeRow({
account_number: '1930',
account_class: 1,
period_debit: 100000,
closing_debit: 100000,
}),
makeRow({
account_number: '2081',
account_class: 2,
period_credit: 25000,
closing_credit: 25000,
}),
makeRow({
account_number: '2097',
account_class: 2,
period_credit: 75000,
closing_credit: 75000,
}),
],
totalDebit: 100000,
totalCredit: 100000,
isBalanced: true,
})
mockIncomeStatement.mockResolvedValue(makeIs())
const report = await generateKassaflodesanalys(
makeSupabase(PERIOD),
'company-1',
'period-1'
)
expect(report.finansierings.nyemission).toBe(100000)
expect(report.finansierings.total).toBe(100000)
expect(report.reconciliation.is_reconciled).toBe(true)
})
it('detects mismatch when a cash movement has no balancing classification', async () => {
// Plant an invariant violation: 1930 went up by 10 000 but no offsetting
// entry on any tracked account class. This is the kind of bug a real
+108
View File
@@ -5,7 +5,9 @@ import {
calculateRevenueGrowth,
calculateExpenseRatio,
calculateAvgPaymentDays,
calculateVatLiability,
} from '../kpi'
import { VAT_INPUT_ACCOUNTS, VAT_OUTPUT_ACCOUNTS } from '../vat-declaration'
import type { IncomeStatementReport, TrialBalanceRow } from '@/types'
function makeIncomeStatement(
@@ -105,6 +107,112 @@ describe('calculateCashPosition', () => {
})
})
describe('calculateVatLiability', () => {
it('returns positive liability for standard output VAT', () => {
const rows = [
makeTrialBalanceRow({ account_number: '2611', closing_credit: 25000 }),
makeTrialBalanceRow({ account_number: '2641', closing_debit: 10000 }),
]
expect(calculateVatLiability(rows)).toBe(15000)
})
it('nets EU reverse charge (2614 + 2645) to zero — issue #715', () => {
const rows = [
makeTrialBalanceRow({ account_number: '2614', closing_credit: 2500 }),
makeTrialBalanceRow({ account_number: '2645', closing_debit: 2500 }),
]
expect(calculateVatLiability(rows)).toBe(0)
})
it('nets domestic reverse charge (2614 + 2647) to zero', () => {
const rows = [
makeTrialBalanceRow({ account_number: '2614', closing_credit: 1200 }),
makeTrialBalanceRow({ account_number: '2647', closing_debit: 1200 }),
]
expect(calculateVatLiability(rows)).toBe(0)
})
it('nets import VAT (2615 + 2645) to zero', () => {
const rows = [
makeTrialBalanceRow({ account_number: '2615', closing_credit: 800 }),
makeTrialBalanceRow({ account_number: '2645', closing_debit: 800 }),
]
expect(calculateVatLiability(rows)).toBe(0)
})
it('reverse charge does not distort the net position alongside regular sales', () => {
const rows = [
makeTrialBalanceRow({ account_number: '2611', closing_credit: 5000 }),
makeTrialBalanceRow({ account_number: '2641', closing_debit: 2000 }),
makeTrialBalanceRow({ account_number: '2614', closing_credit: 1000 }),
makeTrialBalanceRow({ account_number: '2645', closing_debit: 1000 }),
]
// Old formula gave 5000 − (2000 + 1000) = 2000; correct is 3000
expect(calculateVatLiability(rows)).toBe(3000)
})
it('returns negative for net VAT receivable', () => {
const rows = [
makeTrialBalanceRow({ account_number: '2611', closing_credit: 1000 }),
makeTrialBalanceRow({ account_number: '2641', closing_debit: 4000 }),
]
expect(calculateVatLiability(rows)).toBe(-3000)
})
it('ignores accounts outside the VAT declaration set', () => {
const rows = [
makeTrialBalanceRow({ account_number: '2650', closing_credit: 9000 }), // redovisningskonto för moms
makeTrialBalanceRow({ account_number: '1930', closing_debit: 9000 }),
]
expect(calculateVatLiability(rows)).toBe(0)
})
it('respects account overrides, splitting input/output on the 264x prefix', () => {
const rows = [
makeTrialBalanceRow({ account_number: '2611', closing_credit: 5000 }),
makeTrialBalanceRow({ account_number: '2614', closing_credit: 1000 }),
makeTrialBalanceRow({ account_number: '2641', closing_debit: 2000 }),
]
// Override excludes 2614
expect(calculateVatLiability(rows, ['2611', '2641'])).toBe(3000)
})
it('handles debit balances on output accounts (corrections)', () => {
const rows = [
makeTrialBalanceRow({ account_number: '2611', closing_credit: 5000, closing_debit: 500 }),
]
expect(calculateVatLiability(rows)).toBe(4500)
})
})
describe('VAT widget account lists (derived from ACCOUNT_RUTA)', () => {
// Drift guard: an ACCOUNT_RUTA change that alters these lists changes the
// dashboard widget's semantics — update this snapshot deliberately.
it('output accounts cover rutor 10–12, 30–32 and 60–62', () => {
expect([...VAT_OUTPUT_ACCOUNTS].sort()).toEqual([
'2610', '2611', '2612', '2613', '2614', '2615', '2616', '2618',
'2620', '2621', '2622', '2623', '2624', '2625', '2626', '2628',
'2630', '2631', '2632', '2633', '2634', '2635', '2636', '2638',
])
})
it('input accounts cover ruta 48', () => {
expect([...VAT_INPUT_ACCOUNTS].sort()).toEqual([
'2640', '2641', '2642', '2645', '2646', '2647', '2649',
])
})
it('the prefix split used by calculateVatLiability is exact for the defaults', () => {
for (const account of VAT_OUTPUT_ACCOUNTS) {
expect(account.startsWith('26')).toBe(true)
expect(account.startsWith('264')).toBe(false)
}
for (const account of VAT_INPUT_ACCOUNTS) {
expect(account.startsWith('264')).toBe(true)
}
})
})
describe('calculateRevenueGrowth', () => {
it('returns positive growth', () => {
// (120000 - 100000) / 100000 * 100 = 20%
@@ -314,6 +314,12 @@ export function KassaflodesanalysPDF({
<Text style={styles.label}>Nyemission</Text>
<Text style={styles.amount}>{formatAmount(report.finansierings.nyemission)}</Text>
</View>
<View style={styles.row}>
<Text style={styles.label}>Erhållna aktieägartillskott</Text>
<Text style={styles.amount}>
{formatAmount(report.finansierings.erhallna_aktieagartillskott)}
</Text>
</View>
<View style={styles.subtotalRow}>
<Text style={styles.subtotalLabel}>
Kassaflöde från finansieringsverksamheten
+20 -5
View File
@@ -26,7 +26,8 @@ import type { TrialBalanceRow } from '@/types'
*
* 10xx-13xx Anläggningstillgångar (capital goods) → investing
*
* 20xx Eget kapital (nyemission, utdelning) → financing
* 20xx Eget kapital (nyemission, utdelning,
* erhållna aktieägartillskott 2093) → financing
* 23xx Långfristiga skulder (lån) → financing
*
* 19xx Kassa och bank → reconciliation (target)
@@ -61,6 +62,7 @@ export type KassaflodesanalysReport = {
delta_lan: number
utdelningar: number
nyemission: number
erhallna_aktieagartillskott: number
total: number
}
total_cash_flow: number
@@ -281,12 +283,24 @@ export async function generateKassaflodesanalys(
// 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 (premium) deltas specifically to
// avoid double-counting 2099 (årets resultat is non-cash).
const nyemissionDebit = sumDeltaByPrefix(rows, ['2081', '2082', '2083', '2087'])
// 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)
const totalFinansierings = r2(deltaLan + utdelningar + nyemission)
// 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)
@@ -332,6 +346,7 @@ export async function generateKassaflodesanalys(
delta_lan: deltaLan,
utdelningar,
nyemission,
erhallna_aktieagartillskott: erhallnaAktieagartillskott,
total: totalFinansierings,
},
total_cash_flow: totalCashFlow,
+3 -1
View File
@@ -1,4 +1,5 @@
import type { KPIPreferences } from '@/types'
import { VAT_INPUT_ACCOUNTS, VAT_OUTPUT_ACCOUNTS } from '@/lib/reports/vat-declaration'
export interface KPIDefinition {
id: string
@@ -37,7 +38,8 @@ export const KPI_DEFINITIONS: KPIDefinition[] = [
},
{
id: 'vatLiability',
defaultAccounts: ['2611', '2621', '2631', '2641', '2645'],
// Same 26xx accounts as the momsdeklaration (ruta 49) — see vat-declaration.ts
defaultAccounts: [...VAT_OUTPUT_ACCOUNTS, ...VAT_INPUT_ACCOUNTS],
customizableAccounts: true,
defaultVisible: true,
format: 'currency',
+40
View File
@@ -1,4 +1,5 @@
import type { IncomeStatementReport, TrialBalanceRow } from '@/types'
import { VAT_INPUT_ACCOUNTS, VAT_OUTPUT_ACCOUNTS } from '@/lib/reports/vat-declaration'
/**
* Calculate gross margin from income statement.
@@ -30,6 +31,45 @@ export function calculateCashPosition(rows: TrialBalanceRow[]): number {
return Math.round(total * 100) / 100
}
/**
* Calculate net VAT liability (positive = att betala) or receivable
* (negative = att återfå) from trial balance rows.
*
* Uses the same 26xx accounts as the momsdeklaration so the result mirrors
* ruta 49: output VAT (rutor 10–12, 30–32, 60–62) − input VAT (ruta 48).
* Reverse-charge and import pairs (e.g. 2614 credit + 2645 debit) therefore
* net to zero instead of inflating the receivable (#715).
*
* `accounts` overrides the default list (user KPI preferences); accounts in
* the 264x range count as input VAT, all other 26xx accounts as output VAT.
*/
export function calculateVatLiability(
rows: TrialBalanceRow[],
accounts?: string[]
): number {
const vatAccounts =
accounts && accounts.length > 0
? accounts
: [...VAT_OUTPUT_ACCOUNTS, ...VAT_INPUT_ACCOUNTS]
const outputVat = rows
.filter(
(r) =>
vatAccounts.includes(r.account_number) &&
r.account_number.startsWith('26') &&
!r.account_number.startsWith('264')
)
.reduce((sum, r) => sum + (r.closing_credit - r.closing_debit), 0)
const inputVat = rows
.filter(
(r) =>
vatAccounts.includes(r.account_number) && r.account_number.startsWith('264')
)
.reduce((sum, r) => sum + (r.closing_debit - r.closing_credit), 0)
return Math.round((outputVat - inputVat) * 100) / 100
}
/**
* Calculate revenue growth between two periods.
* Returns percentage or null if no previous period data.
+14
View File
@@ -127,6 +127,20 @@ export const ACCOUNT_RUTA: Record<string, { box: keyof VatDeclarationRutor; side
const VAT_ACCOUNTS = Object.keys(ACCOUNT_RUTA)
/**
* 26xx output VAT accounts feeding rutor 10/11/12, 30/31/32 and 60/61/62.
* Derived from ACCOUNT_RUTA so the KPI vatLiability widget can never drift
* from the momsdeklaration (ruta 49) calculation.
*/
export const VAT_OUTPUT_ACCOUNTS = Object.entries(ACCOUNT_RUTA)
.filter(([account, mapping]) => account.startsWith('26') && mapping.side === 'credit')
.map(([account]) => account)
/** Input VAT accounts feeding ruta 48 (2640–2649 series). */
export const VAT_INPUT_ACCOUNTS = Object.entries(ACCOUNT_RUTA)
.filter(([, mapping]) => mapping.box === 'ruta48')
.map(([account]) => account)
/**
* Calculate period start and end dates
*/