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
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user