From 2dff83e2f3144c85570385ac956293ca35ab9c2c Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Thu, 6 Aug 2026 12:12:05 +0200 Subject: [PATCH] feat(bokslut): kontantmetoden year-end cut-off for fordringar and skulder (#1432) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(bokslut): kontantmetoden year-end cut-off for fordringar and skulder Under kontantmetoden nothing reaches 1510/2440 during the year, but BFL 5 kap 2 § still requires fordringar och skulder to be booked at rakenskapsarets utgang. That conversion did not exist: the AR/AP tie-outs were permanently unreconciled by construction for all cash companies, and the balance sheet omitted every open invoice. Adds lib/core/bookkeeping/kontantmetod-cutoff.ts: Fordringar: Debit 1510 / Credit 30xx / Credit 2618|2628|2638 Skulder: Debit 4-6xxx / Debit 2648 / Credit 2440 Moms goes to the VILANDE accounts, never 2611/2641. Under bokslutsmetoden moms is reported at payment, and the vilande accounts are deliberately absent from ACCOUNT_RUTA / ACCOUNT_TO_BOX, so parking it there keeps it out of the momsdeklaration until the invoice is actually paid. Booking it to 2641 would claim the deduction a period early. Two aggregate verifikat, each reversed on day 1 of the next period, and no invoices.journal_entry_id link: the payment flows route on that link, so per-invoice linking would send every new-year payment down the accrual clearing path against a receivable the vandning already removed. Leaving it unset means a new-year payment still books the normal kontantmetoden cash entry at the real payment date. Outstanding is computed from payment DATES, not remaining_amount: an invoice settled in January was still a fordran on 31 December, and reading remaining_amount would shrink the cut-off every day the bokslut is delayed. Surfaced as a bokslut wizard reminder (warning, not a blocker: promoting it would newly block every cash company mid-bokslut, which is a separate call). Co-Authored-By: Claude Opus 5 (1M context) * fix(bokslut): address compliance review on the kontantmetoden cut-off Three findings from the Swedish compliance review, all real: 1. BFL 5 kap 6-7 § traceability. The aggregate verifikat collected invoice references but never wrote them, so an examiner could not trace the 1510/2440 posting back to the affarshandelser behind it. Invoice numbers now go into the entry `notes` via buildCutoffNote(), truncated past 50 so the note stays a pointer to the reskontra rather than a copy of it. 2. Non-atomic posting. The cut-off and its vandning were two sequential creates with no rollback: if the reversal threw, 1510/2440 stayed permanently inflated and every new-year payment would double-book, which is exactly what the module docstring warns about. postKontantmetodCutoff now asserts the target period exists, is open, and contains the reversal date BEFORE posting anything, so the common failures refuse without writing. If a reversal still fails after its cut-off committed, the cut-off is stornoed through reverseEntry() (BFL 5 kap 5 §: never edit or delete a posted entry) and the original error is rethrown. 3. Silent vat_treatment default. Missing vat_treatment fell back to 25 %, which would route a 12/6/undantagen invoice to the wrong vilande account AND the wrong revenue account. Such rows are now collected into CutoffCollection.unknownVatTreatment, excluded from the cut-off, refused by the posting step, and surfaced as their own wizard reminder. Adds 11 cases for postKontantmetodCutoff, which had none: every refusal path asserts nothing was posted, and the storno-compensation path is covered in both the happy and the storno-also-failed direction. Co-Authored-By: Claude Opus 5 (1M context) * fix(bokslut): never split a reverse charge across the cut-off Second compliance round. Verified the three data-dependent findings against production before changing anything; two needed no change, one is hardened: - Credit notes are NOT silently dropped: all 22 credit notes on prod carry document_type='invoice', so they are inside the collected set exactly as the comment claims. The filter only excludes proforma and delivery_note. - Vilande account numbers verified against the BAS 2026 chart in lib/bookkeeping/bas-data: 2618/2628/2638 utgaende, 2648 ingaende. The suggested 2617/2627/2637 do not exist. - Reverse charge: all 123 RC supplier invoices on prod carry vat_amount = 0, so no RC moms could reach 2648 today. That was an implicit data invariant, not an enforced one. CutoffPayable now carries reverseCharge and forces the cut-off moms to 0 for those rows, so a stray amount can never post a one-sided reverse charge into the single vilande bucket. The self-assessed output/input pair stays with the payment entry, after the vandning. Also names the reskontra as the underlag in a truncated aggregate note, so the verifikat points at its specification rather than implying the listed subset is the whole of it. Co-Authored-By: Claude Opus 5 (1M context) * fix(bokslut): surface stray moms on momsfri invoices instead of absorbing it Third compliance round, one legitimate new finding: moms on a treatment that cannot carry Swedish output moms (export, omvand betalningsskyldighet, undantagen) was folded into the revenue line with only a log.warn. That balances the verifikat while silently swallowing a real invoicing error, which is the netting the swedish-vat reference prohibits, and it was inconsistent with how the same module already treats a missing vat_treatment. Those rows now travel the same path as a missing treatment: collected into CutoffCollection.strayVatOnZeroRate, excluded from the cut-off, refused by the posting step, and surfaced as their own wizard reminder. buildCutoffLines keeps its balancing fallback for the case where such a row reaches it directly: it is now a last resort rather than the normal path, and it must still never invent a moms account nor unbalance the verifikat. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) --- DECISIONS.md | 1 + .../__tests__/readiness-aggregator.test.ts | 72 ++ lib/bokslut/readiness-aggregator.ts | 62 +- .../__tests__/kontantmetod-cutoff.test.ts | 429 +++++++++++ lib/core/bookkeeping/kontantmetod-cutoff.ts | 714 ++++++++++++++++++ 5 files changed, 1276 insertions(+), 2 deletions(-) create mode 100644 lib/core/bookkeeping/__tests__/kontantmetod-cutoff.test.ts create mode 100644 lib/core/bookkeeping/kontantmetod-cutoff.ts diff --git a/DECISIONS.md b/DECISIONS.md index 09abba15..04e559b2 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -803,3 +803,4 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-06] ROT/RUT payout strings were placed in the invoice_editor namespace while RotRutPayoutDialog and the invoices page read useTranslations('invoices'), so all 44 labels rendered as raw "invoices.rot_rut_*" key paths in production since #1380. Moved the keys to invoices rather than repointing the components, since the dialog belongs to the invoice list, not the editor. Message files are edited textually, never via JSON.parse/stringify: they contain duplicate keys a round trip would silently drop. Same bug class fixed in TemplateBookDialog (bookkeeping) and Correction/StrikeLines dialogs (journal_detail) by adding the strings to the namespace each component reads, matching the existing precedent that toast_posted_* is duplicated across journal_list and journal_detail. Added i18n/__tests__/message-keys.test.ts, which resolves every literal t() key against both locales: next-intl has no build-time check and fails by rendering the key path, so nothing caught this before users did. [2026-08-06] The ROT/RUT payout button is hidden from the invoices header unless the company has an invoice with deduction_total > 0 or rot_rut_enabled is on in tax settings. ROT/RUT concerns only companies selling eligible work to consumers, and a payout can never precede the invoice that created the claim, so the derived signal cannot hide the action from someone who needs it. Read from the company_settings row the page already fetches for ore_rounding (no extra round trip); deliberately not scoped to the fiscal-year filter, since a begäran is claimed the year after payment. ?rot-rut=1 still opens the dialog, so the feature is hidden, not removed. [2026-08-06] Supplier credit notes under kontantmetoden now reverse when the ORIGINAL was already booked (paid), not only under faktureringsmetoden: skipping left the expense and the 2641 ingaende moms deduction overstated with no accounting trace. Mirrors the customer-side creditNoteNeedsJournalEntry(). The v1 route's GDPR-minimised projection had to re-add registration_journal_entry_id/payment_journal_entry_id/paid_at/paid_amount: status alone misses a part-paid-but-booked original. +[2026-08-06] Kontantmetoden year-end cut-off (BFL 5 kap 2 §) books moms to the VILANDE accounts (2618/2628/2638 ut, 2648 in), never 2611/2641: vilande accounts are deliberately absent from ACCOUNT_RUTA/ACCOUNT_TO_BOX, so the moms stays out of the momsdeklaration until payment, which is what bokslutsmetoden requires. 2647 was considered and rejected: it is domestic omvand betalningsskyldighet, unrelated. Cut-off posts as two AGGREGATE verifikat reversed on day 1 of the next period, and deliberately does NOT set invoices.journal_entry_id: the payment flows route on that link, so per-invoice linking would send every new-year payment down the accrual clearing path against a receivable the vandning already removed, booking the settlement twice. diff --git a/lib/bokslut/__tests__/readiness-aggregator.test.ts b/lib/bokslut/__tests__/readiness-aggregator.test.ts index d2ba9848..92cd3248 100644 --- a/lib/bokslut/__tests__/readiness-aggregator.test.ts +++ b/lib/bokslut/__tests__/readiness-aggregator.test.ts @@ -20,11 +20,16 @@ vi.mock('@/lib/reports/supplier-reconciliation', () => ({ generateReconciliation: vi.fn(), })) +vi.mock('@/lib/core/bookkeeping/kontantmetod-cutoff', () => ({ + collectKontantmetodCutoff: vi.fn(), +})) + import { buildBokslutReadinessReport } from '../readiness-aggregator' import { validateYearEndReadiness } from '@/lib/core/bookkeeping/year-end-service' import { getReconciliationStatus } from '@/lib/reconciliation/bank-reconciliation' import { generateARReconciliation } from '@/lib/reports/ar-reconciliation' import { generateReconciliation as generateAPReconciliation } from '@/lib/reports/supplier-reconciliation' +import { collectKontantmetodCutoff } from '@/lib/core/bookkeeping/kontantmetod-cutoff' const CASH_ACCOUNT_ID = 'dddddddd-dddd-4ddd-8ddd-dddddddddddd' @@ -345,6 +350,7 @@ describe('buildBokslutReadinessReport', () => { // only mislead. vi.mocked(validateYearEndReadiness).mockResolvedValue(baseValidation()) vi.mocked(getReconciliationStatus).mockResolvedValue(RECON_CLEAN as never) + vi.mocked(collectKontantmetodCutoff).mockResolvedValue({ receivables: [], payables: [], unknownVatTreatment: [], strayVatOnZeroRate: [] }) const supabase = makeSupabase({ period: { data: PERIOD, error: null }, settings: { data: { entity_type: 'enskild_firma', accounting_method: 'cash' }, error: null }, @@ -374,4 +380,70 @@ describe('buildBokslutReadinessReport', () => { // The AP side still ran and reported clean independently of the AR failure. expect(vi.mocked(generateAPReconciliation)).toHaveBeenCalled() }) + it('reminds kontantmetoden companies to book the year-end cut-off', async () => { + // BFL 5 kap 2 §: fordringar och skulder must be booked at räkenskapsårets + // utgång even though the year is otherwise kept on a cash basis. + vi.mocked(validateYearEndReadiness).mockResolvedValue(baseValidation()) + vi.mocked(getReconciliationStatus).mockResolvedValue(RECON_CLEAN as never) + vi.mocked(collectKontantmetodCutoff).mockResolvedValue({ + receivables: [{ id: 'i1', reference: 'F-1', vatTreatment: 'standard_25', outstanding: 1250, vat: 250 }], + payables: [{ id: 's1', reference: 'L-1', outstanding: 500, vat: 100, netByAccount: [] }], + unknownVatTreatment: [], + strayVatOnZeroRate: [], + }) + const supabase = makeSupabase({ + period: { data: PERIOD, error: null }, + settings: { data: { entity_type: 'enskild_firma', accounting_method: 'cash' }, error: null }, + }) + + const report = await buildBokslutReadinessReport(supabase, 'co-1', 'user-1', 'fp-1') + + const cutoff = report.reminders.find((r) => r.code === 'kontantmetod_cutoff_required') + expect(cutoff?.severity).toBe('warning') + expect(cutoff?.message).toContain('2 obetalda fakturor') + expect(cutoff?.message).toContain('vilande') + // Advisory only: it must never flip readiness on its own. + expect(report.ready).toBe(true) + }) + + it('emits no cut-off reminder when nothing was outstanding at period end', async () => { + vi.mocked(validateYearEndReadiness).mockResolvedValue(baseValidation()) + vi.mocked(getReconciliationStatus).mockResolvedValue(RECON_CLEAN as never) + vi.mocked(collectKontantmetodCutoff).mockResolvedValue({ receivables: [], payables: [], unknownVatTreatment: [], strayVatOnZeroRate: [] }) + const supabase = makeSupabase({ + period: { data: PERIOD, error: null }, + settings: { data: { entity_type: 'enskild_firma', accounting_method: 'cash' }, error: null }, + }) + + const report = await buildBokslutReadinessReport(supabase, 'co-1', 'user-1', 'fp-1') + expect(report.reminders.find((r) => r.code === 'kontantmetod_cutoff_required')).toBeUndefined() + }) + + it('never runs the cut-off check for faktureringsmetoden companies', async () => { + vi.mocked(validateYearEndReadiness).mockResolvedValue(baseValidation()) + vi.mocked(getReconciliationStatus).mockResolvedValue(RECON_CLEAN as never) + vi.mocked(generateARReconciliation).mockResolvedValue({ is_reconciled: true, difference: 0, unconverted_fx_count: 0 } as never) + vi.mocked(generateAPReconciliation).mockResolvedValue({ is_reconciled: true, difference: 0, unconverted_fx_count: 0 } as never) + const supabase = makeSupabase({ + period: { data: PERIOD, error: null }, + settings: { data: { entity_type: 'aktiebolag', accounting_method: 'accrual' }, error: null }, + }) + + await buildBokslutReadinessReport(supabase, 'co-1', 'user-1', 'fp-1') + expect(vi.mocked(collectKontantmetodCutoff)).not.toHaveBeenCalled() + }) + + it('degrades gracefully when the cut-off check fails', async () => { + vi.mocked(validateYearEndReadiness).mockResolvedValue(baseValidation()) + vi.mocked(getReconciliationStatus).mockResolvedValue(RECON_CLEAN as never) + vi.mocked(collectKontantmetodCutoff).mockRejectedValue(new Error('boom')) + const supabase = makeSupabase({ + period: { data: PERIOD, error: null }, + settings: { data: { entity_type: 'enskild_firma', accounting_method: 'cash' }, error: null }, + }) + + const report = await buildBokslutReadinessReport(supabase, 'co-1', 'user-1', 'fp-1') + expect(report.ready).toBe(true) + expect(report.reminders.find((r) => r.code === 'kontantmetod_cutoff_required')).toBeUndefined() + }) }) diff --git a/lib/bokslut/readiness-aggregator.ts b/lib/bokslut/readiness-aggregator.ts index e431866d..8af59612 100644 --- a/lib/bokslut/readiness-aggregator.ts +++ b/lib/bokslut/readiness-aggregator.ts @@ -4,6 +4,7 @@ import { getReconciliationStatus } from '@/lib/reconciliation/bank-reconciliatio import { resolveCashAccountScope } from '@/lib/reconciliation/cash-account-scope' import { generateARReconciliation } from '@/lib/reports/ar-reconciliation' import { generateReconciliation as generateAPReconciliation } from '@/lib/reports/supplier-reconciliation' +import { collectKontantmetodCutoff } from '@/lib/core/bookkeeping/kontantmetod-cutoff' import { computeEfDeclarationPreview } from '@/lib/bokslut/enskild-firma/ef-declaration-preview' import { createLogger } from '@/lib/logger' import type { YearEndBlocker, YearEndValidation } from '@/types' @@ -162,10 +163,67 @@ export async function buildBokslutReadinessReport( // AR/AP tie-outs: Phase 1 avstämningar per the bokslut process, open // sub-ledger vs konto 1510 / 2440. Accrual companies only: under // kontantmetoden open invoices are deliberately not on 1510/2440 until the - // year-end conversion (BFL 5 kap 2 § 3 st) exists, so the tie-out is - // permanently "unreconciled" there by construction and would only mislead. + // cut-off entry below puts them there, so the tie-out is "unreconciled" by + // construction for the whole year and would only mislead. // Warnings, never blockers: a difference can be legitimate (e.g. partial // payments settled at a different FX rate than the invoice-date rate). + if (accountingMethod === 'cash') { + // Kontantmetoden year-end cut-off (BFL 5 kap 2 §): fordringar och skulder + // must be booked at räkenskapsårets utgång even though the year is kept on + // a cash basis. Advisory here, not a blocker: promoting it would newly + // block every cash company mid-bokslut, and the posting step is the + // founder's call to gate on. + try { + const cutoff = await collectKontantmetodCutoff( + supabase, + companyId, + period.period_start, + period.period_end, + ) + const openCount = cutoff.receivables.length + cutoff.payables.length + if (openCount > 0) { + reminders.push({ + code: 'kontantmetod_cutoff_required', + severity: 'warning', + message: + `${openCount} obetalda fakturor var utestående vid periodens slut. ` + + 'Kontantmetoden kräver att fordringar och skulder bokförs vid ' + + 'räkenskapsårets utgång (BFL 5 kap 2 §). Momsen bokas som vilande ' + + 'och redovisas först vid betalning.', + href: '/reports/kundreskontra', + }) + } + // Surfaced separately: these rows block the cut-off entirely, so the + // user needs to see them even when nothing else is outstanding. + if (cutoff.strayVatOnZeroRate.length > 0) { + reminders.push({ + code: 'kontantmetod_cutoff_stray_vat', + severity: 'warning', + message: + `${cutoff.strayVatOnZeroRate.length} fakturor har moms trots en momsfri ` + + 'momsinställning och kan inte tas med i bokslutsavgränsningen. Rätta dem innan bokslut: ' + + `${cutoff.strayVatOnZeroRate.slice(0, 5).join(', ')}`, + href: '/invoices', + }) + } + if (cutoff.unknownVatTreatment.length > 0) { + reminders.push({ + code: 'kontantmetod_cutoff_missing_vat_treatment', + severity: 'warning', + message: + `${cutoff.unknownVatTreatment.length} fakturor saknar momsinställning och kan ` + + 'inte tas med i bokslutsavgränsningen. Komplettera dem innan bokslut: ' + + `${cutoff.unknownVatTreatment.slice(0, 5).join(', ')}`, + href: '/invoices', + }) + } + } catch (err) { + // Advisory: never break the wizard on it, but keep the failure traceable + // so a silently missing reminder is not mistaken for "nothing open". + log.warn('kontantmetoden cut-off check failed; reminder omitted', err as Error) + } + } + if (accountingMethod === 'accrual') { const [arResult, apResult] = await Promise.allSettled([ generateARReconciliation(supabase, companyId, fiscalPeriodId), diff --git a/lib/core/bookkeeping/__tests__/kontantmetod-cutoff.test.ts b/lib/core/bookkeeping/__tests__/kontantmetod-cutoff.test.ts new file mode 100644 index 00000000..63a9b649 --- /dev/null +++ b/lib/core/bookkeeping/__tests__/kontantmetod-cutoff.test.ts @@ -0,0 +1,429 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('@/lib/bookkeeping/engine', () => ({ + createJournalEntry: vi.fn(), + reverseEntry: vi.fn(), +})) + +import { + buildCutoffLines, + buildCutoffNote, + distributeOre, + postKontantmetodCutoff, + nextDay, + reverseLines, + VILANDE_INPUT_VAT_ACCOUNT, + VILANDE_OUTPUT_VAT_ACCOUNTS, +} from '../kontantmetod-cutoff' +import type { CutoffPayable, CutoffReceivable } from '../kontantmetod-cutoff' +import { roundOre } from '@/lib/money' +import { createJournalEntry, reverseEntry } from '@/lib/bookkeeping/engine' + +const sum = (lines: Array<{ debit_amount: number; credit_amount: number }>) => ({ + debit: roundOre(lines.reduce((s, l) => s + l.debit_amount, 0)), + credit: roundOre(lines.reduce((s, l) => s + l.credit_amount, 0)), +}) + +const receivable = (over: Partial = {}): CutoffReceivable => ({ + id: 'inv-1', + reference: 'F-1', + vatTreatment: 'standard_25', + outstanding: 1250, + vat: 250, + ...over, +}) + +const payable = (over: Partial = {}): CutoffPayable => ({ + id: 'si-1', + reference: 'L-1', + outstanding: 1250, + vat: 250, + netByAccount: [{ account: '5410', amount: 1000 }], + ...over, +}) + +describe('distributeOre', () => { + it('splits exactly, with no öre lost or invented', () => { + // 100 öre over three equal buckets cannot divide evenly: the largest + // remainders must absorb the leftovers rather than the total drifting. + const parts = distributeOre(100, [1, 1, 1]) + expect(parts.reduce((a, b) => a + b, 0)).toBe(100) + expect(parts).toEqual([34, 33, 33]) + }) + + it('weights proportionally', () => { + expect(distributeOre(1000, [3, 1])).toEqual([750, 250]) + }) + + it('handles degenerate input without emitting NaN', () => { + expect(distributeOre(500, [0, 0])).toEqual([500, 0]) + expect(distributeOre(500, [])).toEqual([]) + expect(distributeOre(500, [7])).toEqual([500]) + }) +}) + +describe('buildCutoffLines: fordringar', () => { + it('books the receivable against revenue and VILANDE output moms', () => { + const { receivableLines } = buildCutoffLines([receivable()], []) + + const debit = receivableLines.find((l) => l.debit_amount > 0) + expect(debit?.account_number).toBe('1510') + expect(debit?.debit_amount).toBe(1250) + + // The whole point: moms parks on 2618, NOT 2611, so it stays out of the + // momsdeklaration until the invoice is actually paid. + const vatLine = receivableLines.find((l) => l.account_number === '2618') + expect(vatLine?.credit_amount).toBe(250) + expect(receivableLines.some((l) => l.account_number === '2611')).toBe(false) + + expect(receivableLines.find((l) => l.account_number === '3001')?.credit_amount).toBe(1000) + }) + + it('balances', () => { + const { receivableLines } = buildCutoffLines( + [ + receivable({ id: 'a', outstanding: 1250, vat: 250 }), + receivable({ id: 'b', outstanding: 560, vat: 60, vatTreatment: 'reduced_12' }), + receivable({ id: 'c', outstanding: 106, vat: 6, vatTreatment: 'reduced_6' }), + ], + [], + ) + const totals = sum(receivableLines) + expect(totals.debit).toBe(totals.credit) + expect(totals.debit).toBe(1916) + }) + + it('balances on amounts that do not divide evenly', () => { + // 33.33 % style residue: net is derived as outstanding - vat precisely so + // the two legs always add back to the receivable. + const { receivableLines } = buildCutoffLines( + [receivable({ outstanding: 1000.01, vat: 200.003 })], + [], + ) + const totals = sum(receivableLines) + expect(totals.debit).toBe(totals.credit) + }) + + it('uses one vilande account per rate', () => { + const { receivableLines } = buildCutoffLines( + [ + receivable({ id: 'a', vatTreatment: 'standard_25' }), + receivable({ id: 'b', outstanding: 1120, vat: 120, vatTreatment: 'reduced_12' }), + ], + [], + ) + expect(receivableLines.find((l) => l.account_number === VILANDE_OUTPUT_VAT_ACCOUNTS.standard_25)).toBeDefined() + expect(receivableLines.find((l) => l.account_number === VILANDE_OUTPUT_VAT_ACCOUNTS.reduced_12)).toBeDefined() + }) + + it('treats a zero-moms treatment as pure revenue', () => { + // Export carries no Swedish output moms, so nothing may land on a vilande + // account: the full outstanding is revenue. + const { receivableLines } = buildCutoffLines( + [receivable({ vatTreatment: 'export', outstanding: 5000, vat: 0 })], + [], + ) + expect(receivableLines.some((l) => l.account_number.startsWith('26'))).toBe(false) + expect(receivableLines.find((l) => l.account_number === '3305')?.credit_amount).toBe(5000) + const totals = sum(receivableLines) + expect(totals.debit).toBe(totals.credit) + }) + + it('still balances if a stray moms amount reaches buildCutoffLines directly', () => { + // The collector now excludes these rows and the posting step refuses them, + // so this is the last-resort path. It must never invent a moms account and + // must never unbalance the verifikat. + const { receivableLines } = buildCutoffLines( + [receivable({ vatTreatment: 'export', outstanding: 5000, vat: 100 })], + [], + ) + const totals = sum(receivableLines) + expect(totals.debit).toBe(totals.credit) + expect(receivableLines.some((l) => l.account_number.startsWith('26'))).toBe(false) + }) + + it('emits nothing when there is nothing outstanding', () => { + expect(buildCutoffLines([], []).receivableLines).toEqual([]) + expect(buildCutoffLines([receivable({ outstanding: 0, vat: 0 })], []).receivableLines).toEqual([]) + }) +}) + +describe('buildCutoffLines: skulder', () => { + it('books the payable against expense and VILANDE input moms', () => { + const { payableLines } = buildCutoffLines([], [payable()]) + + const credit = payableLines.find((l) => l.credit_amount > 0) + expect(credit?.account_number).toBe('2440') + expect(credit?.credit_amount).toBe(1250) + + // 2648, not 2641: the deduction is not claimable until payment. + expect(payableLines.find((l) => l.account_number === VILANDE_INPUT_VAT_ACCOUNT)?.debit_amount).toBe(250) + expect(payableLines.some((l) => l.account_number === '2641')).toBe(false) + + expect(payableLines.find((l) => l.account_number === '5410')?.debit_amount).toBe(1000) + }) + + it('splits the net across several expense accounts and still balances', () => { + const { payableLines } = buildCutoffLines( + [], + [ + payable({ + outstanding: 1250, + vat: 250, + netByAccount: [ + { account: '5410', amount: 700 }, + { account: '6110', amount: 300 }, + ], + }), + ], + ) + const totals = sum(payableLines) + expect(totals.debit).toBe(totals.credit) + expect(payableLines.find((l) => l.account_number === '5410')?.debit_amount).toBe(700) + expect(payableLines.find((l) => l.account_number === '6110')?.debit_amount).toBe(300) + }) + + it('balances when the account split cannot divide evenly', () => { + const { payableLines } = buildCutoffLines( + [], + [ + payable({ + outstanding: 100.01, + vat: 0, + netByAccount: [ + { account: '5410', amount: 1 }, + { account: '6110', amount: 1 }, + { account: '6210', amount: 1 }, + ], + }), + ], + ) + const totals = sum(payableLines) + expect(totals.debit).toBe(totals.credit) + expect(totals.credit).toBe(100.01) + }) + + it('falls back to a generic expense account when item detail is missing', () => { + const { payableLines } = buildCutoffLines([], [payable({ netByAccount: [] })]) + expect(payableLines.find((l) => l.account_number === '6990')?.debit_amount).toBe(1000) + const totals = sum(payableLines) + expect(totals.debit).toBe(totals.credit) + }) +}) + +describe('reverseLines', () => { + it('swaps every debit and credit so the vändning nets to zero', () => { + const { receivableLines } = buildCutoffLines([receivable()], []) + const reversed = reverseLines(receivableLines) + + const original = sum(receivableLines) + const back = sum(reversed) + expect(back.debit).toBe(original.credit) + expect(back.credit).toBe(original.debit) + + // Net effect of cut-off + vändning on 1510 is exactly zero. + const net = [...receivableLines, ...reversed] + .filter((l) => l.account_number === '1510') + .reduce((s, l) => s + l.debit_amount - l.credit_amount, 0) + expect(net).toBe(0) + }) + + it('labels the reversal so the verifikat is self-explanatory', () => { + expect(reverseLines([{ account_number: '1510', debit_amount: 10, credit_amount: 0, line_description: 'X' }])[0] + .line_description).toBe('Vändning: X') + }) +}) + +describe('nextDay', () => { + it('rolls over year end', () => { + expect(nextDay('2026-12-31')).toBe('2027-01-01') + }) + + it('handles a broken fiscal year and a leap day', () => { + expect(nextDay('2026-06-30')).toBe('2026-07-01') + expect(nextDay('2028-02-28')).toBe('2028-02-29') + }) +}) + +describe('buildCutoffNote (BFL 5 kap 6-7 §: traceability)', () => { + it('names the invoices an aggregate verifikat covers', () => { + expect(buildCutoffNote('Kundfordringar', ['F-1', 'F-2'])).toBe( + 'Kundfordringar (2 st): F-1, F-2', + ) + }) + + it('truncates a long list to a pointer rather than an unbounded note', () => { + const refs = Array.from({ length: 60 }, (_, i) => `F-${i + 1}`) + const note = buildCutoffNote('Kundfordringar', refs) + expect(note).toContain('(60 st)') + expect(note).toContain('och 10 till') + }) + + it('is explicit when no invoice numbers exist', () => { + expect(buildCutoffNote('Skulder', ['', ' '])).toBe('Skulder: inga fakturanummer registrerade') + }) +}) + +describe('postKontantmetodCutoff', () => { + const OPEN_NEXT = { + id: 'fp-next', + period_start: '2027-01-01', + period_end: '2027-12-31', + is_closed: false, + locked_at: null, + } + + const makeSupabase = (next: Record | null) => ({ + from: () => ({ + select: () => ({ + eq: () => ({ + eq: () => ({ maybeSingle: async () => ({ data: next, error: next ? null : { message: 'x' } }) }), + }), + }), + }), + }) as never + + const baseOpts = { + fiscalPeriodId: 'fp-1', + nextFiscalPeriodId: 'fp-next', + periodEnd: '2026-12-31', + receivables: [receivable()], + payables: [], + } + + beforeEach(() => { + vi.mocked(createJournalEntry).mockReset() + vi.mocked(reverseEntry).mockReset() + }) + + it('posts the cut-off and its vändning, carrying invoice refs into notes', async () => { + vi.mocked(createJournalEntry) + .mockResolvedValueOnce({ id: 'je-cutoff' } as never) + .mockResolvedValueOnce({ id: 'je-reversal' } as never) + + const result = await postKontantmetodCutoff(makeSupabase(OPEN_NEXT), 'co-1', 'user-1', baseOpts) + + expect(result.receivableEntry?.id).toBe('je-cutoff') + expect(result.receivableReversal?.id).toBe('je-reversal') + + const cutoffCall = vi.mocked(createJournalEntry).mock.calls[0][3] + expect(cutoffCall.entry_date).toBe('2026-12-31') + expect(cutoffCall.notes).toContain('F-1') + const reversalCall = vi.mocked(createJournalEntry).mock.calls[1][3] + expect(reversalCall.entry_date).toBe('2027-01-01') + expect(reversalCall.fiscal_period_id).toBe('fp-next') + }) + + it('refuses before posting anything when the next period does not exist', async () => { + await expect( + postKontantmetodCutoff(makeSupabase(null), 'co-1', 'user-1', baseOpts), + ).rejects.toThrow(/nästa räkenskapsår/i) + // The critical assertion: nothing was posted, so no un-reversed cut-off. + expect(vi.mocked(createJournalEntry)).not.toHaveBeenCalled() + }) + + it('refuses before posting anything when the next period is closed or locked', async () => { + await expect( + postKontantmetodCutoff(makeSupabase({ ...OPEN_NEXT, is_closed: true }), 'co-1', 'user-1', baseOpts), + ).rejects.toThrow(/stängt eller låst/i) + expect(vi.mocked(createJournalEntry)).not.toHaveBeenCalled() + + await expect( + postKontantmetodCutoff(makeSupabase({ ...OPEN_NEXT, locked_at: '2027-02-01' }), 'co-1', 'user-1', baseOpts), + ).rejects.toThrow(/stängt eller låst/i) + expect(vi.mocked(createJournalEntry)).not.toHaveBeenCalled() + }) + + it('refuses when the vändning date falls outside the next period', async () => { + await expect( + postKontantmetodCutoff( + makeSupabase({ ...OPEN_NEXT, period_start: '2027-03-01' }), + 'co-1', 'user-1', baseOpts, + ), + ).rejects.toThrow(/utanför nästa räkenskapsår/i) + expect(vi.mocked(createJournalEntry)).not.toHaveBeenCalled() + }) + + it('refuses when an invoice carries moms on a momsfri treatment', async () => { + // Absorbing it into revenue would balance the verifikat and swallow a real + // invoicing error: the netting the swedish-vat reference prohibits. + await expect( + postKontantmetodCutoff(makeSupabase(OPEN_NEXT), 'co-1', 'user-1', { + ...baseOpts, + strayVatOnZeroRate: ['F-7'], + }), + ).rejects.toThrow(/momsfri momsinställning/i) + expect(vi.mocked(createJournalEntry)).not.toHaveBeenCalled() + }) + + it('refuses when any invoice lacks a vat_treatment', async () => { + await expect( + postKontantmetodCutoff(makeSupabase(OPEN_NEXT), 'co-1', 'user-1', { + ...baseOpts, + unknownVatTreatment: ['F-9'], + }), + ).rejects.toThrow(/saknar momsinställning/i) + expect(vi.mocked(createJournalEntry)).not.toHaveBeenCalled() + }) + + it('stornoes the cut-off when its vändning fails, leaving no inflated 1510', async () => { + // The failure mode the module exists to prevent: a committed cut-off with + // no vändning inflates 1510/2440 permanently and double-books every + // new-year payment. + vi.mocked(createJournalEntry) + .mockResolvedValueOnce({ id: 'je-cutoff' } as never) + .mockRejectedValueOnce(new Error('period locked')) + vi.mocked(reverseEntry).mockResolvedValue({ id: 'je-storno' } as never) + + await expect( + postKontantmetodCutoff(makeSupabase(OPEN_NEXT), 'co-1', 'user-1', baseOpts), + ).rejects.toThrow('period locked') + + expect(vi.mocked(reverseEntry)).toHaveBeenCalledWith( + expect.anything(), 'co-1', 'user-1', 'je-cutoff', '2026-12-31', + ) + }) + + it('still rethrows the original error when the compensating storno also fails', async () => { + vi.mocked(createJournalEntry) + .mockResolvedValueOnce({ id: 'je-cutoff' } as never) + .mockRejectedValueOnce(new Error('period locked')) + vi.mocked(reverseEntry).mockRejectedValue(new Error('storno failed')) + + await expect( + postKontantmetodCutoff(makeSupabase(OPEN_NEXT), 'co-1', 'user-1', baseOpts), + ).rejects.toThrow('period locked') + }) + + it('posts nothing at all when there is nothing outstanding', async () => { + const result = await postKontantmetodCutoff(makeSupabase(OPEN_NEXT), 'co-1', 'user-1', { + ...baseOpts, + receivables: [], + payables: [], + }) + expect(result.receivableEntry).toBeNull() + expect(vi.mocked(createJournalEntry)).not.toHaveBeenCalled() + }) +}) + +describe('buildCutoffLines: omvänd betalningsskyldighet', () => { + it('never routes reverse-charge moms into the single vilande bucket', () => { + // A one-sided reverse charge is prohibited: the self-assessed output/input + // pair belongs to the payment entry, not to a deferred 2648 balance. + const { payableLines } = buildCutoffLines( + [], + [payable({ outstanding: 1000, vat: 250, reverseCharge: true, netByAccount: [{ account: '4056', amount: 1000 }] })], + ) + expect(payableLines.some((l) => l.account_number === VILANDE_INPUT_VAT_ACCOUNT)).toBe(false) + // The full outstanding is expense against 2440. + expect(payableLines.find((l) => l.account_number === '4056')?.debit_amount).toBe(1000) + expect(payableLines.find((l) => l.account_number === '2440')?.credit_amount).toBe(1000) + const totals = sum(payableLines) + expect(totals.debit).toBe(totals.credit) + }) + + it('still books vilande moms for ordinary (non-RC) supplier invoices', () => { + const { payableLines } = buildCutoffLines([], [payable({ reverseCharge: false })]) + expect(payableLines.find((l) => l.account_number === VILANDE_INPUT_VAT_ACCOUNT)?.debit_amount).toBe(250) + }) +}) diff --git a/lib/core/bookkeeping/kontantmetod-cutoff.ts b/lib/core/bookkeeping/kontantmetod-cutoff.ts new file mode 100644 index 00000000..88ad2308 --- /dev/null +++ b/lib/core/bookkeeping/kontantmetod-cutoff.ts @@ -0,0 +1,714 @@ +/** + * Kontantmetoden year-end cut-off (BFL 5 kap 2 §). + * + * Under kontantmetoden (bokslutsmetoden) affärshändelser are booked when cash + * moves, so open customer and supplier invoices never reach 1510 / 2440 during + * the year. BFL still requires that fordringar och skulder ARE booked at + * räkenskapsårets utgång, so the year-end needs a cut-off entry that puts every + * still-outstanding invoice onto the balance sheet. + * + * Moms is the part that is easy to get wrong. Under bokslutsmetoden moms is + * reported at payment, so the cut-off must NOT push moms into the current + * momsdeklaration. BAS provides "vilande" (dormant) moms accounts for exactly + * this: 2618/2628/2638 for utgående and 2648 for ingående. They are absent from + * ACCOUNT_RUTA / ACCOUNT_TO_BOX by design, so anything parked there stays out + * of the declaration until the invoice is actually paid. Booking cut-off moms + * to 2611/2641 instead would claim it a period early, which is the real error + * this module exists to avoid. + * + * Shape: two aggregate verifikat (one for fordringar, one for skulder), each + * reversed on the first day of the following period. Deliberately NOT + * per-invoice, and deliberately not linked through invoices.journal_entry_id: + * + * - the payment flows route on whether a live journal-entry link exists, so + * linking here would make every new-year payment take the accrual clearing + * path against a receivable the reversal has already removed, booking the + * settlement twice; + * - leaving the link unset means a new-year payment still books the normal + * kontantmetoden cash entry (revenue/expense + real moms at the payment + * date), which is what bokslutsmetoden requires. + * + * The reversal is what makes that safe: cut-off on the last day of the year, + * vändning on the first day of the next, and the ledger is back to a pure cash + * basis before any new-year payment is booked. + */ +import type { SupabaseClient } from '@supabase/supabase-js' +import type { + CreateJournalEntryLineInput, + EntityType, + JournalEntry, + VatTreatment, +} from '@/types' +import { getRevenueAccount } from '@/lib/bookkeeping/invoice-entries' +import { createJournalEntry, reverseEntry } from '@/lib/bookkeeping/engine' +import { createLogger } from '@/lib/logger' +import { ORE_TOLERANCE, roundOre } from '@/lib/money' + +const log = createLogger('kontantmetod-cutoff') + +/** + * Vilande utgående moms per VAT treatment. Rates outside 25/12/6 (export, + * reverse charge, exempt) carry no Swedish output moms at all, so they never + * reach this map: their whole outstanding amount is revenue. + */ +export const VILANDE_OUTPUT_VAT_ACCOUNTS: Partial> = { + standard_25: '2618', + reduced_12: '2628', + reduced_6: '2638', +} + +/** Vilande ingående moms. One account for every rate, mirroring 2641. */ +export const VILANDE_INPUT_VAT_ACCOUNT = '2648' + +export const RECEIVABLES_ACCOUNT = '1510' +export const PAYABLES_ACCOUNT = '2440' + +/** A customer invoice still outstanding at period end. Amounts are SEK. */ +export interface CutoffReceivable { + id: string + /** Human reference for the line description. */ + reference: string + vatTreatment: VatTreatment + /** Outstanding INCLUDING moms at period end. */ + outstanding: number + /** The moms share of `outstanding`. */ + vat: number +} + +/** A supplier invoice still outstanding at period end. Amounts are SEK. */ +export interface CutoffPayable { + id: string + reference: string + /** Outstanding INCLUDING moms at period end. */ + outstanding: number + /** The ingående moms share of `outstanding`. */ + vat: number + /** + * Omvänd betalningsskyldighet. The supplier charges no moms, so the buyer + * self-assesses output AND input moms on 2614/2624/2634 + 2645/2647, which + * is a symmetric pair that must never be split. `vat` is 0 on every such row + * by construction, and this flag forces it to 0 anyway: routing a stray + * amount into the single 2648 bucket would post a one-sided reverse charge, + * the exact error the swedish-vat reference calls out as prohibited. + * The self-assessed pair is handled by the payment entry after the vändning, + * unchanged by the cut-off. + */ + reverseCharge?: boolean + /** + * Net expense split across BAS accounts, as weights. Only the ratios matter: + * the net total is always derived as `outstanding - vat` so the verifikat + * balances no matter how the source rows round. + */ + netByAccount: Array<{ account: string; amount: number }> +} + +export interface CutoffLines { + receivableLines: CreateJournalEntryLineInput[] + payableLines: CreateJournalEntryLineInput[] + receivableTotal: number + payableTotal: number +} + +// Go through roundOre first: Math.round(x * 100) alone mis-rounds exact-half +// values that arrive with float drift (lib/money.ts). +const toOre = (amount: number): number => Math.round(roundOre(amount) * 100) +const toKronor = (ore: number): number => ore / 100 + +/** + * Split `totalOre` across `weights` so the parts sum to exactly `totalOre`. + * + * Proportional shares with largest-remainder allocation. Doing this in whole + * öre (rather than rounding each share independently) is what keeps the + * verifikat balanced: independent rounding drifts by an öre per bucket and the + * DB balance trigger would reject the entry. + */ +export function distributeOre(totalOre: number, weights: number[]): number[] { + if (weights.length === 0) return [] + if (weights.length === 1) return [totalOre] + + const weightSum = weights.reduce((sum, w) => sum + Math.abs(w), 0) + // Degenerate input (all-zero weights): put everything on the first bucket + // rather than emitting NaN. + if (weightSum === 0) return weights.map((_, i) => (i === 0 ? totalOre : 0)) + + const exact = weights.map((w) => (Math.abs(w) / weightSum) * totalOre) + const floors = exact.map((value) => Math.floor(value)) + let remainder = totalOre - floors.reduce((sum, value) => sum + value, 0) + + // Hand the leftover öre to the largest fractional parts first. + const order = exact + .map((value, index) => ({ index, frac: value - Math.floor(value) })) + .sort((a, b) => b.frac - a.frac) + + const result = [...floors] + for (const { index } of order) { + if (remainder <= 0) break + result[index] += 1 + remainder -= 1 + } + return result +} + +/** + * Build the cut-off verifikat lines. Pure: no IO, so the money math is + * directly testable. + * + * Receivables: Debit 1510 / Credit 30xx + Credit 2618|2628|2638 + * Payables: Debit 4-6xxx + Debit 2648 / Credit 2440 + */ +export function buildCutoffLines( + receivables: CutoffReceivable[], + payables: CutoffPayable[], + entityType: EntityType = 'aktiebolag', +): CutoffLines { + const receivableLines: CreateJournalEntryLineInput[] = [] + const payableLines: CreateJournalEntryLineInput[] = [] + + // ---- Fordringar ------------------------------------------------------- + // Group by VAT treatment: the revenue account and the vilande moms account + // both follow from it. + const revenueByTreatment = new Map() + const outputVatByTreatment = new Map() + let receivableOre = 0 + + for (const row of receivables) { + const outstandingOre = toOre(row.outstanding) + if (outstandingOre === 0) continue + // Derive net from outstanding minus moms so the two legs always add back + // to the receivable, whatever rounding the source row carries. + const vatOre = toOre(row.vat) + const netOre = outstandingOre - vatOre + + receivableOre += outstandingOre + revenueByTreatment.set(row.vatTreatment, (revenueByTreatment.get(row.vatTreatment) ?? 0) + netOre) + if (vatOre !== 0) { + outputVatByTreatment.set( + row.vatTreatment, + (outputVatByTreatment.get(row.vatTreatment) ?? 0) + vatOre, + ) + } + } + + if (receivableOre !== 0) { + receivableLines.push({ + account_number: RECEIVABLES_ACCOUNT, + debit_amount: toKronor(receivableOre), + credit_amount: 0, + line_description: 'Kundfordringar vid räkenskapsårets utgång (kontantmetoden)', + }) + + for (const [treatment, netOre] of revenueByTreatment) { + if (netOre === 0) continue + receivableLines.push({ + account_number: getRevenueAccount(treatment, entityType), + debit_amount: 0, + credit_amount: toKronor(netOre), + line_description: 'Obetalda kundfakturor vid bokslut', + }) + } + + for (const [treatment, vatOre] of outputVatByTreatment) { + const account = VILANDE_OUTPUT_VAT_ACCOUNTS[treatment] + // No vilande account means the treatment carries no Swedish output moms + // (export, omvänd betalningsskyldighet, undantagen). A non-zero moms + // amount there is a data error: fold it into revenue rather than invent + // a moms account, so the verifikat still balances and the anomaly shows + // up as revenue rather than as a phantom momsskuld. + if (!account) { + log.warn('outstanding moms on a treatment with no vilande account; booked as revenue', { + treatment, + ore: vatOre, + }) + receivableLines.push({ + account_number: getRevenueAccount(treatment, entityType), + debit_amount: 0, + credit_amount: toKronor(vatOre), + line_description: 'Obetalda kundfakturor vid bokslut', + }) + continue + } + receivableLines.push({ + account_number: account, + debit_amount: 0, + credit_amount: toKronor(vatOre), + line_description: 'Vilande utgående moms, redovisas vid betalning', + }) + } + } + + // ---- Skulder ---------------------------------------------------------- + const expenseByAccount = new Map() + let payableOre = 0 + let inputVatOre = 0 + + for (const row of payables) { + const outstandingOre = toOre(row.outstanding) + if (outstandingOre === 0) continue + // Reverse charge carries no deductible moms on the invoice itself: the + // self-assessed pair is booked by the payment entry, never split into the + // single vilande bucket. Forced to 0 rather than trusted from the row. + const vatOre = row.reverseCharge ? 0 : toOre(row.vat) + const netOre = outstandingOre - vatOre + + payableOre += outstandingOre + inputVatOre += vatOre + + const buckets = row.netByAccount.length > 0 + ? row.netByAccount + // No item detail: park the net on the generic övriga kostnader account + // rather than dropping it. The entry is reversed the next day, so the + // account choice never survives into the new year. + : [{ account: '6990', amount: 1 }] + const shares = distributeOre(netOre, buckets.map((b) => b.amount)) + buckets.forEach((bucket, index) => { + const share = shares[index] + if (share === 0) return + expenseByAccount.set(bucket.account, (expenseByAccount.get(bucket.account) ?? 0) + share) + }) + } + + if (payableOre !== 0) { + for (const [account, netOre] of expenseByAccount) { + if (netOre === 0) continue + payableLines.push({ + account_number: account, + debit_amount: toKronor(netOre), + credit_amount: 0, + line_description: 'Obetalda leverantörsfakturor vid bokslut', + }) + } + + if (inputVatOre !== 0) { + payableLines.push({ + account_number: VILANDE_INPUT_VAT_ACCOUNT, + debit_amount: toKronor(inputVatOre), + credit_amount: 0, + line_description: 'Vilande ingående moms, dras av vid betalning', + }) + } + + payableLines.push({ + account_number: PAYABLES_ACCOUNT, + debit_amount: 0, + credit_amount: toKronor(payableOre), + line_description: 'Leverantörsskulder vid räkenskapsårets utgång (kontantmetoden)', + }) + } + + return { + receivableLines, + payableLines, + receivableTotal: toKronor(receivableOre), + payableTotal: toKronor(payableOre), + } +} + +/** Swap every debit and credit: the vändning posted on day 1 of the new year. */ +export function reverseLines( + lines: CreateJournalEntryLineInput[], +): CreateJournalEntryLineInput[] { + return lines.map((line) => ({ + ...line, + debit_amount: line.credit_amount, + credit_amount: line.debit_amount, + line_description: `Vändning: ${line.line_description ?? ''}`.trim(), + })) +} + +/** The day after `date`, ISO. Used to date the vändning. */ +export function nextDay(date: string): string { + const d = new Date(`${date}T00:00:00Z`) + d.setUTCDate(d.getUTCDate() + 1) + return d.toISOString().slice(0, 10) +} + +export interface CutoffCollection { + receivables: CutoffReceivable[] + payables: CutoffPayable[] + /** + * Invoices whose vat_treatment is missing. Never guessed at: a reduced-rate + * or exempt invoice silently defaulted to 25 % would land on the wrong + * vilande account and the wrong revenue account. Posting refuses while this + * is non-empty so the user fixes the source rows instead. + */ + unknownVatTreatment: string[] + /** + * Invoices carrying moms on a treatment that cannot have Swedish output moms + * (export, omvänd betalningsskyldighet, undantagen). Absorbing that into the + * revenue line would balance the verifikat while silently swallowing a real + * invoicing error, which is the netting the swedish-vat reference prohibits. + * Excluded and refused on the same footing as a missing treatment. + */ + strayVatOnZeroRate: string[] +} + +/** + * An aggregate verifikat still has to say which affärshändelser it covers + * (BFL 5 kap 6-7 §: motpart and underlag must be traceable). The lines are + * grouped by account, so the invoice references go into the entry `notes` + * where an examiner can follow them back to the sub-ledger. + * + * Truncated past a sane length: the note is a pointer to the reskontra, not a + * replacement for it, and an unbounded note on a company with thousands of + * open invoices helps nobody. + */ +export function buildCutoffNote(label: string, references: string[]): string { + const named = references.filter((ref) => ref && ref.trim().length > 0) + if (named.length === 0) return `${label}: inga fakturanummer registrerade` + const MAX = 50 + const shown = named.slice(0, MAX).join(', ') + const rest = named.length - Math.min(named.length, MAX) + return rest > 0 + ? `${label} (${named.length} st): ${shown} och ${rest} till. ` + + 'Fullständig specifikation finns i reskontran per bokslutsdagen.' + : `${label} (${named.length} st): ${shown}` +} + +/** + * Fetch every invoice still outstanding at `periodEnd`. + * + * "Outstanding at period end" is deliberately payment-DATE based, not the + * current remaining_amount: an invoice settled in January was still a + * fordran on 31 December and must be part of the cut-off. Reading + * remaining_amount would silently shrink the cut-off every day the user + * delays running the bokslut. + */ +export async function collectKontantmetodCutoff( + supabase: SupabaseClient, + companyId: string, + periodStart: string, + periodEnd: string, +): Promise { + const [invoicesResult, supplierResult] = await Promise.all([ + supabase + .from('invoices') + .select('id, invoice_number, invoice_date, status, total, total_sek, vat_amount, vat_amount_sek, vat_treatment, credited_invoice_id, document_type') + .eq('company_id', companyId) + .lte('invoice_date', periodEnd) + .in('status', ['sent', 'overdue', 'partially_paid', 'paid']), + supabase + .from('supplier_invoices') + .select('id, supplier_invoice_number, invoice_date, status, total, total_sek, vat_amount, vat_amount_sek, reverse_charge, is_credit_note, items:supplier_invoice_items(account_number, line_total)') + .eq('company_id', companyId) + .lte('invoice_date', periodEnd) + .in('status', ['registered', 'approved', 'partially_paid', 'paid']), + ]) + + const invoices = (invoicesResult.data ?? []) as Array> + const supplierInvoices = (supplierResult.data ?? []) as Array> + + const invoiceIds = invoices.map((row) => row.id as string) + const supplierIds = supplierInvoices.map((row) => row.id as string) + + // Payments ON OR BEFORE period end reduce the outstanding balance; later + // ones must not. + const [invoicePayments, supplierPayments] = await Promise.all([ + invoiceIds.length > 0 + ? supabase + .from('invoice_payments') + .select('invoice_id, amount, payment_date') + .eq('company_id', companyId) + .lte('payment_date', periodEnd) + .in('invoice_id', invoiceIds) + : Promise.resolve({ data: [] as Array> }), + supplierIds.length > 0 + ? supabase + .from('supplier_invoice_payments') + .select('supplier_invoice_id, amount, payment_date') + .eq('company_id', companyId) + .lte('payment_date', periodEnd) + .in('supplier_invoice_id', supplierIds) + : Promise.resolve({ data: [] as Array> }), + ]) + + const paidByInvoice = new Map() + for (const row of (invoicePayments.data ?? []) as Array>) { + const id = row.invoice_id as string + paidByInvoice.set(id, (paidByInvoice.get(id) ?? 0) + Number(row.amount ?? 0)) + } + const paidBySupplierInvoice = new Map() + for (const row of (supplierPayments.data ?? []) as Array>) { + const id = row.supplier_invoice_id as string + paidBySupplierInvoice.set(id, (paidBySupplierInvoice.get(id) ?? 0) + Number(row.amount ?? 0)) + } + + const receivables: CutoffReceivable[] = [] + const unknownVatTreatment: string[] = [] + const strayVatOnZeroRate: string[] = [] + for (const row of invoices) { + // Credit notes reduce the receivable through their own negative totals; + // they are already part of the invoice set, so no special casing beyond + // skipping non-invoice document types (offers, delivery notes). + const documentType = row.document_type as string | null + if (documentType && documentType !== 'invoice') continue + + const total = Number(row.total_sek ?? row.total ?? 0) + const vat = Number(row.vat_amount_sek ?? row.vat_amount ?? 0) + const paid = paidByInvoice.get(row.id as string) ?? 0 + const outstanding = roundOre(total - paid) + if (Math.abs(outstanding) < ORE_TOLERANCE) continue + + // Never guess the treatment. Defaulting a 12 %/6 %/undantagen invoice to + // 25 % would route it to the wrong vilande account AND the wrong revenue + // account; the moms impact is deferred but the year-end fordran + // composition would be wrong on the balance sheet. Collect and refuse. + const treatment = row.vat_treatment as VatTreatment | null + const reference = (row.invoice_number as string) ?? '' + if (!treatment) { + unknownVatTreatment.push(reference || (row.id as string)) + continue + } + + // Scale the moms share to the part still outstanding: a half-paid invoice + // carries half its moms into the cut-off. + const ratio = total === 0 ? 0 : outstanding / total + const scaledVat = roundOre(vat * ratio) + + // Moms on a treatment that cannot carry Swedish output moms is a real + // invoicing error. Surface it instead of quietly folding it into revenue: + // the verifikat would balance and the mistake would disappear. + if (!VILANDE_OUTPUT_VAT_ACCOUNTS[treatment] && Math.abs(scaledVat) >= ORE_TOLERANCE) { + strayVatOnZeroRate.push(reference || (row.id as string)) + continue + } + receivables.push({ + id: row.id as string, + reference, + vatTreatment: treatment, + outstanding, + vat: scaledVat, + }) + } + + const payables: CutoffPayable[] = [] + for (const row of supplierInvoices) { + const total = Number(row.total_sek ?? row.total ?? 0) + const vat = Number(row.vat_amount_sek ?? row.vat_amount ?? 0) + const paid = paidBySupplierInvoice.get(row.id as string) ?? 0 + const outstanding = roundOre(total - paid) + if (Math.abs(outstanding) < ORE_TOLERANCE) continue + + const ratio = total === 0 ? 0 : outstanding / total + const items = (row.items ?? []) as Array> + payables.push({ + id: row.id as string, + reference: (row.supplier_invoice_number as string) ?? '', + outstanding, + vat: roundOre(vat * ratio), + reverseCharge: Boolean(row.reverse_charge), + netByAccount: items + .filter((item) => item.account_number) + .map((item) => ({ + account: item.account_number as string, + amount: Math.abs(Number(item.line_total ?? 0)), + })), + }) + } + + log.info('collected kontantmetoden cut-off', { + companyId, + periodStart, + periodEnd, + receivables: receivables.length, + payables: payables.length, + }) + + if (unknownVatTreatment.length > 0) { + log.warn('invoices without vat_treatment excluded from the cut-off', { + companyId, + count: unknownVatTreatment.length, + }) + } + if (strayVatOnZeroRate.length > 0) { + log.warn('invoices with moms on a zero-rate treatment excluded from the cut-off', { + companyId, + count: strayVatOnZeroRate.length, + }) + } + + return { receivables, payables, unknownVatTreatment, strayVatOnZeroRate } +} + +export interface PostCutoffResult { + receivableEntry: JournalEntry | null + receivableReversal: JournalEntry | null + payableEntry: JournalEntry | null + payableReversal: JournalEntry | null +} + +/** + * Assert the vändning can actually be posted BEFORE any cut-off entry exists. + * + * The cut-off and its reversal are two verifikat, and the engine gives no + * cross-entry transaction: if the reversal fails after the cut-off is + * committed, 1510/2440 stay permanently inflated and every new-year payment + * double-books. Checking the target period up front turns the common failure + * (next period missing, closed, or locked) into a refusal that posts nothing, + * which leaves the compensating storno below as a genuine last resort rather + * than the expected path. + */ +async function assertReversalPeriodPostable( + supabase: SupabaseClient, + companyId: string, + nextFiscalPeriodId: string, + reversalDate: string, +): Promise { + if (!nextFiscalPeriodId) { + throw new Error( + 'Kontantmetodens bokslutsavgränsning kräver att nästa räkenskapsår är upplagt: vändningen bokas första dagen på det nya året.', + ) + } + + const { data, error } = await supabase + .from('fiscal_periods') + .select('id, period_start, period_end, is_closed, locked_at') + .eq('id', nextFiscalPeriodId) + .eq('company_id', companyId) + .maybeSingle() + + if (error || !data) { + throw new Error( + 'Kontantmetodens bokslutsavgränsning kräver att nästa räkenskapsår är upplagt: vändningen bokas första dagen på det nya året.', + ) + } + if (data.is_closed || data.locked_at) { + throw new Error( + 'Nästa räkenskapsår är stängt eller låst: vändningen av bokslutsavgränsningen kan inte bokföras. Lås upp perioden och försök igen.', + ) + } + if (reversalDate < (data.period_start as string) || reversalDate > (data.period_end as string)) { + throw new Error( + `Vändningsdatumet ${reversalDate} ligger utanför nästa räkenskapsår: kontrollera periodernas datum.`, + ) + } +} + +/** + * Post the cut-off verifikat and their vändningar. + * + * Refuses outright unless the vändning can be posted (see + * assertReversalPeriodPostable) and unless every invoice has a known + * vat_treatment: a cut-off without its vändning leaves 1510/2440 permanently + * inflated and makes every new-year payment double-book. + * + * If a reversal still fails after its cut-off committed, the cut-off is + * stornoed through the sanctioned reverseEntry() path (BFL 5 kap 5 §: posted + * entries are never edited or deleted) so the ledger is left consistent, and + * the original error is rethrown. + */ +export async function postKontantmetodCutoff( + supabase: SupabaseClient, + companyId: string, + userId: string, + opts: { + fiscalPeriodId: string + nextFiscalPeriodId: string + periodEnd: string + receivables: CutoffReceivable[] + payables: CutoffPayable[] + entityType?: EntityType + /** Refuse if any invoice lacked a vat_treatment (see CutoffCollection). */ + unknownVatTreatment?: string[] + /** Refuse if any invoice carried moms on a zero-rate treatment. */ + strayVatOnZeroRate?: string[] + }, +): Promise { + if (opts.unknownVatTreatment && opts.unknownVatTreatment.length > 0) { + throw new Error( + `${opts.unknownVatTreatment.length} fakturor saknar momsinställning och kan inte tas med i bokslutsavgränsningen: ` + + `${opts.unknownVatTreatment.slice(0, 10).join(', ')}. Komplettera fakturorna och kör om.`, + ) + } + + if (opts.strayVatOnZeroRate && opts.strayVatOnZeroRate.length > 0) { + throw new Error( + `${opts.strayVatOnZeroRate.length} fakturor har moms trots en momsfri momsinställning (export, omvänd betalningsskyldighet eller undantagen) och kan inte tas med i bokslutsavgränsningen: ` + + `${opts.strayVatOnZeroRate.slice(0, 10).join(', ')}. Rätta fakturorna och kör om.`, + ) + } + + const { receivableLines, payableLines } = buildCutoffLines( + opts.receivables, + opts.payables, + opts.entityType, + ) + const reversalDate = nextDay(opts.periodEnd) + + const result: PostCutoffResult = { + receivableEntry: null, + receivableReversal: null, + payableEntry: null, + payableReversal: null, + } + + if (receivableLines.length === 0 && payableLines.length === 0) return result + + await assertReversalPeriodPostable(supabase, companyId, opts.nextFiscalPeriodId, reversalDate) + + /** + * Post a cut-off/vändning pair. On reversal failure the cut-off is stornoed + * so the pair is all-or-nothing from the ledger's point of view. + */ + const postPair = async ( + lines: CreateJournalEntryLineInput[], + label: string, + references: string[], + ): Promise<[JournalEntry, JournalEntry]> => { + const entry = await createJournalEntry(supabase, companyId, userId, { + fiscal_period_id: opts.fiscalPeriodId, + entry_date: opts.periodEnd, + description: `${label} vid bokslut (kontantmetoden)`, + source_type: 'year_end', + notes: buildCutoffNote(label, references), + lines, + }) + + try { + const reversal = await createJournalEntry(supabase, companyId, userId, { + fiscal_period_id: opts.nextFiscalPeriodId, + entry_date: reversalDate, + description: `Vändning ${label.toLowerCase()} bokslut (kontantmetoden)`, + source_type: 'year_end', + notes: buildCutoffNote(`Vändning ${label.toLowerCase()}`, references), + lines: reverseLines(lines), + }) + return [entry, reversal] + } catch (reversalError) { + // Compensate: an un-reversed cut-off is worse than no cut-off at all. + try { + // Storno in the same period as the cut-off so the pair nets to zero + // inside the year being closed. + await reverseEntry(supabase, companyId, userId, entry.id, opts.periodEnd) + } catch (stornoError) { + log.error( + 'cut-off reversal failed AND the compensating storno failed: 1510/2440 left inflated, manual correction required', + stornoError as Error, + { companyId, entryId: entry.id }, + ) + } + throw reversalError + } + } + + if (receivableLines.length > 0) { + const [entry, reversal] = await postPair( + receivableLines, + 'Kundfordringar', + opts.receivables.map((r) => r.reference), + ) + result.receivableEntry = entry + result.receivableReversal = reversal + } + + if (payableLines.length > 0) { + const [entry, reversal] = await postPair( + payableLines, + 'Leverantörsskulder', + opts.payables.map((p) => p.reference), + ) + result.payableEntry = entry + result.payableReversal = reversal + } + + return result +}