From 9d91ee01685960a00b1671daffeab83ac880cd06 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg <149234542+jakobwennberg@users.noreply.github.com> Date: Sat, 11 Jul 2026 21:37:30 +0200 Subject: [PATCH] =?UTF-8?q?fix(vat):=20keep=20the=20momsrapport=20intact?= =?UTF-8?q?=20after=20a=20manual=20nollst=C3=A4llning=20(#990)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(vat): keep the momsrapport intact after a manual nollställning (#984) The momsrapport already excludes settlement verifikat tagged with source_type 'vat_settlement' (#983), but settlements booked any other way still zeroed every ruta the moment they were posted: manual momsomföringar booked before the tagged flow existed (the report in issue #984), SIE-imported settlements, and storno reversals of a settlement, which inverted the sign instead and silently doubled the rutor after an annullera. Exclude settlement entries by SHAPE as well: an entry with at least one line on a declaration account (ACCOUNT_RUTA) and at least one on a settlement net account (2650/1650) is bookkeeping about the declaration, not VAT-bearing activity, in both the web projection (fetchVatAccountTotals) and the MCP twin (computeVatReport). Opening-balance entries are exempt: carried-in 26xx balances are unsettled VAT that belongs in the next declaration. Shape-detected POSTED settlements now also gate the "Skapa verifikat" button through existing_entries, since the proposal re-clears the full period and booking it on top of a manual settlement would corrupt the 26xx balances. Stornos never gate, so annullera still re-enables booking. Fixes #984 Co-Authored-By: Claude Fable 5 * fix(mcp): stable id order for the paginated VAT-report line fetch (CodeRabbit) fetchAllRows pages with .range(); without a unique .order() rows can shift across page boundaries once a period exceeds 1000 lines, skipping or double-counting journal lines in the rutor. Same discipline as the web projection (fetch-all.ts). Pre-existing, but the query was already being touched for #984. Also documents the shape-rule triage from the compliance-bot review in DECISIONS.md: compound business-VAT-plus-2650 verifikat stay a known accepted residual (a direction guard would break the storno exclusion), and the opening-balance concern is false for app flows (SIE import and set_opening_balances both tag source_type 'opening_balance'). Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- DECISIONS.md | 2 + .../__tests__/vat-report-compute.test.ts | 47 ++++++ extensions/general/mcp-server/server.ts | 36 ++++- lib/reports/__tests__/vat-declaration.test.ts | 96 ++++++++++++ lib/reports/__tests__/vat-settlement.test.ts | 139 ++++++++++++++++-- lib/reports/vat-declaration.ts | 84 +++++++++-- lib/reports/vat-settlement.ts | 29 +++- 7 files changed, 404 insertions(+), 29 deletions(-) diff --git a/DECISIONS.md b/DECISIONS.md index 12058523..92909a06 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -60,3 +60,5 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-07-09] SalaryCalendar absence-type rainbow palette (red/amber/emerald/blue/indigo pills) left as-is in the UI consistency pass: those colors encode absence categories (data), not status chrome, and swapping them for the 3 semantic tokens would collapse 5 distinguishable categories; needs a proper categorical-palette decision instead of a mechanical fix. [2026-07-10] Momsverifikat from momsrapport (#980): the proposal clears each 26xx account at exact öre but books the 2650/1650 net at the FILED whole-krona amount (buildFiledAmounts, öretal faller bort) with the gap on 3740, so redovisningskontot always matches the skattekonto movement; and vat_settlement entries are excluded from the VAT report projection (web calculateVatDeclaration + MCP computeVatReport) because a pure-projection report would otherwise read zero (and Skatteverket submission would file zeros) the moment the settlement is booked. [2026-07-10] VatBookingCard hard-disables "Skapa verifikat" while a POSTED vat_settlement exists in the period (CodeRabbit finding, accepted over the initial warn-but-allow): the proposal is not delta-aware (it re-clears the FULL period), so booking twice corrupts 26xx balances; the sanctioned redo path is annullera (storno restores the balances and re-enables the button). Already-booked detection is by source_type + entry_date within the period, so redating the entry outside the period escapes the gate: accepted v1 limitation. Card copy is hardcoded Swedish per the file's existing momsdeklaration convention (i18n.md). +[2026-07-11] Momsrapport after settlement (#984): extended the VAT-report exclusion from tag-only to shape-based. Any entry touching both a declaration account (ACCOUNT_RUTA) and a settlement net account (2650/1650) is treated as a momsredovisning and excluded from the projection (web calculateVatDeclaration + MCP computeVatReport), covering manual momsomforingar booked before #980 shipped, SIE-imported settlements, and stornos of a settlement (which would otherwise double the rutor after annullera, a latent bug in the #983 tag-only filter). Opening-balance entries are exempt from the shape rule: carried-in 26xx balances are unsettled VAT that belongs in the next declaration. Shaped POSTED entries also gate the "Skapa verifikat" button via existing_entries (the proposal re-clears the full period, so booking over a manual settlement would corrupt 26xx); stornos never gate, or annullera could not re-enable booking. Rejected the frozen-snapshot alternative the issue suggested: pure projection heals historical periods retroactively (a snapshot would not exist for them) and needs no migration. +[2026-07-11] #984 shape-rule residuals triaged and ACCEPTED (compliance-bot review): a compound verifikat mixing business VAT lines with a 2650/1650 payment/correction line in ONE entry is excluded from the rutor by the shape rule (under-reports). Kept anyway: such compound entries are rare bad practice, and the suggested direction guard (only exclude when 2650 is credited / 1650 debited) would break the storno exclusion, whose reversal carries exactly the flipped sides. Opening-balance concern verified false for app flows: SIE import and set_opening_balances both tag source_type 'opening_balance' (sie-import.ts); only a hand-booked IB verifikat shares the compound-entry residual. diff --git a/extensions/general/mcp-server/__tests__/vat-report-compute.test.ts b/extensions/general/mcp-server/__tests__/vat-report-compute.test.ts index 19582407..9fc35e51 100644 --- a/extensions/general/mcp-server/__tests__/vat-report-compute.test.ts +++ b/extensions/general/mcp-server/__tests__/vat-report-compute.test.ts @@ -13,6 +13,8 @@ interface MockLine { account_number: string debit_amount: number credit_amount: number + journal_entry_id?: string + journal_entries?: { source_type: string | null } | Array<{ source_type: string | null }> } function mockSupabaseWithLines(lines: MockLine[]) { @@ -24,6 +26,7 @@ function mockSupabaseWithLines(lines: MockLine[]) { const terminal = { data: lines, error: null } const chain: Record unknown> = {} chain.range = () => terminal + chain.order = () => chain chain.lte = () => chain chain.gte = () => chain chain.neq = () => chain @@ -186,6 +189,50 @@ describe('computeVatReport', () => { expect(result.rutor.ruta40).toBe(0) }) + it('excludes a manual settlement-shaped entry from the rutor (#984)', async () => { + const lines: MockLine[] = [ + // Business activity on e1. + { journal_entry_id: 'e1', account_number: '3001', debit_amount: 0, credit_amount: 1000, journal_entries: { source_type: 'invoice_created' } }, + { journal_entry_id: 'e1', account_number: '2611', debit_amount: 0, credit_amount: 250, journal_entries: { source_type: 'invoice_created' } }, + // Manual momsomföring on e2 (no vat_settlement tag): would zero ruta10. + { journal_entry_id: 'e2', account_number: '2611', debit_amount: 250, credit_amount: 0, journal_entries: { source_type: 'manual' } }, + { journal_entry_id: 'e2', account_number: '2650', debit_amount: 0, credit_amount: 250, journal_entries: { source_type: 'manual' } }, + ] + + const result = await computeVatReport( + { period_type: 'monthly', year: 2026, period: 1 }, + 'company-1', + mockSupabaseWithLines(lines) + ) + + expect(result.rutor.ruta05).toBe(1000) + expect(result.rutor.ruta10).toBe(250) + expect(result.rutor.ruta49).toBe(250) + }) + + it('settlement-shape exclusion handles the array-typed embed and exempts opening balances', async () => { + const lines: MockLine[] = [ + // Storno of a settlement, with the embed in array form (the client's + // inferred shape): must be excluded from ruta10. + { journal_entry_id: 'e3', account_number: '2611', debit_amount: 0, credit_amount: 100, journal_entries: [{ source_type: 'storno' }] }, + { journal_entry_id: 'e3', account_number: '2650', debit_amount: 100, credit_amount: 0, journal_entries: [{ source_type: 'storno' }] }, + // Opening balance carrying undeclared input VAT and a prior VAT debt: + // stays IN the projection. + { journal_entry_id: 'ib', account_number: '2641', debit_amount: 500, credit_amount: 0, journal_entries: [{ source_type: 'opening_balance' }] }, + { journal_entry_id: 'ib', account_number: '2650', debit_amount: 0, credit_amount: 300, journal_entries: [{ source_type: 'opening_balance' }] }, + ] + + const result = await computeVatReport( + { period_type: 'monthly', year: 2026, period: 1 }, + 'company-1', + mockSupabaseWithLines(lines) + ) + + expect(result.rutor.ruta10).toBe(0) + expect(result.rutor.ruta48).toBe(500) + expect(result.rutor.ruta49).toBe(-500) + }) + it('refund summary string when ruta49 is negative', async () => { const lines: MockLine[] = [ { account_number: '2641', debit_amount: 100, credit_amount: 0 }, diff --git a/extensions/general/mcp-server/server.ts b/extensions/general/mcp-server/server.ts index 3cc96eec..708bfab2 100644 --- a/extensions/general/mcp-server/server.ts +++ b/extensions/general/mcp-server/server.ts @@ -26,6 +26,7 @@ import { calculateVatLiability, } from '@/lib/reports/kpi' import { generateTrialBalance } from '@/lib/reports/trial-balance' +import { ACCOUNT_RUTA, VAT_SETTLEMENT_NET_ACCOUNTS } from '@/lib/reports/vat-declaration' import { fetchAllRows } from '@/lib/supabase/fetch-all' import { generateARLedger } from '@/lib/reports/ar-ledger' import { generateMonthlyBreakdown } from '@/lib/reports/monthly-breakdown' @@ -1071,14 +1072,18 @@ export async function computeVatReport( // Paginate. An unbounded .select() caps at PostgREST's 1000-row default, // which silently truncates a yearly (or busy quarterly) VAT period with // >1000 entry lines and under-reports the momsdeklaration. + // journal_entries is a to-one embed: PostgREST returns an object at + // runtime, but the untyped client infers an array, so accept both shapes. const lines = await fetchAllRows<{ + journal_entry_id: string account_number: string debit_amount: number credit_amount: number + journal_entries?: { source_type: string | null } | Array<{ source_type: string | null }> }>(({ from, to }) => supabase .from('journal_entry_lines') - .select('account_number, debit_amount, credit_amount, journal_entries!inner(entry_date, status, user_id)') + .select('journal_entry_id, account_number, debit_amount, credit_amount, journal_entries!inner(entry_date, status, user_id, source_type)') .eq('journal_entries.company_id', companyId) .in('journal_entries.status', ['posted', 'reversed']) // Momsredovisning entries (the settlement verifikat clearing 26xx to @@ -1087,11 +1092,40 @@ export async function computeVatReport( .neq('journal_entries.source_type', 'vat_settlement') .gte('journal_entries.entry_date', startDate) .lte('journal_entries.entry_date', endDate) + // Stable total order for correct paging (see fetch-all.ts): without it, + // rows can shift across page boundaries on reports over 1000 lines. + .order('id', { ascending: true }) .range(from, to) ) + // Settlements booked WITHOUT the vat_settlement tag (manual momsomföring, + // SIE-imported settlements, stornos of a settlement) are excluded by shape, + // mirroring fetchVatAccountTotals (#984): an entry touching both a + // declaration account (ACCOUNT_RUTA) and a settlement net account + // (2650/1650) is a momsredovisning, not VAT-bearing activity. Opening + // balances are exempt: carried-in 26xx balances are unsettled VAT that + // belongs in the next declaration. + const declarationEntryIds = new Set() + const netEntryIds = new Set() + for (const line of lines) { + if (ACCOUNT_RUTA[line.account_number]) declarationEntryIds.add(line.journal_entry_id) + else if (VAT_SETTLEMENT_NET_ACCOUNTS.includes(line.account_number)) { + netEntryIds.add(line.journal_entry_id) + } + } + const settlementShapedIds = new Set() + for (const line of lines) { + const id = line.journal_entry_id + if (!declarationEntryIds.has(id) || !netEntryIds.has(id)) continue + const embedded = line.journal_entries + const entry = Array.isArray(embedded) ? embedded[0] : embedded + if (!entry || entry.source_type === 'opening_balance') continue + settlementShapedIds.add(id) + } + const accountTotals = new Map() for (const line of lines) { + if (settlementShapedIds.has(line.journal_entry_id)) continue const acc = line.account_number const existing = accountTotals.get(acc) ?? { debit: 0, credit: 0 } existing.debit += Number(line.debit_amount) || 0 diff --git a/lib/reports/__tests__/vat-declaration.test.ts b/lib/reports/__tests__/vat-declaration.test.ts index 61c61bd3..eaed53f8 100644 --- a/lib/reports/__tests__/vat-declaration.test.ts +++ b/lib/reports/__tests__/vat-declaration.test.ts @@ -1192,3 +1192,99 @@ describe('calculateVatDeclaration: annual VAT spans the räkenskapsår', () => { expect(result.period.end).toBe('2026-03-31') }) }) + +// ============================================================ +// #984: settlement entries never zero the report +// ============================================================ + +describe('calculateVatDeclaration: settlement-shaped exclusion (#984)', () => { + function line(entryId: string, account: string, debit: number, credit: number) { + return { + journal_entry_id: entryId, + account_number: account, + debit_amount: debit, + credit_amount: credit, + } + } + + it('excludes a manual momsomföring so the report survives nollställning', async () => { + results = [ + { + data: [{ id: 'e1' }, { id: 'e2', source_type: 'manual' }], + error: null, + }, + { + data: [ + // Business activity. + line('e1', '2611', 0, 2500), + line('e1', '2641', 1000, 0), + line('e1', '3001', 0, 10000), + // Manual settlement clearing the period to 2650 (booked without + // the vat_settlement source_type, e.g. before #980 shipped). + line('e2', '2611', 2500, 0), + line('e2', '2641', 0, 1000), + line('e2', '2650', 0, 1500), + ], + error: null, + }, + { data: [], error: null }, // entry counts + ] + + const result = await calculateVatDeclaration(supabase, 'company-1', 'monthly', 2026, 5) + + // Without the shape exclusion every ruta reads 0 after the settlement. + expect(result.rutor.ruta10).toBe(2500) + expect(result.rutor.ruta48).toBe(1000) + expect(result.rutor.ruta49).toBe(1500) + expect(result.rutor.ruta05).toBe(10000) + }) + + it('excludes a storno of a settlement (annullera must not inflate the rutor)', async () => { + results = [ + { + // The tagged settlement itself is filtered out by the query; its + // storno reversal is not, and would otherwise re-credit 2611. + data: [{ id: 'e1' }, { id: 'e3', source_type: 'storno' }], + error: null, + }, + { + data: [ + line('e1', '2611', 0, 100), + line('e3', '2611', 0, 100), + line('e3', '2650', 100, 0), + ], + error: null, + }, + { data: [], error: null }, // entry counts + ] + + const result = await calculateVatDeclaration(supabase, 'company-1', 'monthly', 2026, 5) + + expect(result.rutor.ruta10).toBe(100) + expect(result.rutor.ruta49).toBe(100) + }) + + it('keeps opening-balance entries: carried-in 26xx balances are unsettled VAT', async () => { + results = [ + { + data: [{ id: 'ib', source_type: 'opening_balance' }], + error: null, + }, + { + data: [ + // Migrating company: undeclared input VAT and a prior VAT debt + // carried in through the same opening-balance entry. + line('ib', '2641', 500, 0), + line('ib', '2650', 0, 300), + ], + error: null, + }, + { data: [], error: null }, // entry counts + ] + + const result = await calculateVatDeclaration(supabase, 'company-1', 'monthly', 2026, 1) + + expect(result.rutor.ruta48).toBe(500) + expect(result.rutor.ruta49).toBe(-500) + }) +}) diff --git a/lib/reports/__tests__/vat-settlement.test.ts b/lib/reports/__tests__/vat-settlement.test.ts index 1c82bc8e..6a1e74a3 100644 --- a/lib/reports/__tests__/vat-settlement.test.ts +++ b/lib/reports/__tests__/vat-settlement.test.ts @@ -2,14 +2,17 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { buildVatSettlementProposal } from '../vat-settlement' // ============================================================ -// Mock: results routed by table + select shape (the builder runs its two +// Mock: results routed by table + applied filters (the builder runs its two // ledger queries and the existing-entries lookup concurrently, so a // sequential result queue would be order-fragile). // ============================================================ interface MockData { - /** journal_entries rows for the entry-scope query (fetchEntryLines step 1). */ - entries?: Array<{ id: string }> + /** + * journal_entries rows for the entry-scope query (fetchEntryLines step 1). + * Shape-detection reads status/entry_date/source_type/voucher_* off these. + */ + entries?: Array> /** journal_entry_lines rows (fetchEntryLines step 2). */ lines?: Array> /** Existing vat_settlement entries in the period. */ @@ -26,16 +29,16 @@ function makeClient(data: MockData) { neqCalls = [] return { from: vi.fn().mockImplementation((table: string) => { - let selectStr = '' + const eqCalls: Array<[string, unknown]> = [] // eslint-disable-next-line @typescript-eslint/no-explicit-any const b: Record = {} - b.select = vi.fn().mockImplementation((s: string) => { - selectStr = s - return b - }) - for (const m of ['eq', 'in', 'gte', 'lte', 'order', 'range', 'limit']) { + for (const m of ['select', 'in', 'gte', 'lte', 'order', 'range', 'limit']) { b[m] = vi.fn().mockReturnValue(b) } + b.eq = vi.fn().mockImplementation((col: string, val: unknown) => { + eqCalls.push([col, val]) + return b + }) b.neq = vi.fn().mockImplementation((col: string, val: unknown) => { neqCalls.push([col, val]) return b @@ -44,9 +47,9 @@ function makeClient(data: MockData) { b.then = (resolve: (v: unknown) => void) => { if (table === 'journal_entry_lines') return resolve({ data: data.lines ?? [], error: null }) // journal_entries serves two queries: the entry scope for the ledger - // totals (select 'id') and the existing-settlement lookup (selects - // voucher columns). - if (selectStr.includes('voucher_series')) { + // totals (filters vat_settlement OUT via .neq) and the tagged + // existing-settlement lookup (filters it IN via .eq). + if (eqCalls.some(([col, val]) => col === 'source_type' && val === 'vat_settlement')) { return resolve( data.existingError ? { data: null, error: data.existingError } @@ -62,11 +65,11 @@ function makeClient(data: MockData) { } let lineId = 0 -function vatLine(account: string, debit: number, credit: number) { +function vatLine(account: string, debit: number, credit: number, entryId = 'e1') { lineId += 1 return { id: `l${lineId}`, - journal_entry_id: 'e1', + journal_entry_id: entryId, account_number: account, debit_amount: debit, credit_amount: credit, @@ -212,6 +215,114 @@ describe('buildVatSettlementProposal', () => { expect(proposal.existing_entries).toEqual(existing) }) + it('gates on a manual settlement-shaped entry and still proposes the full-period clear (#984)', async () => { + const manualSettlement = { + id: 'e2', status: 'posted', entry_date: '2026-03-31', + source_type: 'manual', voucher_series: 'A', voucher_number: 9, + } + const supabase = makeClient({ + entries: [{ id: 'e1' }, manualSettlement], + lines: [ + // Business activity on e1. + vatLine('2611', 0, 100), + vatLine('2641', 25, 0), + // Manual momsomföring on e2: clears 26xx to 2650 without the + // vat_settlement source_type (booked before #980 shipped). + vatLine('2611', 100, 0, 'e2'), + vatLine('2641', 0, 25, 'e2'), + vatLine('2650', 0, 75, 'e2'), + ], + }) + + const proposal = await buildVatSettlementProposal(supabase, 'company-1', 'quarterly', 2026, 1) + + // The manual settlement is excluded from the projection: the proposal + // shows the same full-period clear the report shows, and the posted + // shaped entry gates the booking button via existing_entries. + expect(proposal.is_empty).toBe(false) + expect(proposal.filed_net).toBe(75) + expect(proposal.lines).toEqual([ + { account_number: '2611', debit_amount: 100, credit_amount: 0 }, + { account_number: '2641', debit_amount: 0, credit_amount: 25 }, + { + account_number: '2650', debit_amount: 0, credit_amount: 75, + line_description: 'Moms att betala', + }, + ]) + expect(proposal.existing_entries).toEqual([manualSettlement]) + }) + + it('does not gate on a storno of a settlement (annullera must re-enable booking)', async () => { + const supabase = makeClient({ + entries: [ + { id: 'e1' }, + // A manual settlement that has been annulled... + { + id: 'e2', status: 'reversed', entry_date: '2026-03-31', + source_type: 'manual', voucher_series: 'A', voucher_number: 9, + }, + // ...and its storno reversal. + { + id: 'e3', status: 'posted', entry_date: '2026-03-31', + source_type: 'storno', voucher_series: 'A', voucher_number: 10, + }, + ], + lines: [ + vatLine('2611', 0, 100), + vatLine('2611', 100, 0, 'e2'), + vatLine('2650', 0, 100, 'e2'), + vatLine('2611', 0, 100, 'e3'), + vatLine('2650', 100, 0, 'e3'), + ], + }) + + const proposal = await buildVatSettlementProposal(supabase, 'company-1', 'quarterly', 2026, 1) + + // Settlement + storno are both excluded from the projection (they would + // otherwise double ruta 10), and neither gates: the period can be + // settled again. + expect(proposal.existing_entries).toEqual([]) + expect(proposal.filed_net).toBe(100) + expect(proposal.lines).toEqual([ + { account_number: '2611', debit_amount: 100, credit_amount: 0 }, + { + account_number: '2650', debit_amount: 0, credit_amount: 100, + line_description: 'Moms att betala', + }, + ]) + }) + + it('ignores a plain VAT payment on 2650 (no declaration accounts touched)', async () => { + const supabase = makeClient({ + entries: [ + { id: 'e1' }, + { + id: 'e2', status: 'posted', entry_date: '2026-02-12', + source_type: 'bank_transaction', voucher_series: 'A', voucher_number: 7, + }, + ], + lines: [ + vatLine('2611', 0, 100), + // Paying last period's VAT debt: 2650 against the bank account. + // Touches a settlement net account but no declaration account, so it + // is NOT settlement-shaped: it must neither gate nor shift the rutor. + vatLine('2650', 75, 0, 'e2'), + ], + }) + + const proposal = await buildVatSettlementProposal(supabase, 'company-1', 'quarterly', 2026, 1) + + expect(proposal.existing_entries).toEqual([]) + expect(proposal.filed_net).toBe(100) + expect(proposal.lines).toEqual([ + { account_number: '2611', debit_amount: 100, credit_amount: 0 }, + { + account_number: '2650', debit_amount: 0, credit_amount: 100, + line_description: 'Moms att betala', + }, + ]) + }) + it('throws when the existing-settlement lookup fails (the UI gate depends on it)', async () => { const supabase = makeClient({ entries: [{ id: 'e1' }], diff --git a/lib/reports/vat-declaration.ts b/lib/reports/vat-declaration.ts index b2a8b3bf..e1d7daeb 100644 --- a/lib/reports/vat-declaration.ts +++ b/lib/reports/vat-declaration.ts @@ -238,31 +238,73 @@ export async function resolvePeriodDates( return calculatePeriodDates(periodType, year, period) } +/** + * Accounts a momsredovisning settles the period's net against: 2650 + * (Redovisningskonto för moms, att betala) and 1650 (Momsfordran, att återfå). + * Mirrors VAT_SETTLEMENT_ACCOUNT/VAT_REFUND_ACCOUNT in vat-settlement.ts, + * which imports from this module and therefore cannot be imported here. + */ +export const VAT_SETTLEMENT_NET_ACCOUNTS = ['2650', '1650'] + +/** A momsredovisning entry detected by shape rather than source_type. */ +export interface VatSettlementShapedEntry { + id: string + status: string + entry_date: string + source_type: string | null + voucher_series: string | null + voucher_number: number | null +} + +export interface VatAccountTotals { + totals: Map + /** + * Untagged momsredovisning entries found in the period (manual vouchers, + * SIE-imported settlements, stornos of a settlement). Already excluded + * from `totals`; surfaced so the settlement proposal can warn and gate. + */ + settlementShapedEntries: VatSettlementShapedEntry[] +} + /** * Fetch and aggregate debit/credit totals per VAT-relevant account * (ACCOUNT_RUTA) for a period. Shared by the declaration calculation and the * settlement proposal (lib/reports/vat-settlement.ts) so the two can never * disagree on which ledger lines count. * - * Momsredovisning entries (source_type 'vat_settlement': the verifikat that - * clears the 26xx accounts to 2650/1650) are excluded. They are bookkeeping - * about the declaration, not VAT-bearing business activity; including them - * would zero out the rutor the moment the settlement is booked, turning the - * report, its exports, and a later Skatteverket submission into an empty - * declaration. + * Momsredovisning entries are excluded. They are bookkeeping about the + * declaration, not VAT-bearing business activity; including them would zero + * out the rutor the moment the settlement is booked, turning the report, its + * exports, and a later Skatteverket submission into an empty declaration + * (#984). Two detection paths: + * + * - tagged: source_type 'vat_settlement' (the app's own settlement flow), + * filtered in the query; + * - shaped: an entry with at least one line on a declaration account + * (ACCOUNT_RUTA) and at least one on 2650/1650. This catches settlements + * booked before the tagged flow existed, manual vouchers, SIE-imported + * settlements, and storno reversals of a settlement (source_type + * 'storno', which would otherwise re-inflate the rutor after annullera). + * + * Opening-balance entries are exempt from the shape rule: 26xx balances + * carried in by a migrating company are unsettled VAT that belongs in the + * next declaration, even when the same entry carries a 2650/1650 balance. */ export async function fetchVatAccountTotals( supabase: SupabaseClient, companyId: string, start: string, end: string -): Promise> { +): Promise { const lines = await fetchEntryLines<{ + journal_entry_id: string account_number: string debit_amount: number credit_amount: number + journal_entries?: VatSettlementShapedEntry }>({ supabase, + entryColumns: 'id, status, entry_date, source_type, voucher_series, voucher_number', lineColumns: 'account_number, debit_amount, credit_amount', filterEntries: (q: EntryLinesQuery) => q @@ -271,17 +313,39 @@ export async function fetchVatAccountTotals( .neq('source_type', 'vat_settlement') .gte('entry_date', start) .lte('entry_date', end), - filterLines: (q: EntryLinesQuery) => q.in('account_number', VAT_ACCOUNTS), + filterLines: (q: EntryLinesQuery) => + q.in('account_number', [...VAT_ACCOUNTS, ...VAT_SETTLEMENT_NET_ACCOUNTS]), }) + // Shape detection: an entry is a settlement when it touches both a + // declaration account and a settlement net account (2650/1650). + const declarationEntryIds = new Set() + const netEntryIds = new Set() + for (const line of lines) { + if (ACCOUNT_RUTA[line.account_number]) declarationEntryIds.add(line.journal_entry_id) + else if (VAT_SETTLEMENT_NET_ACCOUNTS.includes(line.account_number)) { + netEntryIds.add(line.journal_entry_id) + } + } + + const shapedById = new Map() + for (const line of lines) { + const id = line.journal_entry_id + if (!declarationEntryIds.has(id) || !netEntryIds.has(id)) continue + const entry = line.journal_entries + if (!entry || entry.source_type === 'opening_balance') continue + shapedById.set(id, entry) + } + const totals = new Map() for (const line of lines) { + if (shapedById.has(line.journal_entry_id)) continue const t = totals.get(line.account_number) || { debit: 0, credit: 0 } t.debit += Number(line.debit_amount) || 0 t.credit += Number(line.credit_amount) || 0 totals.set(line.account_number, t) } - return totals + return { totals, settlementShapedEntries: [...shapedById.values()] } } /** @@ -350,7 +414,7 @@ export async function calculateVatDeclaration( ) // Fetch and aggregate posted VAT-account activity for the period - const totals = await fetchVatAccountTotals(supabase, companyId, start, end) + const { totals } = await fetchVatAccountTotals(supabase, companyId, start, end) // Map account balances to momsdeklaration boxes const rutor = rutorFromTotals(totals) diff --git a/lib/reports/vat-settlement.ts b/lib/reports/vat-settlement.ts index b54aec25..03d15849 100644 --- a/lib/reports/vat-settlement.ts +++ b/lib/reports/vat-settlement.ts @@ -50,11 +50,16 @@ export interface VatSettlementProposalLine { line_description?: string } -/** A vat_settlement entry already booked (or drafted) inside the period. */ +/** + * A settlement entry already booked (or drafted) inside the period: tagged + * (source_type 'vat_settlement') or detected by shape (a manual momsomföring + * clearing 26xx to 2650/1650, see fetchVatAccountTotals in vat-declaration.ts). + */ export interface VatSettlementExistingEntry { id: string status: string entry_date: string + source_type: string | null voucher_series: string | null voucher_number: number | null } @@ -103,11 +108,11 @@ export async function buildVatSettlementProposal( supabase, companyId, periodType, year, period, options.fiscalPeriodId ) - const [totals, existingResult] = await Promise.all([ + const [{ totals, settlementShapedEntries }, existingResult] = await Promise.all([ fetchVatAccountTotals(supabase, companyId, start, end), supabase .from('journal_entries') - .select('id, status, entry_date, voucher_series, voucher_number') + .select('id, status, entry_date, source_type, voucher_series, voucher_number') .eq('company_id', companyId) .eq('source_type', 'vat_settlement') .in('status', ['draft', 'posted']) @@ -126,6 +131,22 @@ export async function buildVatSettlementProposal( ) } + // Shape-detected settlements (manual momsomföring, SIE imports) gate the + // booking button exactly like tagged ones (#984): the proposal re-clears + // the FULL period, so booking on top of a manual settlement would corrupt + // the 26xx balances. Stornos are the CANCELLATION of a settlement and must + // not gate, or annullera could never re-enable the button. Only posted + // entries gate: the shape of an unposted draft has no balance effect. + const shapedExisting = settlementShapedEntries.filter( + (e) => e.status === 'posted' && e.source_type !== 'storno' + ) + const existingEntries = [ + ...((existingResult.data ?? []) as VatSettlementExistingEntry[]), + ...shapedExisting, + ] + .sort((a, b) => (a.entry_date < b.entry_date ? 1 : a.entry_date > b.entry_date ? -1 : 0)) + .slice(0, 5) + const rutor = rutorFromTotals(totals) const { net: filedNet } = buildFiledAmounts(rutor) @@ -192,6 +213,6 @@ export async function buildVatSettlementProposal( filed_net: filedNet, rounding_amount: roundingAmount, is_empty: lines.length === 0, - existing_entries: (existingResult.data ?? []) as VatSettlementExistingEntry[], + existing_entries: existingEntries, } }