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:
co-authored by
Claude Opus 4.8
parent
7af4b96d10
commit
88f49c0ccc
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user