diff --git a/DECISIONS.md b/DECISIONS.md index 52b9019c..2e5121b6 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1335,4 +1335,5 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-28] Employee-save failure reported inline (role=alert in the dialog footer, carrying the requestId) in addition to the single destructive toast, and a missing PERSONNUMMER_ENCRYPTION_KEY typed as 503 PERSONNUMMER_ENCRYPTION_NOT_CONFIGURED rather than INTERNAL_ERROR (#1996): the Radix modal aria-hides the root-layout Toaster while the dialog is open, so the toast is invisible to assistive tech and E2E drivers; TOAST_LIMIT is 1, so a second toast is not an option; and the missing key is a permanent configuration gap where "try again later" is wrong and "contact support" is right (same reasoning as CUSTOMER_PERSONAL_NUMBER_UNREADABLE and INVOICE_SEND_EMAIL_NOT_CONFIGURED). The shared postAction helper was not extended (it takes no body and exposes no requestId): keeping the change local to the dialog avoids widening a helper other panels rely on. [2026-08-28] /migrate SIE guard extended to every provider (Fortnox exemption removed) as "a completed SIE import must exist for the company", not "must be part of this run", plus a wizard hint that disables Start when SIE is unchecked and never imported; chose this over forcing the checkbox on because the route is the only seam a direct API call or a stale client cannot bypass, and "must exist" keeps entities-only re-runs after a full migration working (#2000). [2026-08-28] /migrate SIE guard skips company-info-only runs (all entity flags false) and the wizard derives "SIE already imported" from the preview OR this session's successful /import-sie results: company info writes no accounts, balances or subledger rows, so the BFL rationale does not apply; and the one-shot preview went stale after phase 1 succeeded and phase 2 failed, falsely blocking an entities-only retry (#2000 review). +[2026-08-28] get_vat_ruta_source_lines (the VAT ruta drill-down) now applies the same four exclusions as get_vat_declaration_totals (the filed figure): posted closing entries, source_type 'vat_settlement', the two kontantmetod year-end reversals, and settlement-SHAPED entries (a line on a ruta account plus a line on 2650/1650). It previously filtered on company, status and date only, so expanding a ruta listed verifikat that are not in the number it claims to explain, with no total on the panel to reveal the mismatch. Measured on prod 2026-08-28: 322 posted/reversed entries carrying 26xx lines across 214 companies sit in those excluded classes. A momsdeklaration is räkenskapsinformation (BFL 5 kap.) and this drill-down is what substantiates a filed figure, so the two must agree exactly. The exclusion CTEs are lifted VERBATIM from the figure rather than re-derived: any divergence reintroduces exactly this bug, and an identical copy is easy to diff when the figure changes. Settlement-shape is detected against journal_entry_lines directly instead of through the figure's vat_lines CTE, which is EQUIVALENT not a shortcut (p_ruta_accounts = VAT_ACCOUNTS and p_net_accounts = ['2650','1650'] are both strict subsets of the figure's p_accounts, so restricting to vat_lines first cannot change which entries match); that keeps p_accounts meaning "the accounts of the ruta being expanded" without a fourth account parameter. opening_balance entries are deliberately NOT excluded: the figure exempts them from `shaped`, which keeps their lines IN the totals, so dropping them here would break the equality in the other direction (pinned by its own test). VAT_ACCOUNTS is now exported from lib/reports/vat-declaration.ts so the route detects shape from the same list the figure uses; a second copy is what let the two disagree. DROP + CREATE OR REPLACE, not CREATE OR REPLACE alone: the signature gains p_ruta_accounts/p_net_accounts and adding parameters registers a second overload PostgREST cannot choose between (trap documented in 20260421140000); OR REPLACE on the new arity keeps the file re-runnable. Verified the new pg test actually catches the bug by reinstalling the old body and watching 3 of 4 tests fail with the real misreporting (2611: drill-down 250/240 vs figure 0/200), then restoring. [2026-08-28] Bankavstamning NULL-link fix scoped to transfer legs with contradicting sign (20260828220000): the naive rule (NULL counts only for the primary account) and the formula-only variant (drop far-leg-settled vouchers from unexplained) were both simulated against prod and rejected; the naive rule worsened 4 of 11 affected cards (worst -37 000 kr false alarm on single-leg vouchers with no user action available), the formula variant blew up healthy cards by up to 474 550 kr. The shipped three-condition rule changes 24 vouchers on 7 cards in 6 companies, all verified per-card. diff --git a/app/api/reports/vat-declaration/ruta/[ruta]/sources/route.ts b/app/api/reports/vat-declaration/ruta/[ruta]/sources/route.ts index b8fa11f3..4d301d51 100644 --- a/app/api/reports/vat-declaration/ruta/[ruta]/sources/route.ts +++ b/app/api/reports/vat-declaration/ruta/[ruta]/sources/route.ts @@ -3,6 +3,8 @@ import { NextResponse } from 'next/server' import { ACCOUNT_RUTA, resolvePeriodDates, + VAT_ACCOUNTS, + VAT_SETTLEMENT_NET_ACCOUNTS, } from '@/lib/reports/vat-declaration' import { fetchDynamicVatAccounts } from '@/lib/reports/vat-revenue-accounts' import type { ReportSourceLine } from '@/lib/reports/source-lines' @@ -151,6 +153,13 @@ export const GET = withRouteContext<{ params: Promise<{ ruta: string }> }>( p_start: start, p_end: end, p_accounts: accountsForRuta, + // Settlement-shape detectors, passed exactly as fetchVatAccountTotals + // passes them to get_vat_declaration_totals. The drill-down must drop the + // same entries the figure drops (posted closing entries, vat_settlement, + // the kontantmetod year-end reversals, and anything shaped like a + // momsredovisning), or it lists verifikat that are not in the number. + p_ruta_accounts: VAT_ACCOUNTS, + p_net_accounts: VAT_SETTLEMENT_NET_ACCOUNTS, p_cursor_date: cursorDate, p_cursor_voucher_number: cursorVoucherNum, p_cursor_entry_id: cursorEntryId, diff --git a/lib/reports/vat-declaration.ts b/lib/reports/vat-declaration.ts index a7dd7957..04e02006 100644 --- a/lib/reports/vat-declaration.ts +++ b/lib/reports/vat-declaration.ts @@ -137,7 +137,14 @@ export const ACCOUNT_RUTA: Record $count migrations applied" +# NOTE: this blanket grant includes `anon`, which PostgREST needs to answer at +# all. It also means THIS DATABASE IS NOT VALID FOR THE pg-real SUITE: ~29 of +# those files assert least privilege ("does not grant EXECUTE to anon"), and +# they fail here on unmodified main. Point `npm run test:pg` at its own +# database, not at this one. echo "==> granting on everything the migrations created" psql_run -c " GRANT ALL ON ALL TABLES IN SCHEMA public TO postgres, anon, authenticated, service_role; diff --git a/supabase/migrations/20260828172003_vat_ruta_drilldown_matches_declaration.sql b/supabase/migrations/20260828172003_vat_ruta_drilldown_matches_declaration.sql new file mode 100644 index 00000000..1a4730d5 --- /dev/null +++ b/supabase/migrations/20260828172003_vat_ruta_drilldown_matches_declaration.sql @@ -0,0 +1,155 @@ +-- Make the VAT ruta drill-down return exactly the lines the filed figure sums. +-- +-- `get_vat_declaration_totals` (the FIGURE, migration 20260813124510) drops +-- four classes of entry before summing: posted closing entries, source_type +-- 'vat_settlement', the two kontantmetod year-end reversals, and any entry +-- shaped like a momsredovisning (a line on a ruta account AND a line on +-- 2650/1650). `get_vat_ruta_source_lines` (the DRILL-DOWN, migration +-- 20260721103000) filtered on company, status and date only, so expanding a +-- ruta listed verifikat that are not in the number it claims to explain, with +-- no total on the panel to reveal the mismatch. +-- +-- Measured on production 2026-08-28: 322 posted/reversed entries carrying 26xx +-- lines across 214 companies sit in those excluded classes. A momsdeklaration +-- is räkenskapsinformation (BFL 5 kap.), and this drill-down is what a +-- consultant uses to substantiate a filed figure, so the two must agree +-- exactly. +-- +-- The exclusion CTEs below are lifted VERBATIM from get_vat_declaration_totals +-- rather than re-derived. That is deliberate: any divergence between the two +-- reintroduces exactly this bug, and a copy that reads identically is easy to +-- diff when the figure changes. tests/pg/vat-ruta-drilldown-reconcile.pg.test.ts +-- asserts the equality directly, so a future edit to one and not the other +-- fails CI rather than silently misreporting. +-- +-- Settlement-shape is detected against journal_entry_lines directly instead of +-- through the figure's `vat_lines` CTE. That is equivalent, not a shortcut: +-- the figure passes p_ruta_accounts = VAT_ACCOUNTS and p_net_accounts = +-- ['2650','1650'], both strict subsets of its p_accounts, so restricting to +-- vat_lines first cannot change which entries match. It lets the drill-down +-- keep p_accounts meaning "the accounts of the ruta being expanded" without a +-- fourth account parameter. +-- +-- opening_balance entries are deliberately NOT excluded. They are excluded +-- from the figure's `shaped` set, which keeps their lines IN the totals, so +-- dropping them here would break the equality in the other direction. +-- +-- pg-test: tests/pg/vat-ruta-drilldown-reconcile.pg.test.ts + +-- The signature gains p_ruta_accounts / p_net_accounts. DROP first: adding +-- parameters with CREATE OR REPLACE registers a second overload, and PostgREST +-- then cannot choose between them (documented in 20260421140000). +DROP FUNCTION IF EXISTS public.get_vat_ruta_source_lines( + uuid, date, date, text[], date, integer, uuid, uuid, integer +); + +-- OR REPLACE on the NEW signature, so re-running this file is a no-op rather +-- than "function already exists with same argument types". The DROP above is +-- what removes the old arity; this cannot reintroduce an overload. +CREATE OR REPLACE FUNCTION public.get_vat_ruta_source_lines( + p_company_id uuid, + p_start date, + p_end date, + -- Accounts of the ruta being expanded: which lines to RETURN. + p_accounts text[], + -- Settlement-shape detectors, mirroring the figure's own parameters. + p_ruta_accounts text[], + p_net_accounts text[], + p_cursor_date date DEFAULT NULL, + p_cursor_voucher_number integer DEFAULT NULL, + p_cursor_entry_id uuid DEFAULT NULL, + p_cursor_line_id uuid DEFAULT NULL, + p_limit integer DEFAULT 501 +) +RETURNS TABLE ( + line_id uuid, + journal_entry_id uuid, + voucher_number integer, + voucher_series text, + entry_date date, + description text, + debit_amount numeric, + credit_amount numeric +) +LANGUAGE sql +STABLE +SECURITY INVOKER +SET search_path TO 'public' +AS $$ + WITH closing_entries AS ( + SELECT fp.closing_entry_id AS id + FROM public.fiscal_periods fp + WHERE fp.company_id = p_company_id + AND fp.closing_entry_id IS NOT NULL + ), + scoped_entries AS ( + SELECT e.id, e.status, e.entry_date, e.source_type, e.description, + e.voucher_series, e.voucher_number + FROM public.journal_entries e + WHERE e.company_id = p_company_id + AND e.status IN ('posted', 'reversed') + AND e.entry_date >= p_start + AND e.entry_date <= p_end + AND NOT ( + e.status = 'posted' + AND EXISTS (SELECT 1 FROM closing_entries c WHERE c.id = e.id) + ) + ), + non_settlement_entries AS ( + SELECT * FROM scoped_entries + WHERE source_type IS DISTINCT FROM 'vat_settlement' + AND NOT ( + source_type = 'year_end' + AND description IN ( + 'Vändning kundfordringar bokslut (kontantmetoden)', + 'Vändning leverantörsskulder bokslut (kontantmetoden)' + ) + ) + ), + shaped AS ( + SELECT e.id + FROM non_settlement_entries e + WHERE e.source_type IS DISTINCT FROM 'opening_balance' + AND EXISTS ( + SELECT 1 FROM public.journal_entry_lines l + WHERE l.journal_entry_id = e.id + AND l.account_number = ANY (p_ruta_accounts) + ) + AND EXISTS ( + SELECT 1 FROM public.journal_entry_lines l + WHERE l.journal_entry_id = e.id + AND l.account_number = ANY (p_net_accounts) + ) + ) + SELECT + l.id AS line_id, + je.id AS journal_entry_id, + je.voucher_number, + COALESCE(je.voucher_series, 'A') AS voucher_series, + je.entry_date, + COALESCE(je.description, '') AS description, + l.debit_amount, + l.credit_amount + FROM non_settlement_entries je + JOIN public.journal_entry_lines l ON l.journal_entry_id = je.id + WHERE l.account_number = ANY (p_accounts) + AND NOT EXISTS (SELECT 1 FROM shaped s WHERE s.id = je.id) + AND ( + p_cursor_date IS NULL + OR ( + je.entry_date, + je.voucher_number, + je.id, + l.id + ) > ( + p_cursor_date, + p_cursor_voucher_number, + COALESCE(p_cursor_entry_id, 'ffffffff-ffff-ffff-ffff-ffffffffffff'::uuid), + COALESCE(p_cursor_line_id, 'ffffffff-ffff-ffff-ffff-ffffffffffff'::uuid) + ) + ) + ORDER BY je.entry_date, je.voucher_number, je.id, l.id + LIMIT LEAST(GREATEST(COALESCE(p_limit, 501), 1), 501); +$$; + +NOTIFY pgrst, 'reload schema'; diff --git a/tests/pg/production-error-regressions.pg.test.ts b/tests/pg/production-error-regressions.pg.test.ts index bc68fb8b..56f5b90c 100644 --- a/tests/pg/production-error-regressions.pg.test.ts +++ b/tests/pg/production-error-regressions.pg.test.ts @@ -128,24 +128,31 @@ describe('production error regressions', () => { ], }) + // p_ruta_accounts / p_net_accounts are the settlement-shape detectors the + // drill-down gained in 20260828172003 so it drops the same entries as the + // filed figure. Neither fixture here is settlement-shaped, so paging is + // unaffected; the equality itself is covered by + // tests/pg/vat-ruta-drilldown-reconcile.pg.test.ts. const first = await getPool().query( `SELECT * FROM public.get_vat_ruta_source_lines( - $1, $2, $3, $4, NULL, NULL, NULL, NULL, 1 + $1, $2, $3, $4, $5, $6, NULL, NULL, NULL, NULL, 1 )`, - [ctx.companyId, '2026-03-01', '2026-03-31', ['2611']], + [ctx.companyId, '2026-03-01', '2026-03-31', ['2611'], ['2611'], ['2650', '1650']], ) expect(first.rows).toHaveLength(1) expect(first.rows[0].voucher_number).toBe(1) const second = await getPool().query( `SELECT * FROM public.get_vat_ruta_source_lines( - $1, $2, $3, $4, $5, $6, $7, $8, 1 + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 1 )`, [ ctx.companyId, '2026-03-01', '2026-03-31', ['2611'], + ['2611'], + ['2650', '1650'], first.rows[0].entry_date, first.rows[0].voucher_number, first.rows[0].journal_entry_id, diff --git a/tests/pg/vat-ruta-drilldown-reconcile.pg.test.ts b/tests/pg/vat-ruta-drilldown-reconcile.pg.test.ts new file mode 100644 index 00000000..62ec213e --- /dev/null +++ b/tests/pg/vat-ruta-drilldown-reconcile.pg.test.ts @@ -0,0 +1,227 @@ +/** + * The VAT ruta drill-down must return exactly the lines the filed figure sums. + * + * `get_vat_declaration_totals` drops four classes of entry before summing: + * posted closing entries, source_type 'vat_settlement', the two kontantmetod + * year-end reversals, and any entry shaped like a momsredovisning (a line on a + * ruta account AND a line on 2650/1650). `get_vat_ruta_source_lines` filtered + * on company, status and date only, so expanding a ruta listed verifikat that + * were not in the number it claims to explain. 322 posted/reversed entries + * across 214 companies sat in those classes on production (2026-08-28). + * + * A momsdeklaration is räkenskapsinformation (BFL 5 kap.) and this drill-down + * is what substantiates a filed figure, so the headline test here is a single + * equality: for every account, the drill-down's sum equals the figure's total. + * It holds for the whole account set at once, so a future edit to one function + * and not the other fails here rather than silently misreporting. + */ +import { describe, it, expect, beforeAll } from 'vitest' +import { randomUUID } from 'node:crypto' +import { getPool } from './setup' +import { + insertAuthUser, + insertCompany, + insertFiscalPeriod, + insertPostedJournalEntry, +} from './fixtures' + +// Mirrors the TS call site: a representative slice of ACCOUNT_RUTA. The full +// list is a parameter, never baked into the SQL. +const RUTA_ACCOUNTS = ['2611', '2641', '2645', '3001'] +const NET_ACCOUNTS = ['2650', '1650'] +const ALL_ACCOUNTS = [...RUTA_ACCOUNTS, ...NET_ACCOUNTS] +const BALANCING_ACCOUNT = '2999' + +interface DrillLine { + line_id: string + journal_entry_id: string + voucher_number: number + entry_date: string + description: string + debit_amount: string | number + credit_amount: string | number +} + +async function figureTotals(companyId: string) { + const { rows } = await getPool().query( + `SELECT public.get_vat_declaration_totals($1,$2,$3,$4,$5,$6) AS payload`, + [companyId, '2026-01-01', '2026-12-31', ALL_ACCOUNTS, RUTA_ACCOUNTS, NET_ACCOUNTS], + ) + const payload = rows[0].payload as { + totals: Array<{ account_number: string; debit: number; credit: number }> + } + return new Map(payload.totals.map((t) => [t.account_number, t])) +} + +async function drillDown(companyId: string, accounts: string[]): Promise { + const { rows } = await getPool().query( + `SELECT * FROM public.get_vat_ruta_source_lines( + $1,$2,$3,$4,$5,$6, NULL, NULL, NULL, NULL, 501)`, + [companyId, '2026-01-01', '2026-12-31', accounts, RUTA_ACCOUNTS, NET_ACCOUNTS], + ) + return rows as DrillLine[] +} + +function sumOf(lines: DrillLine[]) { + return lines.reduce( + (acc, l) => ({ + debit: acc.debit + Number(l.debit_amount ?? 0), + credit: acc.credit + Number(l.credit_amount ?? 0), + }), + { debit: 0, credit: 0 }, + ) +} + +async function insertEntry(params: { + userId: string + companyId: string + fiscalPeriodId: string + voucherNumber: number + sourceType?: string + description?: string + lines: Array<{ account: string; debit: number; credit: number }> +}): Promise { + return insertPostedJournalEntry({ + userId: params.userId, + companyId: params.companyId, + fiscalPeriodId: params.fiscalPeriodId, + voucherNumber: params.voucherNumber, + entryDate: '2026-03-15', + description: params.description ?? 'drilldown test', + sourceType: params.sourceType ?? 'manual', + lines: params.lines.map((l) => ({ + accountNumber: l.account, + debitAmount: l.debit, + creditAmount: l.credit, + })), + }) +} + +describe('VAT ruta drill-down reconciles with the declaration figure', () => { + let userId: string + let companyId: string + let fiscalPeriodId: string + let plainEntryId: string + + beforeAll(async () => { + userId = await insertAuthUser() + companyId = await insertCompany({ createdBy: userId }) + fiscalPeriodId = await insertFiscalPeriod({ userId, companyId }) + + // 1. A plain sale. Must appear in BOTH the figure and the drill-down. + plainEntryId = await insertEntry({ + userId, companyId, fiscalPeriodId, voucherNumber: 1, + description: 'Vanlig försäljning', + lines: [ + { account: '3001', debit: 0, credit: 800 }, + { account: '2611', debit: 0, credit: 200 }, + { account: BALANCING_ACCOUNT, debit: 1000, credit: 0 }, + ], + }) + + // 2. A tagged momsredovisning. Excluded from the figure. + await insertEntry({ + userId, companyId, fiscalPeriodId, voucherNumber: 2, + sourceType: 'vat_settlement', description: 'Momsredovisning', + lines: [ + { account: '2611', debit: 200, credit: 0 }, + { account: '2650', debit: 0, credit: 200 }, + ], + }) + + // 3. Settlement SHAPE without the tag: a ruta account plus 2650. + await insertEntry({ + userId, companyId, fiscalPeriodId, voucherNumber: 3, + sourceType: 'manual', description: 'Otaggad momsredovisning', + lines: [ + { account: '2641', debit: 0, credit: 125 }, + { account: '2650', debit: 125, credit: 0 }, + ], + }) + + // 4. The kontantmetod year-end reversal, excluded by description. + await insertEntry({ + userId, companyId, fiscalPeriodId, voucherNumber: 4, + sourceType: 'year_end', + description: 'Vändning kundfordringar bokslut (kontantmetoden)', + lines: [ + { account: '2611', debit: 50, credit: 0 }, + { account: BALANCING_ACCOUNT, debit: 0, credit: 50 }, + ], + }) + + // 5. An opening balance. Deliberately NOT excluded: the figure exempts + // opening_balance from `shaped`, which keeps its lines in the totals. + // Dropping it here would break the equality in the other direction. + await insertEntry({ + userId, companyId, fiscalPeriodId, voucherNumber: 5, + sourceType: 'opening_balance', description: 'Ingående balans', + lines: [ + { account: '2641', debit: 75, credit: 0 }, + { account: BALANCING_ACCOUNT, debit: 0, credit: 75 }, + ], + }) + + // 6. A posted closing entry, excluded once the period points at it. + const closingId = await insertEntry({ + userId, companyId, fiscalPeriodId, voucherNumber: 6, + sourceType: 'year_end', description: 'Bokslutsverifikat', + lines: [ + { account: '2611', debit: 0, credit: 40 }, + { account: BALANCING_ACCOUNT, debit: 40, credit: 0 }, + ], + }) + await getPool().query( + `UPDATE public.fiscal_periods SET closing_entry_id = $1 WHERE id = $2`, + [closingId, fiscalPeriodId], + ) + }, 60_000) + + it('sums identically to the figure, for every account', async () => { + // The headline assertion. Any entry the figure drops and the drill-down + // keeps (or vice versa) shows up here as a mismatched account. + const totals = await figureTotals(companyId) + const mismatches: string[] = [] + + for (const account of ALL_ACCOUNTS) { + const lines = await drillDown(companyId, [account]) + const drilled = sumOf(lines) + const figure = totals.get(account) ?? { debit: 0, credit: 0 } + if ( + Math.round(drilled.debit * 100) !== Math.round(Number(figure.debit) * 100) || + Math.round(drilled.credit * 100) !== Math.round(Number(figure.credit) * 100) + ) { + mismatches.push( + `${account}: drill-down ${drilled.debit}/${drilled.credit} vs figure ${figure.debit}/${figure.credit}`, + ) + } + } + + expect(mismatches).toEqual([]) + }, 60_000) + + it('still returns the ordinary sale it is meant to explain', async () => { + // Guards the other direction: a filter that excluded everything would + // satisfy the equality above trivially if the figure were empty too. + const lines = await drillDown(companyId, ['2611']) + expect(lines.length).toBeGreaterThan(0) + expect(lines.map((l) => l.journal_entry_id)).toContain(plainEntryId) + expect(sumOf(lines).credit).toBe(200) + }, 30_000) + + it('excludes tagged settlements, shaped settlements, kontantmetod reversals and closing entries', async () => { + const descriptions = (await drillDown(companyId, ALL_ACCOUNTS)).map((l) => l.description) + expect(descriptions).not.toContain('Momsredovisning') + expect(descriptions).not.toContain('Otaggad momsredovisning') + expect(descriptions).not.toContain('Vändning kundfordringar bokslut (kontantmetoden)') + expect(descriptions).not.toContain('Bokslutsverifikat') + }, 30_000) + + it('keeps opening-balance lines, which the figure also counts', async () => { + // The subtle one. `shaped` exempts opening_balance, so its 26xx lines stay + // in the totals; excluding them from the drill-down would be a new bug in + // the opposite direction. + const lines = await drillDown(companyId, ['2641']) + expect(lines.map((l) => l.description)).toContain('Ingående balans') + }, 30_000) +})