diff --git a/DECISIONS.md b/DECISIONS.md index e28dfdda..25d5d6f3 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -831,5 +831,6 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-07-28] Transaction method (structured payment rail): the trailing channel phrase ("Överföring via internet", "Kortköp/uttag") is stripped from transactions.description at INGEST and by a one-shot BACKFILL, not merely hidden at render: description is the mutable working title, original_description keeps the full bank string, and every dedup surface (external_id: date+öre only; content bridge: prefix-containment over original_description ?? description, and a trailing strip leaves a prefix) is provably unaffected. transaction_method is text + CHECK (repo convention, no PG enums) beside verbatim bank_transaction_code / proprietary_bank_transaction_code evidence columns per data_quality_master Appendix B Layer-A; the dead `enrichment` jsonb was NOT reused (the Gokind lesson: opaque blobs with no readers die). mapping-engine now also matches original_description so user rules written against the full bank text keep firing on stripped rows. [2026-07-29] Transaction-method backfill scope: classification and title-stripping are FEED-ROW concepts (import_source present, not manual/mcp), enforced identically at ingest and in the 20260808090100 backfill, plus an adjective guard so "Egen insättning"/"Eget uttag"/"Intern överföring" keep their full titles even on feed rows (the phrase IS the meaning after a possessive/scope adjective). Chosen over vocabulary tweaks because the failure mode for unknown bank phrasings must be "row unchanged", and user-authored titles must never be rewritten by a channel vocabulary. A read-only prod dry-run script exists for coverage measurement but prod reads were left to the founder (permission-gated). [2026-08-08] Compliance-bot finding on the transaction_method backfill (booked rows' titles rewritten without a rattelse trail) triaged as satisfied-by-design, not a blocker: BFL 5 kap 5 attaches to bokforingsposter, and the backfill touches no journal table; the verifikat description is snapshotted into journal_entries at commit and SIE #VER export reads journal_entries only (both verified in code, no report reads transactions.description lazily); the bank original is preserved byte-identical in original_description by the same UPDATE (enforced since 80ef1ee0, and prod has 0/25,566 feed rows lacking it). The stricter TRANSACTION_TITLE_LOCKED gate on booked rows blocks arbitrary user free-text renames, a different mutation class from a deterministic trailing-vocabulary strip that skips user-edited titles and keeps the original adjacent. Period-lock triggers sit on the journal tables and fiscal periods, not on transactions; the pg-upgrade CI run applied the backfill against seeded booked rows with all enforcement triggers active. +[2026-08-08] Per-voucher RC basis gaps (findRcBasisGaps) downgrade from filing-blocking ERROR to WARNING only under per-rate evidence: the 44xx/45xx basis accounts grouped by momssats must match ruta30/0.25, ruta31/0.12, ruta32/0.06 two-sided within 0.5 kr, all rate boxes non-negative, and no RC_OUTPUT_MISSING present. A first cross-rate-sum predicate was refuted by /skeptic (wrong-rate fiktiv moms reached parity and unblocked a 7 800 kr under-declaration; a negative rate box made the predicate vacuous), so the certificate is per-rate, which rutor alone cannot express (rutor 20-24 are partitioned by purchase type, not rate); evidence therefore flows from the account totals. Why downgrade at all: a moms-only rattelseverifikat carries fiktiv moms whose basbelopp lives in another (often reversed) verifikat, and no voucher arrangement satisfies both the per-voucher scan and the aggregate identity in that state, so the ERROR was an unfixable dead end (Orto Engineering 3DJake case 2026-08; support vouchers A169/A175/A177 joined the blocklist they were meant to clear). Data side repaired separately with voucher A177 restoring bank parity and the basis/moms identity exactly. [2026-08-08] Fenced-JSON fix uses brace-slice, not fence-regex: also rescues preamble/postamble prose around the object, and degrades to the existing empty-result path when no braces exist. [2026-08-08] extractJsonObject upgraded from brace-slice to depth-aware balanced scan after PR 1460 review: prose containing braces around the JSON no longer poisons the slice; first parseable candidate wins. diff --git a/components/reports/views/index.tsx b/components/reports/views/index.tsx index 93addaad..ef9bd415 100644 --- a/components/reports/views/index.tsx +++ b/components/reports/views/index.tsx @@ -1793,10 +1793,16 @@ export function VatDeclarationView({ pageTitle }: { pageTitle?: string } = {}) { // ordinary 2641 and the user pays in moms they were entitled to deduct. // rcInputTotalsFromDeclaration returns undefined (not an empty map) when a // response predates the field, which keeps the fallback honest. + // The gap-downgrade evidence (per-momssats 44xx/45xx balances) travels on + // the declaration payload. Absent on responses from an older deploy: then + // the gaps keep their blocking ERROR tier rather than guessing. const checks = data ? withRcBasisGapFindings( runVatDeclarationChecks(data.rutor, rcInputTotalsFromDeclaration(data)), rcBasisScan, + data.rcBasisByRate + ? { rutor: data.rutor, rcBasisByRate: data.rcBasisByRate } + : undefined, ) : [] const checksBlocked = isFilingBlocked(checks) diff --git a/extensions/general/mcp-server/server.ts b/extensions/general/mcp-server/server.ts index 66f3108e..93e14d3f 100644 --- a/extensions/general/mcp-server/server.ts +++ b/extensions/general/mcp-server/server.ts @@ -58,6 +58,7 @@ import { import { withRcBasisGapFindings, isFilingBlocked, + rcBasisTotalsByRate, type RcBasisGapScan, } from '@/lib/reports/vat-filing-gate' import { findRcBasisGaps } from '@/lib/reports/rc-basis-gaps' @@ -1650,7 +1651,13 @@ async function runVatCompletenessChecks( } catch { scan = { status: 'unavailable' } } - return withRcBasisGapFindings(runVatDeclarationChecks(rutor, accountTotals), scan) + // Downgrade evidence (per-momssats 44xx/45xx balances) only exists when the + // caller supplied the account totals; without them the per-voucher gaps + // keep their blocking ERROR tier rather than guessing. + const evidence = accountTotals + ? { rutor, rcBasisByRate: rcBasisTotalsByRate(accountTotals) } + : undefined + return withRcBasisGapFindings(runVatDeclarationChecks(rutor, accountTotals), scan, evidence) } /** Wire shape for a completeness finding on the MCP surface. */ diff --git a/lib/reports/__tests__/vat-filing-gate.test.ts b/lib/reports/__tests__/vat-filing-gate.test.ts index f41ca4c5..4df1b404 100644 --- a/lib/reports/__tests__/vat-filing-gate.test.ts +++ b/lib/reports/__tests__/vat-filing-gate.test.ts @@ -1,11 +1,41 @@ import { describe, it, expect } from 'vitest' import { isFilingBlocked, + rcBasisGapAdvisoryFinding, rcBasisGapFinding, rcBasisScanUnavailableFinding, + rcBasisTotalsByRate, withRcBasisGapFindings, + RC_BASIS_ACCOUNTS_BY_RATE, + type RcBasisTotalsByRate, + type RcGapDowngradeEvidence, } from '../vat-filing-gate' import type { VatDeclarationCheck } from '../vat-declaration-checks' +import type { VatDeclarationRutor } from '@/types' + +function makeRutor(overrides: Partial = {}): VatDeclarationRutor { + return { + ruta05: 0, ruta06: 0, ruta07: 0, ruta08: 0, + ruta10: 0, ruta11: 0, ruta12: 0, + ruta20: 0, ruta21: 0, ruta22: 0, ruta23: 0, ruta24: 0, + ruta30: 0, ruta31: 0, ruta32: 0, + ruta35: 0, ruta36: 0, ruta37: 0, ruta38: 0, + ruta39: 0, ruta40: 0, ruta41: 0, ruta42: 0, + ruta48: 0, ruta49: 0, + ruta50: 0, ruta60: 0, ruta61: 0, ruta62: 0, + ...overrides, + } +} + +function makeEvidence( + rutor: Partial, + basis: Partial, +): RcGapDowngradeEvidence { + return { + rutor: makeRutor(rutor), + rcBasisByRate: { r25: 0, r12: 0, r6: 0, ...basis }, + } +} const aggregateRcBasisMissing: VatDeclarationCheck = { code: 'RC_BASIS_MISSING', @@ -13,6 +43,12 @@ const aggregateRcBasisMissing: VatDeclarationCheck = { message: 'aggregate finding', } +const aggregateRcOutputMissing: VatDeclarationCheck = { + code: 'RC_OUTPUT_MISSING', + status: 'ERROR', + message: 'aggregate surplus finding', +} + const warningOnly: VatDeclarationCheck = { code: 'RC_INPUT_VAT_MISMATCH', status: 'WARNING', @@ -66,6 +102,197 @@ describe('withRcBasisGapFindings', () => { }) }) +describe('withRcBasisGapFindings, correction-voucher tiering', () => { + // The Orto Engineering case (2026-08): a refund correction chain left the + // period per-rate identity exactly consistent while three moms-only + // correction vouchers carried fiktiv moms whose basbelopp lived in other + // (partly reversed) verifikat. No arrangement of vouchers can satisfy both + // the per-voucher scan and the aggregate identity in that state, so an + // ERROR was an unfixable block. + it('downgrades gaps to WARNING when the per-rate basis matches the fiktiv moms', () => { + const evidence = makeEvidence( + { ruta21: 7750.36, ruta30: 1937.59 }, + { r25: 7750.36 }, + ) + const result = withRcBasisGapFindings([], { status: 'scanned', gapCount: 3 }, evidence) + expect(result).toHaveLength(1) + // Same code so the Korrigera worklist in VatChecksCard stays visible. + expect(result[0].code).toBe('RC_BASIS_MISSING') + expect(result[0].status).toBe('WARNING') + expect(result[0].message).toContain('rättelseverifikat') + expect(isFilingBlocked(result)).toBe(false) + }) + + it('keeps blocking when the period has a real shortfall the aggregate tolerance absorbed', () => { + // 400 000 kr implied basis, 1 500 kr missing: inside the aggregate 0.5% + // tolerance (so checks is empty), but the declaration under-reports. + // This is the original hole the module closes; it must survive the tiering. + const evidence = makeEvidence( + { ruta21: 398500, ruta30: 100000 }, + { r25: 398500 }, + ) + const result = withRcBasisGapFindings([], { status: 'scanned', gapCount: 2 }, evidence) + expect(result[0].status).toBe('ERROR') + expect(isFilingBlocked(result)).toBe(true) + }) + + it('refutes the wrong-rate escape: a 25% basis cannot vouch for 12% fiktiv moms', () => { + // /skeptic counterexample A: EU consulting booked with moms at 12% + // (K 2624 12 000) while its 100 000 kr basis sits on 4535 (a 25% account). + // The refuted cross-rate sum reached parity (100 000 covers 12 000/0.12); + // per rate, the 12% moms has zero same-sats basis and the 25% basis has + // zero same-sats moms, so both pairs fail and the gap keeps blocking. + const evidence = makeEvidence( + { ruta21: 100000, ruta31: 12000, ruta48: 4800 }, + { r25: 100000, r12: 0 }, + ) + const result = withRcBasisGapFindings([], { status: 'scanned', gapCount: 1 }, evidence) + expect(result[0].status).toBe('ERROR') + expect(isFilingBlocked(result)).toBe(true) + }) + + it('refutes the negative-box escape: a net-negative rate refuses the downgrade', () => { + // /skeptic counterexample B: ruta31 net negative (credit notes) made the + // summed implied basis negative, so empty basis boxes "covered" it. Any + // negative moms box now refuses the downgrade outright. + const evidence = makeEvidence( + { ruta30: 2500, ruta31: -1260 }, + { r25: 10000, r12: -10500 }, + ) + const result = withRcBasisGapFindings([], { status: 'scanned', gapCount: 2 }, evidence) + expect(result[0].status).toBe('ERROR') + expect(isFilingBlocked(result)).toBe(true) + }) + + it('refuses the downgrade when RC_OUTPUT_MISSING already blocks the aggregate', () => { + // /skeptic finding 3: an advisory claiming the underlag stämmer must never + // render beside an aggregate ERROR asserting the opposite. The per-rate + // identity nearly excludes this state mathematically; the guard makes it + // impossible regardless. + const evidence = makeEvidence( + { ruta21: 7750.36, ruta30: 1937.59 }, + { r25: 7750.36 }, + ) + const result = withRcBasisGapFindings( + [aggregateRcOutputMissing], + { status: 'scanned', gapCount: 1 }, + evidence, + ) + expect(result).toHaveLength(2) + expect(result[1].status).toBe('ERROR') + }) + + it('keeps blocking when a surplus sits at the same rate as the gap', () => { + // Same-rate surplus means some voucher is missing its fiktiv moms (the + // RC_OUTPUT_MISSING defect): two-sided comparison refuses the downgrade + // even before the aggregate ERROR is considered. + const evidence = makeEvidence( + { ruta21: 9000, ruta30: 1937.59 }, + { r25: 9000 }, + ) + const result = withRcBasisGapFindings([], { status: 'scanned', gapCount: 1 }, evidence) + expect(result[0].status).toBe('ERROR') + }) + + it('keeps blocking when the caller cannot supply evidence', () => { + const result = withRcBasisGapFindings([], { status: 'scanned', gapCount: 2 }) + expect(result[0].status).toBe('ERROR') + }) + + it('öre drift does not flip the tier', () => { + const evidence = makeEvidence( + { ruta21: 7750.36, ruta30: 1937.59 }, + // 0.30 kr below the implied 7750.36: inside the öre epsilon. + { r25: 7750.06 }, + ) + const result = withRcBasisGapFindings([], { status: 'scanned', gapCount: 1 }, evidence) + expect(result[0].status).toBe('WARNING') + }) + + it('drift just past the öre epsilon blocks: the 0.5 kr tolerance is pinned', () => { + // 0.51 kr below the implied basis: one öre outside eps. This case exists + // so a future widening of eps cannot slip through with every test green. + const evidence = makeEvidence( + { ruta21: 7750.36, ruta30: 1937.59 }, + { r25: 7749.85 }, + ) + const result = withRcBasisGapFindings([], { status: 'scanned', gapCount: 1 }, evidence) + expect(result[0].status).toBe('ERROR') + }) + + it('malformed evidence blocks: a missing or non-numeric rate figure must not pass as NaN', () => { + // The web view reads rcBasisByRate off unvalidated JSON. NaN compares + // false to everything, so without the finite guard a malformed payload + // would sail through every comparison and relax the gate. + const rutor = makeRutor({ ruta21: 7750.36, ruta30: 1937.59 }) + const missingField = { + rutor, + rcBasisByRate: { r25: 7750.36 } as unknown as RcBasisTotalsByRate, + } + const nonNumeric = { + rutor, + rcBasisByRate: { r25: '7750.36', r12: 0, r6: 0 } as unknown as RcBasisTotalsByRate, + } + const nanMoms = { + rutor: makeRutor({ ruta21: 7750.36, ruta30: Number.NaN }), + rcBasisByRate: { r25: 7750.36, r12: 0, r6: 0 }, + } + for (const evidence of [missingField, nonNumeric, nanMoms]) { + const result = withRcBasisGapFindings([], { status: 'scanned', gapCount: 1 }, evidence) + expect(result[0].status).toBe('ERROR') + expect(isFilingBlocked(result)).toBe(true) + } + }) +}) + +describe('rcBasisTotalsByRate', () => { + it('groups net debit balances per momssats and rounds to öre', () => { + const totals = new Map([ + ['4535', { debit: 1000.005, credit: 200 }], + ['4515', { debit: 500, credit: 0 }], + ['4536', { debit: 300, credit: 50 }], + ['4517', { debit: 0, credit: 75 }], + ['2614', { debit: 0, credit: 999 }], // not a basis account: ignored + ]) + expect(rcBasisTotalsByRate(totals)).toEqual({ + r25: 1300.01, + r12: 250, + r6: -75, + }) + }) + + it('returns zeros for an empty map', () => { + expect(rcBasisTotalsByRate(new Map())).toEqual({ r25: 0, r12: 0, r6: 0 }) + }) + + it('covers all fifteen RC basis accounts exactly once across the rate groups', () => { + const all = [ + ...RC_BASIS_ACCOUNTS_BY_RATE.r25, + ...RC_BASIS_ACCOUNTS_BY_RATE.r12, + ...RC_BASIS_ACCOUNTS_BY_RATE.r6, + ] + expect(new Set(all).size).toBe(15) + expect([...all].sort()).toEqual([ + '4415', '4416', '4417', + '4425', '4426', '4427', + '4515', '4516', '4517', + '4531', '4532', '4533', + '4535', '4536', '4537', + ]) + }) +}) + +describe('rcBasisGapAdvisoryFinding', () => { + it('uses singular and plural Swedish wording and keeps the worklist pointer', () => { + expect(rcBasisGapAdvisoryFinding(1).message).toContain('1 verifikation i perioden') + expect(rcBasisGapAdvisoryFinding(3).message).toContain('3 verifikationer i perioden') + expect(rcBasisGapAdvisoryFinding(1).message).toContain('listan nedan') + expect(rcBasisGapAdvisoryFinding(1).message).toContain('per momssats') + expect(rcBasisGapAdvisoryFinding(1).status).toBe('WARNING') + expect(rcBasisGapAdvisoryFinding(1).code).toBe('RC_BASIS_MISSING') + }) +}) + describe('withRcBasisGapFindings, failed scan', () => { it('adds a non-blocking finding so the banner cannot claim all-clear', () => { // An empty check list renders as "Inga fel hittades i underlaget för diff --git a/lib/reports/rc-basis-gaps.ts b/lib/reports/rc-basis-gaps.ts index 0340502a..9c8b1182 100644 --- a/lib/reports/rc-basis-gaps.ts +++ b/lib/reports/rc-basis-gaps.ts @@ -5,6 +5,7 @@ import { type EntryLinesQuery, } from '@/lib/bookkeeping/entry-lines' import { resolvePeriodDates } from './vat-declaration' +import { RC_BASIS_ACCOUNTS_BY_RATE } from './vat-filing-gate' import type { VatPeriodType } from '@/types' /** @@ -19,12 +20,14 @@ import type { VatPeriodType } from '@/types' const RC_OUTPUT_ACCOUNTS = ['2614', '2624', '2634'] as const type RcOutputAccount = typeof RC_OUTPUT_ACCOUNTS[number] -const RC_BASIS_ACCOUNTS = new Set([ - '4515', '4516', '4517', // EU goods 25/12/6% - '4531', '4532', '4533', // non-EU services 25/12/6% - '4535', '4536', '4537', // EU services 25/12/6% - '4415', '4416', '4417', // domestic goods RC - '4425', '4426', '4427', // domestic services RC +// EU goods (4515-4517), non-EU services (4531-4533), EU services (4535-4537), +// domestic goods RC (4415-4417), domestic services RC (4425-4427). Derived +// from the rate-grouped single source in vat-filing-gate.ts so this scan and +// the per-rate downgrade evidence can never disagree on the account set. +const RC_BASIS_ACCOUNTS = new Set([ + ...RC_BASIS_ACCOUNTS_BY_RATE.r25, + ...RC_BASIS_ACCOUNTS_BY_RATE.r12, + ...RC_BASIS_ACCOUNTS_BY_RATE.r6, ]) const RATE_BY_OUTPUT: Record = { diff --git a/lib/reports/vat-declaration.ts b/lib/reports/vat-declaration.ts index 49b2d74a..0b7ed72a 100644 --- a/lib/reports/vat-declaration.ts +++ b/lib/reports/vat-declaration.ts @@ -5,6 +5,7 @@ import type { VatPeriodType, } from '@/types' import type { VatCheckAccountTotals } from './vat-declaration-checks' +import { rcBasisTotalsByRate } from './vat-filing-gate' import { fetchDynamicRuta05Accounts } from './vat-revenue-accounts' /** @@ -593,6 +594,9 @@ export async function calculateVatDeclaration( // the sharp RC_INPUT_VAT_MISMATCH comparison instead of the ruta 48 // fallback: see VatDeclaration.rcInputAccountTotals. rcInputAccountTotals: rcInputTotals(totals), + // Per-momssats RC basis balances (44xx/45xx), the downgrade evidence for + // the per-voucher gap tiering: see VatDeclaration.rcBasisByRate. + rcBasisByRate: rcBasisTotalsByRate(totals), invoiceCount, transactionCount, breakdown: { diff --git a/lib/reports/vat-filing-gate.ts b/lib/reports/vat-filing-gate.ts index 3523d959..4ca7220e 100644 --- a/lib/reports/vat-filing-gate.ts +++ b/lib/reports/vat-filing-gate.ts @@ -1,4 +1,8 @@ -import type { VatDeclarationCheck } from './vat-declaration-checks' +import type { + VatDeclarationCheck, + VatCheckAccountTotals, +} from './vat-declaration-checks' +import type { VatDeclarationRutor } from '@/types' /** * The filing gate for the momsdeklaration: ONE derived value that the @@ -21,14 +25,36 @@ import type { VatDeclarationCheck } from './vat-declaration-checks' * above a worklist of the very verifikationer that make the declaration * wrong, with Skicka enabled. * - * The per-voucher scan is authoritative here: every gap it returns is a - * verifikat with fiktiv moms on 2614/2624/2634 and no matching basbelopp on - * 44xx/45xx, which understates rutorna 20-24. Vid omvänd skattskyldighet ska - * köparen redovisa BÅDE beskattningsunderlaget (ruta 20-24) och den fiktiva - * momsen (ruta 30-32); tyst kvittning är inte tillåten, och Skatteverkets - * gateway avvisar den obalansen med felkod FK004. En sådan deklaration är - * alltså ofullständig, inte bara misstänkt, so it blocks filing exactly like - * the aggregate ERROR it stands in for. + * The per-voucher scan is authoritative here WHEN the aggregate identity is + * also broken: every gap it returns is a verifikat with fiktiv moms on + * 2614/2624/2634 and no matching basbelopp on 44xx/45xx in that same + * verifikat. Vid omvänd skattskyldighet ska köparen redovisa BÅDE + * beskattningsunderlaget (ruta 20-24) och den fiktiva momsen (ruta 30-32); + * tyst kvittning är inte tillåten, och Skatteverkets gateway avvisar den + * obalansen med felkod FK004. So when the period totals are ALSO short, the + * scan blocks filing exactly like the aggregate ERROR it stands in for. + * + * When the period's basis/moms identity holds PER MOMSSATS (see + * rcBasisPerRateConsistent), the same gaps downgrade to a WARNING: a + * moms-only rattelseverifikat legitimately carries fiktiv moms whose + * basbelopp lives in another verifikat, and with the per-rate identity intact + * the flagged vouchers cannot be under-reporting either the basis boxes or + * the moms boxes. Blocking on them was an unfixable dead end: the correction + * voucher that repairs the period joins the blocklist it was meant to clear + * (see rcBasisGapAdvisoryFinding for the full argument). + * + * The evidence is deliberately PER RATE and taken from the 44xx/45xx account + * totals, not from the rutor: rutor 20-24 are partitioned by purchase type + * (EU goods / EU services / non-EU / domestic RC), not by rate, so a + * cross-rate sum over them certifies nothing about rutor 30-32. A /skeptic + * pass refuted the first cross-rate version of this predicate with a + * wrong-rate fiktiv moms voucher (12% moms "covered" by a 25% basis, 7 800 kr + * under-declared) and with a net-negative rate box that made the summed + * comparison vacuous. Both are impossible per rate: the basis accounts are + * rate-specific (4515 vs 4516 vs 4517, and so on), so each ruta 30/31/32 is + * checked against exactly the basis booked at its own sats, two-sided, with + * öre epsilon only. A shortfall the aggregate 0.5% tolerance absorbs still + * blocks here, which is the exact hole this module was built to close. * * Blocking, not advisory, is safe here because the block is not a dead end: * the one-click Korrigera worklist sits on the same page directly under the @@ -53,6 +79,100 @@ export type RcBasisGapScan = | { status: 'unavailable' } | { status: 'scanned'; gapCount: number } +/** + * The reverse-charge basis accounts grouped by momssats. One column per rate + * across the five purchase-type families (EU goods, EU services, non-EU + * services, domestic goods RC, domestic services RC). This is the single + * source for the per-rate downgrade evidence, and rc-basis-gaps.ts derives + * its flat account set from it, so the scan and the evidence can never + * disagree about which accounts carry RC basis. + */ +export const RC_BASIS_ACCOUNTS_BY_RATE = { + r25: ['4515', '4535', '4531', '4415', '4425'], + r12: ['4516', '4536', '4532', '4416', '4426'], + r6: ['4517', '4537', '4533', '4417', '4427'], +} as const + +/** Net debit balance of the RC basis accounts, one figure per momssats. */ +export interface RcBasisTotalsByRate { + r25: number + r12: number + r6: number +} + +/** + * Project the per-rate RC basis balances out of a per-account totals map + * (the `VatAccountTotals.totals` shape `fetchVatAccountTotals()` returns). + * Debit minus credit, like every basis box: a credit-heavy rate (a period + * dominated by credit notes) legitimately comes out negative. + */ +export function rcBasisTotalsByRate(totals: VatCheckAccountTotals): RcBasisTotalsByRate { + const sumGroup = (accounts: readonly string[]): number => { + let sum = 0 + for (const account of accounts) { + const t = totals.get(account) + if (t) sum += t.debit - t.credit + } + return Math.round(sum * 100) / 100 + } + return { + r25: sumGroup(RC_BASIS_ACCOUNTS_BY_RATE.r25), + r12: sumGroup(RC_BASIS_ACCOUNTS_BY_RATE.r12), + r6: sumGroup(RC_BASIS_ACCOUNTS_BY_RATE.r6), + } +} + +/** + * Everything the downgrade decision needs. Both halves come from the same + * declaration calculation, so they describe the same ledger state: `rutor` + * carries the moms boxes 30-32, `rcBasisByRate` the per-sats basis balances + * the rutor cannot express. Callers that cannot supply this (an older wire + * payload, a totals-less context) simply omit it and keep the blocking + * behavior. + */ +export interface RcGapDowngradeEvidence { + rutor: VatDeclarationRutor + rcBasisByRate: RcBasisTotalsByRate +} + +/** + * The WARNING-tier variant of the per-voucher gap finding, used when the + * period's per-rate basis/moms identity holds (see rcBasisPerRateConsistent). + * + * Why it exists: the per-voucher scan assumes every posted verifikat with + * fiktiv moms (2614/2624/2634 credit) carries its own basbelopp. A legitimate + * moms-only rattelseverifikat breaks that assumption by construction: its + * basbelopp already lives in another verifikat (often a reversed one the scan + * never sees), and there is provably NO arrangement of vouchers that satisfies + * both the per-voucher scan and the aggregate basis/moms identity once, say, a + * refund's basis reduction sits in reversed history. Blocking on that made the + * red state unfixable: every correction the user (or support) booked joined + * the blocklist it was meant to clear. (First hit in production 2026-08: + * a 323 kr refund correction chain left a company permanently blocked.) + * + * So when the basis booked at each momssats matches the fiktiv moms declared + * at that sats, the per-voucher list stays visible as a worklist (same + * RC_BASIS_MISSING code) but stops gating "Skicka": at that point the flagged + * vouchers are corrections whose basis is elsewhere in the period at the same + * sats, and the declaration is not under-reporting. With any per-rate + * mismatch the ERROR tier still applies. + */ +export function rcBasisGapAdvisoryFinding(gapCount: number): VatDeclarationCheck { + const subject = gapCount === 1 ? '1 verifikation' : `${gapCount} verifikationer` + return { + code: 'RC_BASIS_MISSING', + status: 'WARNING', + message: + `${subject} i perioden har fiktiv moms (2614/2624/2634) utan eget basbelopp ` + + 'på 44xx/45xx. Periodens underlag stämmer dock per momssats: basbeloppen ' + + 'på 44xx/45xx motsvarar den fiktiva momsen i ruta 30-32, så detta är ' + + 'normalt rättelseverifikat vars basbelopp redan finns i ett annat ' + + 'verifikat. Kontrollera listan nedan; är raderna rättelser behöver du ' + + 'inte göra något och kan lämna in som vanligt.', + rutor: ['ruta20', 'ruta21', 'ruta22', 'ruta23', 'ruta24', 'ruta30', 'ruta31', 'ruta32'], + } +} + /** * The synthetic finding that makes per-voucher gaps visible to the gate. * @@ -106,6 +226,7 @@ export function rcBasisScanUnavailableFinding(): VatDeclarationCheck { export function withRcBasisGapFindings( checks: VatDeclarationCheck[], scan: RcBasisGapScan, + evidence?: RcGapDowngradeEvidence, ): VatDeclarationCheck[] { // The aggregate check already says this, and already blocks; don't say it // twice, and don't stack a "could not check" note on top of a live finding. @@ -113,7 +234,63 @@ export function withRcBasisGapFindings( if (scan.status === 'pending') return checks if (scan.status === 'unavailable') return [...checks, rcBasisScanUnavailableFinding()] if (scan.gapCount <= 0) return checks - return [...checks, rcBasisGapFinding(scan.gapCount)] + // Tier the finding by the per-rate identity (see rcBasisPerRateConsistent). + // RC_OUTPUT_MISSING in the list refuses the downgrade outright: that ERROR + // asserts the period totals are broken, and an advisory beside it claiming + // they hold would contradict it in the same card. Mathematically the + // per-rate identity nearly excludes it anyway (three rates at öre epsilon + // leave at most 1.5 kr of surplus, inside the aggregate tolerance), so this + // guard is belt and braces, not a second predicate. Callers that cannot + // supply evidence keep the blocking behavior unchanged. + const aggregateSurplusError = checks.some((c) => c.code === 'RC_OUTPUT_MISSING') + return [ + ...checks, + evidence && !aggregateSurplusError && rcBasisPerRateConsistent(evidence) + ? rcBasisGapAdvisoryFinding(scan.gapCount) + : rcBasisGapFinding(scan.gapCount), + ] +} + +/** + * True when, FOR EACH momssats separately, the RC basis booked at that rate + * matches the fiktiv moms declared at that rate (basbelopp = moms / sats), + * two-sided within an öre epsilon, and no moms box is negative. + * + * Under this condition a per-voucher basis gap cannot mean the declaration is + * under-reporting: the gap voucher's missing in-voucher basis provably exists + * elsewhere in the period AT THE SAME SATS, so both the basis boxes and the + * moms boxes are exactly what the ledger supports. Two-sided on purpose: a + * surplus at one rate is not allowed to vouch for a shortfall at another, and + * a surplus at the SAME rate means fiktiv moms is missing for some voucher + * (the RC_OUTPUT_MISSING defect), which a downgrade must not paper over. + * + * The negative-box guard closes the vacuity hole: a net-negative rate + * (credit notes exceeding purchases) made the refuted summed comparison + * trivially true while rutor 20-24 could be arbitrarily wrong. Any negative + * moms box refuses the downgrade outright, even a per-rate-consistent one: + * SKV rejects negative rutor anyway, so such a period needs human attention + * regardless, and failing toward the blocking ERROR is the safe direction. + */ +function rcBasisPerRateConsistent(evidence: RcGapDowngradeEvidence): boolean { + const { rutor, rcBasisByRate } = evidence + const eps = 0.5 + const pairs: Array<[number, number, number]> = [ + [rcBasisByRate.r25, rutor.ruta30, 0.25], + [rcBasisByRate.r12, rutor.ruta31, 0.12], + [rcBasisByRate.r6, rutor.ruta32, 0.06], + ] + for (const [basis, moms, rate] of pairs) { + // The evidence can arrive as unvalidated JSON (the web view reads it off + // the declaration response). A missing or non-numeric field would make + // every comparison below false-and-passing (NaN compares false to + // everything), silently relaxing a statutory filing gate. Non-finite + // input therefore refuses the downgrade outright: this predicate must + // only ever fail toward the blocking ERROR. + if (!Number.isFinite(basis) || !Number.isFinite(moms)) return false + if (moms < -eps) return false + if (Math.abs(basis - moms / rate) > eps) return false + } + return true } /** diff --git a/types/index.ts b/types/index.ts index 2dcd7b73..89cb54f2 100644 --- a/types/index.ts +++ b/types/index.ts @@ -3164,6 +3164,21 @@ export interface VatDeclaration { * (lib/reports/vat-declaration.ts), never by hand. */ rcInputAccountTotals?: Record + /** + * Net debit balance of the reverse-charge BASIS accounts (44xx/45xx), + * grouped per momssats: r25/r12/r6. Carried so a caller that reads the + * declaration over HTTP can hand `withRcBasisGapFindings` its downgrade + * evidence (lib/reports/vat-filing-gate.ts): rutor 20-24 are partitioned by + * purchase type, not rate, so the per-rate identity against rutor 30-32 is + * only computable from these account-level figures. + * + * Optional because it crosses a JSON boundary: a client parsing a response + * from an older deploy must keep the blocking per-voucher behavior rather + * than fabricate zeros, which would read as "no basis booked at any rate" + * and block correct periods. Produced by `rcBasisTotalsByRate()`, never by + * hand. + */ + rcBasisByRate?: { r25: number; r12: number; r6: number } // Supporting data invoiceCount: number transactionCount: number