* 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>
356 lines
14 KiB
TypeScript
356 lines
14 KiB
TypeScript
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',
|
|
status: 'ERROR',
|
|
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',
|
|
message: 'warning finding',
|
|
}
|
|
|
|
describe('withRcBasisGapFindings', () => {
|
|
it('leaves the list untouched when the scan found no gaps', () => {
|
|
expect(withRcBasisGapFindings([], { status: 'scanned', gapCount: 0 })).toEqual([])
|
|
expect(
|
|
withRcBasisGapFindings([warningOnly], { status: 'scanned', gapCount: 0 }),
|
|
).toEqual([warningOnly])
|
|
})
|
|
|
|
it('says nothing while the scan is still in flight', () => {
|
|
// Pending is not "inga brister", but it is not worth a row either: the
|
|
// scan resolves in the same breath as the declaration itself.
|
|
expect(withRcBasisGapFindings([], { status: 'pending' })).toEqual([])
|
|
})
|
|
|
|
it('adds a blocking finding when the aggregate check stayed silent', () => {
|
|
// The filing hole: a 0.5% tolerance on a large period absorbs the whole
|
|
// shortfall, so runVatDeclarationChecks returns [] while individual
|
|
// verifikat still miss their basbelopp.
|
|
const result = withRcBasisGapFindings([], { status: 'scanned', gapCount: 2 })
|
|
expect(result).toHaveLength(1)
|
|
expect(result[0].code).toBe('RC_BASIS_MISSING')
|
|
expect(result[0].status).toBe('ERROR')
|
|
expect(result[0].message).toContain('2 verifikationer')
|
|
})
|
|
|
|
it('keeps warnings and appends the gap finding after them', () => {
|
|
const result = withRcBasisGapFindings([warningOnly], { status: 'scanned', gapCount: 1 })
|
|
expect(result).toHaveLength(2)
|
|
expect(result[0]).toBe(warningOnly)
|
|
expect(result[1].message).toContain('1 verifikation ')
|
|
})
|
|
|
|
it('does not duplicate the message when the aggregate check already fired', () => {
|
|
const result = withRcBasisGapFindings([aggregateRcBasisMissing], {
|
|
status: 'scanned',
|
|
gapCount: 3,
|
|
})
|
|
expect(result).toEqual([aggregateRcBasisMissing])
|
|
})
|
|
|
|
it('does not mutate the input array', () => {
|
|
const input: VatDeclarationCheck[] = [warningOnly]
|
|
withRcBasisGapFindings(input, { status: 'scanned', gapCount: 4 })
|
|
expect(input).toHaveLength(1)
|
|
})
|
|
})
|
|
|
|
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
|
|
// perioden". A scan that never answered has not earned that sentence.
|
|
const result = withRcBasisGapFindings([], { status: 'unavailable' })
|
|
expect(result).toHaveLength(1)
|
|
expect(result[0].status).toBe('WARNING')
|
|
expect(isFilingBlocked(result)).toBe(false)
|
|
})
|
|
|
|
it('does not stack on top of a finding that already blocks', () => {
|
|
const result = withRcBasisGapFindings([aggregateRcBasisMissing], { status: 'unavailable' })
|
|
expect(result).toEqual([aggregateRcBasisMissing])
|
|
})
|
|
})
|
|
|
|
describe('isFilingBlocked', () => {
|
|
it('is false for an empty list and for warnings only', () => {
|
|
expect(isFilingBlocked([])).toBe(false)
|
|
expect(isFilingBlocked([warningOnly])).toBe(false)
|
|
})
|
|
|
|
it('is true for any ERROR', () => {
|
|
expect(isFilingBlocked([warningOnly, aggregateRcBasisMissing])).toBe(true)
|
|
})
|
|
|
|
it('is true for a declaration whose only fault is unfixed gap verifikat', () => {
|
|
// The regression this whole module exists for: banner and Skicka button
|
|
// read the SAME folded array, so "inga fel" can no longer render above a
|
|
// populated worklist with the send button enabled.
|
|
const checks = withRcBasisGapFindings([], { status: 'scanned', gapCount: 2 })
|
|
expect(checks.length).toBeGreaterThan(0)
|
|
expect(isFilingBlocked(checks)).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('rcBasisGapFinding', () => {
|
|
it('names the boxes and the SKV rejection code so the user can act', () => {
|
|
const finding = rcBasisGapFinding(1)
|
|
expect(finding.message).toContain('ruta 20-24')
|
|
expect(finding.message).toContain('FK004')
|
|
expect(finding.rutor).toContain('ruta20')
|
|
})
|
|
|
|
it('uses singular and plural Swedish wording', () => {
|
|
expect(rcBasisGapFinding(1).message).toContain('1 verifikation i perioden')
|
|
expect(rcBasisGapFinding(5).message).toContain('5 verifikationer i perioden')
|
|
expect(rcBasisGapFinding(1).message).toContain('Korrigera verifikationen')
|
|
expect(rcBasisGapFinding(5).message).toContain('Korrigera verifikationerna')
|
|
})
|
|
})
|
|
|
|
describe('rcBasisScanUnavailableFinding', () => {
|
|
it('is a warning that admits it does not know, and points at the worklist', () => {
|
|
const finding = rcBasisScanUnavailableFinding()
|
|
expect(finding.status).toBe('WARNING')
|
|
expect(finding.message).toContain('kunde inte köras')
|
|
expect(finding.message).toContain('listan nedan')
|
|
})
|
|
})
|