From 6318501b71d23e85309990e23fb58fc2e3f2020b Mon Sep 17 00:00:00 2001 From: Mattsson <111893710+mattssonn@users.noreply.github.com> Date: Thu, 30 Jul 2026 11:28:50 +0200 Subject: [PATCH] fix(vat): recover ruta 05 for null-rate custom accounts (#1296) --- DECISIONS.md | 2 + .../[ruta]/sources/__tests__/route.test.ts | 18 +++++- .../vat-close-check-completeness.test.ts | 34 ++++++++++- .../__tests__/vat-declaration-checks.test.ts | 6 +- lib/reports/__tests__/vat-declaration.test.ts | 61 +++++++++++++------ lib/reports/vat-declaration-checks.ts | 6 +- lib/reports/vat-revenue-accounts.ts | 60 +++++++++++++++--- 7 files changed, 154 insertions(+), 33 deletions(-) diff --git a/DECISIONS.md b/DECISIONS.md index bbecc9c0..38000abb 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -699,3 +699,5 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-07-29] Booking-feedback parity: extracted runCategorize's success tail into one finishBooking() rather than copying the toast into the counterparty branch. The counterparty path was already a second, thinner implementation of the same tail (the reason it silently lacked confirmation, undo and the count decrement), so a third copy was the wrong shape. Also caught while wiring the parity test: handleTransactionBooked (manual booking dialog / voucher match) never decremented totalUncategorizedCount either, so the header count stayed one high until the next refetch; fixed. Deliberately NOT given an Ångra action: its `matched` branch links the transaction to a PRE-EXISTING verifikat, and /uncategorize storno-reverses whatever journal_entry_id the transaction points at, so an undo there would reverse a voucher the user never created in that flow. [2026-07-30] InvoiceMatchDialog classifies stale targets as matchable, settled, or not open instead of calling every invalid status fully paid: paid and zero-balance targets need different copy from cancelled, credited, disputed, reversed, draft, or malformed targets, while valid partially paid invoices keep the existing amount-difference flow. Blocked targets do not fetch or show a voucher preview or a confirm-outcome panel because neither match route has a reachable success path for them. + +[2026-07-30] Issue #1289 ruta 05 null-rate fallback is report-local and requires both the 30x1/30x2/30x3 suffix and a matching 25/12/6 % moms account label; explicit configured values win. Declined historical backfill, account-creation derivation, and a DB NOT NULL guard: 3011 is custom rather than BAS 2026, NULL is valid across class 3, and history or mixed vouchers cannot safely set future defaults. diff --git a/app/api/reports/vat-declaration/ruta/[ruta]/sources/__tests__/route.test.ts b/app/api/reports/vat-declaration/ruta/[ruta]/sources/__tests__/route.test.ts index 6923d2d4..e753227e 100644 --- a/app/api/reports/vat-declaration/ruta/[ruta]/sources/__tests__/route.test.ts +++ b/app/api/reports/vat-declaration/ruta/[ruta]/sources/__tests__/route.test.ts @@ -28,7 +28,11 @@ interface SupabaseShape { function buildSupabase( linesResult: { data: unknown; error: unknown }, fiscalPeriodResult: { data: unknown; error: unknown } = { data: null, error: null }, - chartAccounts: Array<{ account_number: string; default_vat_rate: number }> = [] + chartAccounts: Array<{ + account_number: string + account_name?: string + default_vat_rate: number | null + }> = [] ): SupabaseShape { const chartResult = { data: chartAccounts, error: null } return { @@ -253,6 +257,18 @@ describe('GET /api/reports/vat-declaration/ruta/[ruta]/sources: ruta 05 accounts expect(accounts).toContain('3001') // static mapping still there }) + it('drills into a null-rate account when its number and label resolve the rate (#1289)', async () => { + const supabase = buildSupabase({ data: [], error: null }, { data: null, error: null }, [{ + account_number: '3011', + account_name: 'Försäljning tjänster inom Sverige, 25 % moms', + default_vat_rate: null, + }]) + authOk(supabase) + + expect((await get('05')).status).toBe(200) + expect(rpcAccounts(supabase)).toContain('3011') + }) + it('leaves other rutor on the static mapping alone', async () => { const supabase = buildSupabase({ data: [], error: null }, { data: null, error: null }, [ { account_number: '3013', default_vat_rate: 0.06 }, diff --git a/extensions/general/mcp-server/__tests__/vat-close-check-completeness.test.ts b/extensions/general/mcp-server/__tests__/vat-close-check-completeness.test.ts index bc3ba3fe..b260ac18 100644 --- a/extensions/general/mcp-server/__tests__/vat-close-check-completeness.test.ts +++ b/extensions/general/mcp-server/__tests__/vat-close-check-completeness.test.ts @@ -38,12 +38,18 @@ interface MockLine { 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[]) { +function mockSupabase(lines: MockLine[], chartAccounts: MockChartAccount[] = []) { const entries = [ ...new Map( lines.map((l, i) => { @@ -91,6 +97,7 @@ function mockSupabase(lines: MockLine[]) { 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, @@ -107,6 +114,31 @@ function mockSupabase(lines: MockLine[]) { 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 diff --git a/lib/reports/__tests__/vat-declaration-checks.test.ts b/lib/reports/__tests__/vat-declaration-checks.test.ts index 23ba9089..4b90a9ea 100644 --- a/lib/reports/__tests__/vat-declaration-checks.test.ts +++ b/lib/reports/__tests__/vat-declaration-checks.test.ts @@ -423,7 +423,11 @@ describe('runVatDeclarationChecks', () => { ruta49: 2500, } const findings = runVatDeclarationChecks(rutor) - expect(findings.find((f) => f.code === 'OUTPUT_VAT_WITHOUT_SALES_BASE')?.status).toBe('ERROR') + const finding = findings.find((f) => f.code === 'OUTPUT_VAT_WITHOUT_SALES_BASE') + expect(finding?.status).toBe('ERROR') + expect(finding?.message).toContain('momspliktiga intäktskonton') + expect(finding?.message).toContain('Standard moms') + expect(finding?.message).not.toContain('3001/3002/3003') }) // SKV §4.1.1.4 rule 5: import base without import output VAT. diff --git a/lib/reports/__tests__/vat-declaration.test.ts b/lib/reports/__tests__/vat-declaration.test.ts index 32c6914d..9029b3c2 100644 --- a/lib/reports/__tests__/vat-declaration.test.ts +++ b/lib/reports/__tests__/vat-declaration.test.ts @@ -8,13 +8,16 @@ let resultIdx: number let results: Array<{ data?: unknown; error?: unknown }> /** - * The company's own class 3 accounts carrying a "Standard moms", as - * fetchDynamicRuta05Accounts reads them. Answered off a table-routed builder - * rather than the sequential queue: every calculateVatDeclaration test would - * otherwise have to seed one, and a missing seed would silently hand the chart - * query the ledger result. + * The company's own class 3 accounts, as fetchDynamicRuta05Accounts reads + * them. Answered off a table-routed builder rather than the sequential queue: + * every calculateVatDeclaration test would otherwise have to seed one, and a + * missing seed would silently hand the chart query the ledger result. */ -let chartAccounts: Array<{ account_number: string; default_vat_rate: number | null }> +let chartAccounts: Array<{ + account_number: string + account_name?: string + default_vat_rate: number | null +}> function makeBuilder() { const b: Record = {} @@ -28,9 +31,9 @@ function makeBuilder() { } /** - * chart_of_accounts builder. Applies the same filters the real query relies on - * (account_class = 3, default_vat_rate in the taxable sats) so a fixture can - * assert that a rate-less or non-revenue konto never reaches ruta 05. + * chart_of_accounts builder. The real query returns all active class 3 rows: + * fetchDynamicRuta05Accounts applies configured-rate and narrow missing-rate + * fallback rules in memory. */ function makeChartBuilder() { const b: Record = {} @@ -39,9 +42,7 @@ function makeChartBuilder() { } b.then = (resolve: (v: unknown) => void) => resolve({ - data: chartAccounts.filter( - (a) => a.default_vat_rate != null && [0.25, 0.12, 0.06].includes(a.default_vat_rate) - ), + data: chartAccounts.map((account) => ({ account_name: '', ...account })), error: null, }) return b @@ -939,6 +940,26 @@ describe('calculateVatDeclaration: parent/summary accounts', () => { // ============================================================ describe('calculateVatDeclaration: company-specific ruta 05 accounts', () => { + it('infers a missing rate only from a matching domestic-sales number and label (#1289)', async () => { + chartAccounts = [{ + account_number: '3011', + account_name: 'Försäljning tjänster inom Sverige, 25 % moms', + default_vat_rate: null, + }] + seedLedger([ + { account_number: '3011', debit_amount: 0, credit_amount: 9725 }, + { account_number: '2611', debit_amount: 0, credit_amount: 2431.25 }, + ]) + + const result = await calculateVatDeclaration(supabase, 'company-1', 'monthly', 2024, 1) + const findings = runVatDeclarationChecks(result.rutor) + + expect(result.rutor.ruta05).toBe(9725) + expect(result.rutor.ruta10).toBe(2431.25) + expect(result.breakdown.invoices.base25).toBe(9725) + expect(findings.map((f) => f.code)).not.toContain('OUTPUT_VAT_WITHOUT_SALES_BASE') + }) + it('includes a user-added revenue account carrying a moms-sats', async () => { chartAccounts = [{ account_number: '3013', default_vat_rate: 0.06 }] seedLedger([ @@ -1090,16 +1111,20 @@ describe('calculateVatDeclaration: company-specific ruta 05 accounts', () => { expect(result.breakdown.invoices.base25).toBe(2000) }) - it('ignores revenue accounts with no sats or an explicit 0 %', async () => { - // "Ingen standard" and "Ingen moms" both mean the konto is not declared - // momspliktig: momsfri revenue belongs in ruta 42, not 05. + it('ignores missing rates without matching evidence and keeps explicit 0 % authoritative', async () => { + // A number or a free-text label alone is not enough, and an explicit + // "Ingen moms" always wins over the fallback convention. chartAccounts = [ - { account_number: '3013', default_vat_rate: null }, - { account_number: '3014', default_vat_rate: 0 }, + { account_number: '3013', account_name: 'Varugrupp C', default_vat_rate: null }, + { account_number: '3011', account_name: 'Varugrupp A, 25 % moms', default_vat_rate: 0 }, + { account_number: '3098', account_name: 'Försäljning 25 % moms', default_vat_rate: null }, + { account_number: '3023', account_name: 'Försäljning 25 % moms', default_vat_rate: null }, ] seedLedger([ { account_number: '3013', debit_amount: 0, credit_amount: 8000 }, - { account_number: '3014', debit_amount: 0, credit_amount: 2000 }, + { account_number: '3011', debit_amount: 0, credit_amount: 2000 }, + { account_number: '3098', debit_amount: 0, credit_amount: 1000 }, + { account_number: '3023', debit_amount: 0, credit_amount: 500 }, ]) const result = await calculateVatDeclaration(supabase, 'company-1', 'monthly', 2024, 1) diff --git a/lib/reports/vat-declaration-checks.ts b/lib/reports/vat-declaration-checks.ts index ccfb5db8..47477857 100644 --- a/lib/reports/vat-declaration-checks.ts +++ b/lib/reports/vat-declaration-checks.ts @@ -267,8 +267,10 @@ export function runVatDeclarationChecks( message: 'Du har redovisat utgående moms (ruta 10-12) men ingen momspliktig ' + 'försäljning (ruta 05-08). Skatteverket kräver att utgående moms ' + - 'matchas med ett försäljningsunderlag. Kontrollera att intäktskonton ' + - '(3001/3002/3003) är bokförda för varje VAT-rad.', + 'matchas med ett försäljningsunderlag. Kontrollera att momspliktiga ' + + 'intäktskonton är bokförda för varje momsrad. Om ett försäljningskonto ' + + 'saknas i ruta 05, kontrollera att kontots "Standard moms" är rätt ' + + 'inställd i kontoplanen.', rutor: ['ruta05', 'ruta06', 'ruta07', 'ruta08', 'ruta10', 'ruta11', 'ruta12'], }) } diff --git a/lib/reports/vat-revenue-accounts.ts b/lib/reports/vat-revenue-accounts.ts index aa3c1d2d..3d657c4f 100644 --- a/lib/reports/vat-revenue-accounts.ts +++ b/lib/reports/vat-revenue-accounts.ts @@ -17,10 +17,11 @@ import { ACCOUNT_TO_BOX } from '@/lib/vat/moms-box-mapping' * correct declaration got a blocking OUTPUT_VAT_WITHOUT_SALES error (#1261). * * The per-account "Standard moms" (chart_of_accounts.default_vat_rate) is the - * resolver: a class 3 konto the user marked 25/12/6 % is by definition domestic - * taxable sales, which is exactly what ruta 05 collects. The account dialogs - * say so, since the field now carries declaration weight and not just line - * prefill. + * primary resolver: a class 3 konto the user marked 25/12/6 % is by definition + * domestic taxable sales, which is exactly what ruta 05 collects. For a + * missing value, the narrow 30x1/30x2/30x3 convention is accepted only when + * the account label explicitly confirms the same rate. This recovers imported + * and older custom accounts without guessing from a number or free text alone. */ /** @@ -43,9 +44,43 @@ export const RUTA_05_EXCLUDED_ACCOUNTS = new Set([ '3913', ]) -/** VAT rates that mark a konto as momspliktig försäljning. 0 and NULL do not. */ +/** VAT rates that mark a configured konto as momspliktig försäljning. */ const TAXABLE_RATES = [0.25, 0.12, 0.06] +const DOMESTIC_SALES_RATE_BY_SUFFIX: Record = { + '1': 0.25, + '2': 0.12, + '3': 0.06, +} + +/** + * Resolve a missing rate for a company-specific domestic sales sub-account. + * + * Neither signal is sufficient by itself: + * - 3011 is not in the BAS 2026 catalog and custom numbers can be repurposed; + * - account labels are free text and can be stale or contradictory. + * + * Requiring the conventional 30x1/30x2/30x3 suffix and one matching explicit + * "25/12/6 % moms" label keeps the fallback deterministic. A configured value, + * including explicit 0 %, is always authoritative and never reaches here. + */ +function inferDomesticSalesRate( + accountNumber: string, + accountName: string | null | undefined, +): number | null { + const accountMatch = /^30\d([123])$/.exec(accountNumber) + if (!accountMatch) return null + + const expectedRate = DOMESTIC_SALES_RATE_BY_SUFFIX[accountMatch[1]] + const namedRates = new Set( + [...(accountName ?? '').matchAll(/\b(25|12|6)\s*%\s*moms\b/gi)].map( + (match) => Number(match[1]) / 100, + ) + ) + + return namedRates.size === 1 && namedRates.has(expectedRate) ? expectedRate : null +} + /** * Ruta 05 accounts that ACCOUNT_TO_BOX already sums, but whose per-rate bucket * cannot be inferred from the account number. @@ -104,11 +139,15 @@ export async function fetchDynamicRuta05Accounts( supabase: SupabaseClient, companyId: string ): Promise { - const rows = await fetchAllRows<{ account_number: string; default_vat_rate: number | string | null }>( + const rows = await fetchAllRows<{ + account_number: string + account_name: string + default_vat_rate: number | string | null + }>( ({ from, to }) => supabase .from('chart_of_accounts') - .select('account_number, default_vat_rate') + .select('account_number, account_name, default_vat_rate') .eq('company_id', companyId) .eq('account_class', 3) // Deactivated accounts only. is_active is nullable (boolean DEFAULT @@ -117,7 +156,6 @@ export async function fetchDynamicRuta05Accounts( // NULL-flagged konto: the same kind of quiet omission this whole fix // exists to remove. .not('is_active', 'is', false) - .in('default_vat_rate', TAXABLE_RATES) .order('account_number', { ascending: true }) .range(from, to) ) @@ -129,8 +167,10 @@ export async function fetchDynamicRuta05Accounts( const staticRateByAccount = new Map() for (const row of rows) { const account = row.account_number - const rate = Number(row.default_vat_rate) - if (!TAXABLE_RATES.includes(rate)) continue + const rate = row.default_vat_rate === null + ? inferDomesticSalesRate(account, row.account_name) + : Number(row.default_vat_rate) + if (rate === null || !TAXABLE_RATES.includes(rate)) continue // Checked before the ACCOUNT_TO_BOX skip: these accounts ARE in that map, // which is precisely why they need the rate surfaced separately.