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:
co-authored by
Claude Fable 5
parent
cadf02e407
commit
799fa1246a
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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> = {
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user