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 +}