/** * gnubok_vat_close_check: momsdeklaration completeness gate. * * Regression cover for the pre-flight hole this tool used to have. Its only * declaration check was `acquisitionAndImportBase > 0 && ruta48 === 0`, and * ruta 48 aggregates 2641/2642/2645/2646/2647/2649: a single ordinary domestic * receipt in the period made it unreachable, and there was no basbelopp check * (rutor 20-24 against rutor 30-32) at all. A declaration with fiktiv moms and * no underlag, the FK004 shape Skatteverket rejects, came back as "Klart för * stängning". * * The tool now runs the SHARED core checks (lib/reports/vat-declaration-checks) * over the full SKV 4700 projection, folded through the same filing gate the * web UI uses, so these assertions are really asserting "MCP agrees with the * web UI". * * Only the bank reconciliation is mocked: it queries shapes this fixture has no * opinion about, and an unreconciled bank would add an unrelated blocker. */ import { describe, it, expect, vi } from 'vitest' vi.mock('@/lib/reconciliation/bank-reconciliation', () => ({ getReconciliationStatus: vi.fn(async () => ({ is_reconciled: true, difference: 0, unmatched_transaction_count: 0, unmatched_gl_line_count: 0, })), })) import { computeVatCloseCheck } from '../server' interface MockLine { entry?: string account_number: string debit_amount?: number credit_amount?: number source_type?: string | null } interface MockChartAccount { account_number: string account_name: string default_vat_rate: number | null } /** * Table-routed Supabase double. journal_entries / journal_entry_lines serve the * fixture; everything else (transactions, supplier_invoices, company_settings) * comes back empty so no unrelated blocker fires. */ function mockSupabase(lines: MockLine[], chartAccounts: MockChartAccount[] = []) { const entries = [ ...new Map( lines.map((l, i) => { const id = l.entry ?? `entry-${i}` return [ id, { id, source_type: l.source_type ?? 'manual', voucher_series: 'A', voucher_number: 100 + i, entry_date: '2026-01-15', description: 'Fixture', status: 'posted', }, ] }), ).values(), ] const bareLines = lines.map((l, i) => ({ id: `line-${String(i).padStart(4, '0')}`, journal_entry_id: l.entry ?? `entry-${i}`, account_number: l.account_number, debit_amount: l.debit_amount ?? 0, credit_amount: l.credit_amount ?? 0, })) const makeChain = (rows: unknown[]): Record => { const chain: Record = {} const settled = { data: rows, error: null, count: rows.length } chain.range = () => settled chain.single = async () => ({ data: null, error: null }) chain.maybeSingle = async () => ({ data: null, error: null }) chain.then = (resolve: (v: unknown) => void) => resolve(settled) for (const m of [ 'order', 'lte', 'gte', 'neq', 'in', 'eq', 'is', 'select', 'limit', 'contains', 'filter', 'not', 'or', ]) { chain[m] = () => chain } return chain } return { from: (table: string) => { if (table === 'journal_entries') return makeChain(entries) if (table === 'journal_entry_lines') return makeChain(bareLines) if (table === 'chart_of_accounts') return makeChain(chartAccounts) return makeChain([]) }, // The missing-underlag blocker reads the verifikat_without_documents RPC, // which answers with an envelope rather than a row set. This fixture has no // opinion about underlag, so it answers "none missing"; the predicate itself // is covered by vat-close-check-missing-underlag.test.ts. rpc: (fn: string) => fn === 'verifikat_without_documents' ? Promise.resolve({ data: { ok: true, total_count: 0, verifikat: [] }, error: null }) : makeChain([]), } as never } const PERIOD = { period_type: 'monthly', year: 2026, period: 1 } describe('gnubok_vat_close_check: declaration completeness', () => { it('includes a null-rate 3011 with matching domestic VAT evidence (#1289)', async () => { const result = await computeVatCloseCheck( PERIOD, 'company-1', mockSupabase( [ { entry: 'e1', account_number: '3011', credit_amount: 9725 }, { entry: 'e1', account_number: '2611', credit_amount: 2431.25 }, { entry: 'e1', account_number: '1510', debit_amount: 12156.25 }, ], [{ account_number: '3011', account_name: 'Försäljning tjänster inom Sverige, 25 % moms', default_vat_rate: null, }], ), ) expect(result.rutor.ruta05).toBe(9725) expect(result.rutor.ruta10).toBe(2431.25) expect(result.declaration_checks.map((finding) => finding.code)) .not.toContain('OUTPUT_VAT_WITHOUT_SALES_BASE') expect(result.ready_to_close).toBe(true) }) it('refuses the #1164 declaration: fiktiv moms on 2614/2645 with no basbelopp on 44xx/45xx', async () => { // Both VAT legs of a reverse-charge purchase booked, but the cost went // straight to 6540 instead of the 4535 basis account, so rutor 20-24 stay // empty while ruta 30 carries 1 250 kr. This is FK004. const result = await computeVatCloseCheck( PERIOD, 'company-1', mockSupabase([ { entry: 'e1', account_number: '3001', credit_amount: 10000 }, { entry: 'e1', account_number: '2611', credit_amount: 2500 }, { entry: 'e1', account_number: '1510', debit_amount: 12500 }, { entry: 'e2', account_number: '6540', debit_amount: 5000 }, { entry: 'e2', account_number: '2440', credit_amount: 5000 }, { entry: 'e2', account_number: '2614', credit_amount: 1250 }, { entry: 'e2', account_number: '2645', debit_amount: 1250 }, ]), ) expect(result.ready_to_close).toBe(false) expect(result.summary).not.toMatch(/Klart för stängning/) expect(result.summary).toMatch(/ofullständigt/) const codes = result.declaration_checks.map((c) => c.code) expect(codes).toContain('RC_BASIS_MISSING') const finding = result.declaration_checks.find((c) => c.code === 'RC_BASIS_MISSING')! expect(finding.status).toBe('ERROR') expect(finding.message).toMatch(/FK004/) const blocker = result.blockers.find((b) => b.check_code === 'RC_BASIS_MISSING')! expect(blocker.kind).toBe('reverse_charge_input_missing') expect(blocker.severity).toBe('high') // The old mirror could not see this at all: ruta 48 carries the 2645 leg. expect(result.rutor.ruta48).toBe(1250) }) it('one ordinary domestic receipt no longer suppresses the reverse-charge check', async () => { // The exact unreachability bug: 2641 from a normal receipt puts 200 kr in // ruta 48, so `ruta48 === 0` was false and the whole period went unchecked // even though the reverse charge is booked on one side only. const result = await computeVatCloseCheck( PERIOD, 'company-1', mockSupabase([ { entry: 'e1', account_number: '6210', debit_amount: 800 }, { entry: 'e1', account_number: '2641', debit_amount: 200 }, { entry: 'e1', account_number: '1930', credit_amount: 1000 }, { entry: 'e2', account_number: '6540', debit_amount: 5000 }, { entry: 'e2', account_number: '2440', credit_amount: 5000 }, { entry: 'e2', account_number: '2614', credit_amount: 1250 }, ]), ) expect(result.rutor.ruta48).toBe(200) expect(result.rutor.ruta48).not.toBe(0) expect(result.ready_to_close).toBe(false) const codes = result.declaration_checks.map((c) => c.code) expect(codes).toContain('RC_BASIS_MISSING') expect(codes).toContain('RC_INPUT_VAT_MISMATCH') // The input-VAT mismatch stays advisory (partial deduction can explain a // shortfall); the missing basbelopp is what blocks. const mismatch = result.blockers.find((b) => b.check_code === 'RC_INPUT_VAT_MISMATCH')! expect(mismatch.severity).toBe('medium') expect( result.blockers.some((b) => b.check_code === 'RC_BASIS_MISSING' && b.severity === 'high'), ).toBe(true) }) // The check the shared core sharpened, now reachable from MCP: rutor 30-32 are // compared against the reverse-charge INPUT accounts (2645/2647), not against // the ruta 48 aggregate that ordinary debiterad ingående moms inflates. it('sees a completely missing beräknad ingående moms behind a larger ruta 48', async () => { const result = await computeVatCloseCheck( PERIOD, 'company-1', mockSupabase([ // EU services purchase, correctly booked on the underlag side: basis on // 4535 (ruta 21 = 200 000) and fiktiv utgående moms on 2614 (ruta 30 = // 50 000), so there is no FK004 finding to hide behind. The beräknad // ingående moms on 2645 was never booked. { entry: 'e1', account_number: '4535', debit_amount: 200000 }, { entry: 'e1', account_number: '2440', credit_amount: 200000 }, { entry: 'e1', account_number: '2614', credit_amount: 50000 }, // Ordinary domestic purchases: 60 000 kr of debiterad ingående moms. // This is the mask: ruta 48 (60 000) stays ABOVE the RC output (50 000). { entry: 'e2', account_number: '5410', debit_amount: 240000 }, { entry: 'e2', account_number: '2641', debit_amount: 60000 }, { entry: 'e2', account_number: '1930', credit_amount: 300000 }, ]), ) expect(result.rutor.ruta30).toBe(50000) expect(result.rutor.ruta48).toBe(60000) // Precisely the state the aggregate comparison called fine. expect(result.rutor.ruta48).toBeGreaterThan(result.rutor.ruta30) const mismatch = result.declaration_checks.find((c) => c.code === 'RC_INPUT_VAT_MISMATCH')! expect(mismatch).toBeDefined() // Still a WARNING, deliberately: limited avdragsrätt (blandad verksamhet, // ML 13 kap 18/24-25 §§) makes a shortfall correct for some filers. expect(mismatch.status).toBe('WARNING') expect(mismatch.message).toMatch(/2645/) // \s, not a literal space: sv-SE groups thousands with a no-break space. expect(mismatch.message).toMatch(/50\s000 kr saknas/) expect(result.declaration_checks.map((c) => c.code)).not.toContain('RC_BASIS_MISSING') // A warning is not a blocker: ruta 48 is non-zero, so a partial-deduction // story exists and the noInputVatAtAll escalation must stay out of it. const blocker = result.blockers.find((b) => b.check_code === 'RC_INPUT_VAT_MISMATCH')! expect(blocker.kind).toBe('reverse_charge_input_missing') expect(blocker.severity).toBe('medium') }) it('still blocks import output VAT with no deductible input at all (ruta 60 without ruta 48)', async () => { // The one case the shared checks do not compare against ruta 48: keep the // local escalation so removing the old mirror loses no coverage. const result = await computeVatCloseCheck( PERIOD, 'company-1', mockSupabase([ { entry: 'e1', account_number: '4545', debit_amount: 10000 }, { entry: 'e1', account_number: '2440', credit_amount: 10000 }, { entry: 'e1', account_number: '2615', credit_amount: 2500 }, ]), ) expect(result.ready_to_close).toBe(false) const blocker = result.blockers.find((b) => b.kind === 'reverse_charge_input_missing')! expect(blocker.severity).toBe('high') expect(blocker.message).toMatch(/ruta 60\/61\/62/) }) it('a clean period still passes: reverse charge booked with basbelopp AND both VAT legs', async () => { const result = await computeVatCloseCheck( PERIOD, 'company-1', mockSupabase([ { entry: 'e1', account_number: '3001', credit_amount: 10000 }, { entry: 'e1', account_number: '2611', credit_amount: 2500 }, { entry: 'e1', account_number: '1510', debit_amount: 12500 }, // EU services purchase, correctly booked: basis on 4535 (ruta 21), // fiktiv utgående på 2614 (ruta 30), beräknad ingående på 2645 (ruta 48). { entry: 'e2', account_number: '4535', debit_amount: 5000 }, { entry: 'e2', account_number: '2440', credit_amount: 5000 }, { entry: 'e2', account_number: '2614', credit_amount: 1250 }, { entry: 'e2', account_number: '2645', debit_amount: 1250 }, ]), ) expect(result.declaration_checks).toEqual([]) expect(result.blockers.filter((b) => b.severity === 'high')).toEqual([]) expect(result.ready_to_close).toBe(true) expect(result.summary).toMatch(/Klart för stängning/) expect(result.rutor.ruta49).toBe(2500) }) })