fix(vat): downgrade per-voucher RC basis gaps only under per-rate evidence (#1464)

* fix(vat): downgrade per-voucher RC basis gaps only under per-rate evidence

Per-voucher RC basis gap findings (findRcBasisGaps) blocked "Skicka till
Skatteverket" as ERROR even when the flagged vouchers were legitimate
moms-only rattelseverifikat whose basbelopp lives in another (often
reversed) verifikat. In that state no arrangement of vouchers satisfies
both the per-voucher scan and the aggregate basis/moms identity, so the
block was unfixable: every correction voucher joined the blocklist it
was meant to clear (Orto Engineering 3DJake support case, 2026-08).

The gap finding now downgrades to a non-blocking WARNING only when ALL
of the following hold, otherwise the blocking ERROR stays exactly as
before:

- the 44xx/45xx RC basis accounts, grouped per momssats
  (RC_BASIS_ACCOUNTS_BY_RATE), match ruta 30/31/32 two-sided within a
  0.5 kr ore epsilon per rate;
- no moms box (ruta 30/31/32) is negative;
- the aggregate RC_OUTPUT_MISSING check has not fired;
- the caller supplied the evidence at all (older wire payloads and
  totals-less contexts keep the blocking behavior).

A first cross-rate-sum predicate was refuted by adversarial review: a
wrong-rate fiktiv moms voucher (12% moms "covered" by a 25% basis)
reached parity and unblocked a 7 800 kr under-declaration, and a
net-negative rate box made the summed comparison vacuous (textbook
FK004 state filing). Rutor 20-24 are partitioned by purchase type, not
rate, so the certificate must come from account totals; both
counterexamples plus the tolerance-hole case (shortfall inside the
aggregate 0.5% tolerance still blocks) are locked in as regression
tests.

The evidence travels as rcBasisByRate on the declaration payload
(rcBasisTotalsByRate projection), consumed by the web view and the MCP
completeness checks; rc-basis-gaps.ts derives its flat account set from
the same rate-grouped single source so scan and evidence cannot drift.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(vat): refuse gap downgrade on non-finite evidence; pin the ore epsilon

Review findings, one pass:

- CodeRabbit (major): rcBasisByRate arrives as unvalidated JSON in the
  web view; a missing or non-numeric field made every per-rate
  comparison evaluate against NaN, which compares false and PASSED the
  predicate, relaxing the filing gate in the unsafe direction. The
  predicate now refuses the downgrade outright on any non-finite basis
  or moms figure, covering both the web and MCP callers.
- CodeRabbit (nit): added a 0.51 kr drift case so a future widening of
  the 0.5 kr epsilon fails a test instead of slipping through green.

Declined with reasons (recorded in the PR summary): requiring textual
voucher-to-voucher references before downgrading (belongs to the
rattelse documentation flow, and would reintroduce the unfixable block
this PR removes); epsilon stacking across rates (max 1.5 kr, immaterial
at whole-krona filing and below the aggregate tolerance); explicit
negative-basis guard (all negative-basis paths already block via the
two-sided mismatch or the negative-moms guard, now plus the finite
guard).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-08-08 14:45:22 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent cadf02e407
commit 799fa1246a
8 changed files with 457 additions and 17 deletions
+1
View File
@@ -831,5 +831,6 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. 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.
+6
View File
@@ -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)
+8 -1
View File
@@ -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. */
@@ -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> = {}): 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<VatDeclarationRutor>,
basis: Partial<RcBasisTotalsByRate>,
): 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
+9 -6
View File
@@ -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<string>([
...RC_BASIS_ACCOUNTS_BY_RATE.r25,
...RC_BASIS_ACCOUNTS_BY_RATE.r12,
...RC_BASIS_ACCOUNTS_BY_RATE.r6,
])
const RATE_BY_OUTPUT: Record<RcOutputAccount, number> = {
+4
View File
@@ -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: {
+187 -10
View File
@@ -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
}
/**
+15
View File
@@ -3164,6 +3164,21 @@ export interface VatDeclaration {
* (lib/reports/vat-declaration.ts), never by hand.
*/
rcInputAccountTotals?: Record<string, { debit: number; credit: number }>
/**
* 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