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
@@ -42,6 +42,12 @@ vi.mock('@/lib/bookkeeping/engine', () => ({
getNextVoucherNumber: vi.fn(async () => 1),
}))
// On-demand BAS backfill — never triggered here (accounts resolve on the
// first read in every scenario below).
vi.mock('@/lib/bookkeeping/account-backfill', () => ({
backfillStandardBASAccounts: vi.fn(async () => []),
}))
// resolvePeriodStatusForDate is the classification gate — mock it directly so
// each test controls whether the target date is open/locked/closed/uncovered.
const mockResolve = vi.fn()
@@ -131,17 +137,18 @@ describe('recordateEntry', () => {
results = [
{ data: original, error: null }, // 0 recordate fetch original
{ data: { name: '2025', period_start: '2025-01-01', period_end: '2025-12-31' }, error: null }, // 1 target period
{ data: reversalEntry, error: null }, // 2 insert reversal
{ data: null, error: null }, // 3 reversal lines
{ data: null, error: null }, // 4 post reversal
{ data: [{ id: 'a1', account_number: '6230' }, { id: 'a2', account_number: '1930' }], error: null }, // 5 accounts
{ data: [{ id: 'a1', account_number: '6230' }, { id: 'a2', account_number: '1930' }], error: null }, // 2 accounts (Step 0)
{ data: reversalEntry, error: null }, // 3 insert reversal
{ data: null, error: null }, // 4 reversal lines
{ data: null, error: null }, // 5 post reversal
{ data: correctedEntry, error: null }, // 6 insert corrected
{ data: null, error: null }, // 7 corrected lines
{ data: null, error: null }, // 8 post corrected
{ data: [{ id: 'orig-1' }], error: null }, // 9 CAS
{ data: { ...reversalEntry, lines: [] }, error: null }, // 10 final reversal
{ data: { ...correctedEntry, lines: [] }, error: null }, // 11 final corrected
{ data: null, error: null }, // 12 relink documents
{ data: null, error: null }, // 10 relink transactions
{ data: null, error: null }, // 11 relink documents
{ data: { ...reversalEntry, lines: [] }, error: null }, // 12 final reversal
{ data: { ...correctedEntry, lines: [] }, error: null }, // 13 final corrected
]
const supabase = makeClient()
const result = await recordateEntry(supabase as never, 'company-1', 'user-1', 'orig-1', '2025-07-03')
@@ -37,6 +37,12 @@ vi.mock('@/lib/bookkeeping/engine', () => ({
getNextVoucherNumber: vi.fn(async () => ++resultIdx), // just increment
}))
// On-demand BAS backfill — default: nothing seedable. Tests override.
const mockBackfill = vi.fn()
vi.mock('@/lib/bookkeeping/account-backfill', () => ({
backfillStandardBASAccounts: (...args: unknown[]) => mockBackfill(...args),
}))
import { correctEntry } from '../storno-service'
import { validateBalance, getNextVoucherNumber } from '@/lib/bookkeeping/engine'
@@ -51,6 +57,7 @@ beforeEach(() => {
vi.mocked(validateBalance).mockReturnValue({ valid: true, totalDebit: 1000, totalCredit: 1000 })
let voucherNum = 0
vi.mocked(getNextVoucherNumber).mockImplementation(async () => ++voucherNum)
mockBackfill.mockResolvedValue([])
})
describe('correctEntry', () => {
@@ -78,15 +85,14 @@ describe('correctEntry', () => {
results = [
// 0: fetch original (.single())
{ data: originalEntry, error: null },
// 1: insert reversal entry (.single())
{ data: reversalEntry, error: null },
// 2: insert reversal lines (thenable)
{ data: null, error: null },
// 3: update reversal to posted (thenable)
{ data: null, error: null },
// -- getNextVoucherNumber increments resultIdx --
// 4: fetch accounts for corrected lines (thenable)
// 1: fetch accounts for corrected lines — Step 0 pre-validation (thenable)
{ data: [{ id: 'acc-5420', account_number: '5420' }, { id: 'acc-1930', account_number: '1930' }], error: null },
// 2: insert reversal entry (.single())
{ data: reversalEntry, error: null },
// 3: insert reversal lines (thenable)
{ data: null, error: null },
// 4: update reversal to posted (thenable)
{ data: null, error: null },
// 5: insert corrected entry (.single())
{ data: correctedEntry, error: null },
// 6: insert corrected lines (thenable)
@@ -95,9 +101,13 @@ describe('correctEntry', () => {
{ data: null, error: null },
// 8: CAS update original to reversed (thenable, needs array for .length check)
{ data: [{ id: 'orig-1' }], error: null },
// 9: fetch final reversal (.single())
// 9: relink transactions original → corrected (thenable)
{ data: null, error: null },
// 10: relink documents original → corrected (thenable)
{ data: null, error: null },
// 11: fetch final reversal (.single())
{ data: { ...reversalEntry, lines: [] }, error: null },
// 10: fetch final corrected (.single())
// 12: fetch final corrected (.single())
{ data: { ...correctedEntry, lines: correctedLines }, error: null },
]
}
@@ -141,10 +151,10 @@ describe('correctEntry', () => {
results = [
{ data: originalEntry, error: null }, // 0: fetch original
{ data: reversalEntry, error: null }, // 1: insert reversal
{ data: null, error: null }, // 2: insert reversal lines
{ data: null, error: null }, // 3: post reversal
{ data: [{ id: 'acc-5420', account_number: '5420' }, { id: 'acc-1930', account_number: '1930' }], error: null }, // 4: accounts
{ data: [{ id: 'acc-5420', account_number: '5420' }, { id: 'acc-1930', account_number: '1930' }], error: null }, // 1: accounts (Step 0)
{ data: reversalEntry, error: null }, // 2: insert reversal
{ data: null, error: null }, // 3: insert reversal lines
{ data: null, error: null }, // 4: post reversal
{ data: correctedEntry, error: null }, // 5: insert corrected
{ data: null, error: null }, // 6: insert corrected lines
{ data: null, error: null }, // 7: post corrected
@@ -166,10 +176,10 @@ describe('correctEntry', () => {
results = [
{ data: originalEntry, error: null }, // 0: fetch original
{ data: reversalEntry, error: null }, // 1: insert reversal
{ data: null, error: null }, // 2: insert reversal lines
{ data: null, error: null }, // 3: post reversal
{ data: [{ id: 'acc-5420', account_number: '5420' }, { id: 'acc-1930', account_number: '1930' }], error: null }, // 4: accounts
{ data: [{ id: 'acc-5420', account_number: '5420' }, { id: 'acc-1930', account_number: '1930' }], error: null }, // 1: accounts (Step 0)
{ data: reversalEntry, error: null }, // 2: insert reversal
{ data: null, error: null }, // 3: insert reversal lines
{ data: null, error: null }, // 4: post reversal
{ data: null, error: { message: 'DB error' } }, // 5: insert corrected FAILS
{ data: null, error: null }, // 6: cancelEntry reversal update
{ data: null, error: null }, // 7: cancelEntry reversal lines delete
@@ -186,10 +196,11 @@ describe('correctEntry', () => {
results = [
{ data: originalEntry, error: null }, // 0: fetch original
{ data: reversalEntry, error: null }, // 1: insert reversal
{ data: null, error: { message: 'line error' } }, // 2: insert reversal lines FAILS
{ data: null, error: null }, // 3: cancelEntry update
{ data: null, error: null }, // 4: cancelEntry lines delete
{ data: [{ id: 'acc-5420', account_number: '5420' }, { id: 'acc-1930', account_number: '1930' }], error: null }, // 1: accounts (Step 0)
{ data: reversalEntry, error: null }, // 2: insert reversal
{ data: null, error: { message: 'line error' } }, // 3: insert reversal lines FAILS
{ data: null, error: null }, // 4: cancelEntry update
{ data: null, error: null }, // 5: cancelEntry lines delete
]
const supabase = makeClient()
@@ -302,16 +313,18 @@ describe('correctEntry', () => {
results = [
{ data: correctionAsOriginal, error: null }, // 0: fetch original (the prior correction)
{ data: secondReversal, error: null }, // 1: insert reversal
{ data: null, error: null }, // 2: insert reversal lines
{ data: null, error: null }, // 3: post reversal
{ data: [{ id: 'acc-5430', account_number: '5430' }, { id: 'acc-1930', account_number: '1930' }], error: null }, // 4: accounts
{ data: [{ id: 'acc-5430', account_number: '5430' }, { id: 'acc-1930', account_number: '1930' }], error: null }, // 1: accounts (Step 0)
{ data: secondReversal, error: null }, // 2: insert reversal
{ data: null, error: null }, // 3: insert reversal lines
{ data: null, error: null }, // 4: post reversal
{ data: secondCorrection, error: null }, // 5: insert corrected
{ data: null, error: null }, // 6: insert corrected lines
{ data: null, error: null }, // 7: post corrected
{ data: [{ id: 'correction-1' }], error: null }, // 8: CAS update
{ data: { ...secondReversal, lines: [] }, error: null }, // 9: fetch final reversal
{ data: { ...secondCorrection, lines: [] }, error: null }, // 10: fetch final corrected
{ data: null, error: null }, // 9: relink transactions
{ data: null, error: null }, // 10: relink documents
{ data: { ...secondReversal, lines: [] }, error: null }, // 11: fetch final reversal
{ data: { ...secondCorrection, lines: [] }, error: null }, // 12: fetch final corrected
]
const supabase = makeClient()
@@ -325,6 +338,58 @@ describe('correctEntry', () => {
expect(result.corrected.source_type).toBe('correction')
})
it('fails fast on unknown accounts — BEFORE the storno exists or a voucher number is consumed', async () => {
// Regression: the old flow created+posted the storno first, then hit
// AccountsNotInChartError on the corrected lines and had to cancel the
// storno again — leaving a voided 0 kr storno in the chain (the user's
// "A98") and burning voucher numbers (the missing "A99").
results = [
{ data: originalEntry, error: null }, // 0: fetch original
{ data: [{ id: 'acc-1930', account_number: '1930' }], error: null }, // 1: accounts — 5420 missing
]
mockBackfill.mockResolvedValue([]) // not seedable (e.g. deactivated / unknown)
const supabase = makeClient()
await expect(
correctEntry(supabase as never, 'company-1', 'user-1', 'orig-1', correctedLines)
).rejects.toMatchObject({ code: 'ACCOUNTS_NOT_IN_CHART' })
// Nothing was written to the journal and no voucher number was fetched.
expect(inserts.filter((i) => i.table === 'journal_entries')).toHaveLength(0)
expect(inserts.filter((i) => i.table === 'journal_entry_lines')).toHaveLength(0)
expect(getNextVoucherNumber).not.toHaveBeenCalled()
})
it('seeds a standard BAS account missing from the chart and proceeds', async () => {
const reversalEntry = makeJournalEntry({ id: 'reversal-1', reverses_id: 'orig-1' })
const correctedEntry = makeJournalEntry({ id: 'corrected-1', correction_of_id: 'orig-1' })
results = [
{ data: originalEntry, error: null }, // 0: fetch original
{ data: [{ id: 'acc-1930', account_number: '1930' }], error: null }, // 1: accounts — 5420 missing
// -- backfill seeds 5420 --
{ data: [{ id: 'acc-5420', account_number: '5420' }, { id: 'acc-1930', account_number: '1930' }], error: null }, // 2: re-resolve
{ data: reversalEntry, error: null }, // 3: insert reversal
{ data: null, error: null }, // 4: reversal lines
{ data: null, error: null }, // 5: post reversal
{ data: correctedEntry, error: null }, // 6: insert corrected
{ data: null, error: null }, // 7: corrected lines
{ data: null, error: null }, // 8: post corrected
{ data: [{ id: 'orig-1' }], error: null }, // 9: CAS
{ data: null, error: null }, // 10: relink transactions
{ data: null, error: null }, // 11: relink documents
{ data: { ...reversalEntry, lines: [] }, error: null }, // 12: final reversal
{ data: { ...correctedEntry, lines: [] }, error: null }, // 13: final corrected
]
mockBackfill.mockResolvedValue(['5420'])
const supabase = makeClient()
const result = await correctEntry(
supabase as never, 'company-1', 'user-1', 'orig-1', correctedLines
)
expect(result.corrected.id).toBe('corrected-1')
expect(mockBackfill).toHaveBeenCalledWith(expect.anything(), 'company-1', 'user-1', ['5420'])
})
it('emits journal_entry.corrected event', async () => {
setupResults()
@@ -368,16 +433,18 @@ describe('correctEntry — date/period override (recordate engine)', () => {
results = [
{ data: originalEntry, error: null }, // 0 fetch original
{ data: { name: '2025', period_start: '2025-01-01', period_end: '2025-12-31' }, error: null }, // 1 target period
{ data: reversalEntry, error: null }, // 2 insert reversal
{ data: null, error: null }, // 3 reversal lines
{ data: null, error: null }, // 4 post reversal
{ data: [{ id: 'acc-5410', account_number: '5410' }, { id: 'acc-1930', account_number: '1930' }], error: null }, // 5 accounts
{ data: [{ id: 'acc-5410', account_number: '5410' }, { id: 'acc-1930', account_number: '1930' }], error: null }, // 2 accounts (Step 0)
{ data: reversalEntry, error: null }, // 3 insert reversal
{ data: null, error: null }, // 4 reversal lines
{ data: null, error: null }, // 5 post reversal
{ data: correctedEntry, error: null }, // 6 insert corrected
{ data: null, error: null }, // 7 corrected lines
{ data: null, error: null }, // 8 post corrected
{ data: [{ id: 'orig-1' }], error: null }, // 9 CAS
{ data: { ...reversalEntry, lines: [] }, error: null }, // 10 final reversal
{ data: { ...correctedEntry, lines: [] }, error: null }, // 11 final corrected
{ data: null, error: null }, // 10 relink transactions
{ data: null, error: null }, // 11 relink documents
{ data: { ...reversalEntry, lines: [] }, error: null }, // 12 final reversal
{ data: { ...correctedEntry, lines: [] }, error: null }, // 13 final corrected
]
const supabase = makeClient()
const result = await correctEntry(
+76 -25
View File
@@ -6,6 +6,7 @@ import type {
JournalEntryLine,
} from '@/types'
import { validateBalance, getNextVoucherNumber } from '@/lib/bookkeeping/engine'
import { backfillStandardBASAccounts } from '@/lib/bookkeeping/account-backfill'
import { resolvePeriodStatusForDate } from '@/lib/core/bookkeeping/period-service'
import {
AccountsNotInChartError,
@@ -204,6 +205,43 @@ export async function correctEntry(
}
}
// ===== Step 0: Resolve corrected-line accounts BEFORE any journal write =====
// The old flow created and posted the storno first and only then discovered
// that a corrected line referenced an account outside the chart. The storno
// then had to be cancelled again, which left a voided 0 kr storno in the
// correction chain and permanently burned voucher numbers (next_voucher_number
// is a consuming counter → an unexplained BFNAR 2013:2 gap). Validate up
// front instead: standard BAS accounts missing from the chart are seeded on
// demand (same as createDraftEntry); unknown numbers or deliberately
// deactivated accounts fail fast with nothing written.
const accountNumbers = [...new Set(correctedLines.map((l) => l.account_number))]
const resolveActiveAccountIds = async (): Promise<Map<string, string>> => {
const { data: accounts } = await supabase
.from('chart_of_accounts')
.select('id, account_number')
.eq('company_id', companyId)
.eq('is_active', true)
.in('account_number', accountNumbers)
const map = new Map<string, string>()
for (const account of accounts || []) {
map.set(account.account_number, account.id)
}
return map
}
let accountIdMap = await resolveActiveAccountIds()
let missingAccounts = accountNumbers.filter((num) => !accountIdMap.has(num))
if (missingAccounts.length > 0) {
const seeded = await backfillStandardBASAccounts(supabase, companyId, userId, missingAccounts)
if (seeded.length > 0) {
accountIdMap = await resolveActiveAccountIds()
missingAccounts = accountNumbers.filter((num) => !accountIdMap.has(num))
}
if (missingAccounts.length > 0) {
throw new AccountsNotInChartError(missingAccounts)
}
}
// ===== Step 1: Create storno (reversal) entry =====
const reversalVoucherNumber = await getNextVoucherNumber(
supabase,
@@ -288,26 +326,8 @@ export async function correctEntry(
original.voucher_series || 'A'
)
// Resolve account IDs for corrected lines — only active rows count
const accountNumbers = [...new Set(correctedLines.map((l) => l.account_number))]
const { data: accounts } = await supabase
.from('chart_of_accounts')
.select('id, account_number')
.eq('company_id', companyId)
.eq('is_active', true)
.in('account_number', accountNumbers)
const accountIdMap = new Map<string, string>()
for (const account of accounts || []) {
accountIdMap.set(account.account_number, account.id)
}
// Validate all account numbers resolved to IDs
const missingAccounts = accountNumbers.filter(num => !accountIdMap.has(num))
if (missingAccounts.length > 0) {
throw new AccountsNotInChartError(missingAccounts)
}
// Account IDs were resolved (and standard BAS accounts seeded) in Step 0,
// before the storno existed — nothing to clean up if we got this far.
const { data: newEntry, error: correctedError } = await supabase
.from('journal_entries')
.insert({
@@ -394,6 +414,15 @@ export async function correctEntry(
throw new EntryAlreadyReversedError()
}
// Re-point bank transactions and underlag from the original to the corrected
// entry. The original is now status 'reversed'; the corrected entry is the
// live representation of the affärshändelse, so the transaction row should
// keep reading as booked against it (and stay correctable/uncategorizable),
// and the underlag should travel with it. Best-effort — the correction_of_id
// chain preserves traceability even if either relink fails.
await relinkTransactionsToEntry(supabase, companyId, originalEntryId, correctedEntry!.id)
await relinkDocumentsToEntry(supabase, companyId, originalEntryId, correctedEntry!.id)
// ===== Step 3: Fetch complete entries =====
const { data: finalReversal } = await supabase
.from('journal_entries')
@@ -516,14 +545,36 @@ export async function recordateEntry(
}
)
// Move the underlag to the corrected entry so it doesn't surface as a
// "verifikat utan underlag" in the target year. Best-effort — the
// correction_of_id chain preserves traceability even if this fails.
await relinkDocumentsToEntry(supabase, companyId, originalEntryId, result.corrected.id)
// Underlag and bank-transaction links follow the corrected entry —
// correctEntry handles both relinks for every correction flavour.
return result
}
/**
* Re-point every bank transaction from one entry to another. Used when a
* verifikation is corrected so the transaction row keeps reading as booked
* against the live (corrected) entry instead of the reversed original.
* Failures are logged, not thrown — the correction chain stays traceable.
*/
async function relinkTransactionsToEntry(
supabase: SupabaseClient,
companyId: string,
fromEntryId: string,
toEntryId: string
): Promise<void> {
const { error } = await supabase
.from('transactions')
.update({ journal_entry_id: toEntryId })
.eq('company_id', companyId)
.eq('journal_entry_id', fromEntryId)
if (error) {
console.error(
`[storno] relinkTransactionsToEntry: failed to move transactions ${fromEntryId} → ${toEntryId}:`,
error.message
)
}
}
/**
* Re-point every document_attachment from one entry to another. Used when a
* verifikation is moved to a different period so its underlag travels with the