Files
accounted/lib/reports/vat-filing-gate.ts
T
MattssonandClaude Fable 5 86f0b70fdd fix(vat): complete account treatment enforcement (#1593)
* fix(vat): complete account treatment enforcement

* docs(api): refresh account endpoint skill

* fix(mcp): preserve ruta 05 compatibility

* test(vat): seed migration constraint fixtures

* docs(vat): clarify treatment precedence

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 23:45:04 +02:00

315 lines
15 KiB
TypeScript

import type {
VatDeclarationCheck,
VatCheckAccountTotals,
} from './vat-declaration-checks'
import { roundOre } from '@/lib/money'
import type { VatDeclarationRutor } from '@/types'
/**
* The filing gate for the momsdeklaration: ONE derived value that the
* "Kontroll av underlaget" banner, the stegen counters and the "Skicka till
* Skatteverket" button all read.
*
* Why this exists: `runVatDeclarationChecks` compares PERIOD TOTALS with a
* tolerance, while `/api/reports/vat-declaration/rc-basis-gaps` scans PER
* VERIFIKAT with no tolerance. The two disagree by design:
*
* - the RC tolerance scales with period size (0.5% of the implied basis), so
* a handful of vouchers can each miss their basbelopp while the period
* shortfall still lands inside it. At a 400 000 kr basis that hides up to
* 2 000 kr of missing underlag.
* - a partly finished korrigering clears the aggregate long before the last
* broken voucher is fixed (the worklist itself documents this).
*
* Before this helper the banner and the send gate read the aggregate only, so
* the UI could render "Inga fel hittades i underlaget för perioden" directly
* above a worklist of the very verifikationer that make the declaration
* wrong, with Skicka enabled.
*
* 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
* finding, and the manual filing route (eSKD-XML and PDF in
* VatManualFilingCard) is deliberately left ungated, so a user who disagrees
* with the finding can still file. Only the direct SKV submission is gated.
*
* This module owns only the gate. The 0.5% tolerance itself belongs to
* vat-declaration-checks.ts and is deliberately untouched here.
*/
/**
* What the per-verifikat scan currently knows.
*
* `pending` and `unavailable` are kept apart on purpose: neither is "inga
* brister", but only a settled failure is worth telling the user about. An
* in-flight scan says nothing; a failed one must not be allowed to pass as an
* all-clear.
*/
export type RcBasisGapScan =
| { status: 'pending' }
| { 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,
dynamic?: { explicitAccounts: Set<string>; rcBasisRateByAccount: Map<string, number> },
): RcBasisTotalsByRate {
const sumGroup = (accounts: readonly string[]): number => {
let sum = 0
for (const account of accounts) {
if (dynamic?.explicitAccounts.has(account)) continue
const t = totals.get(account)
if (t) sum += t.debit - t.credit
}
return Math.round(sum * 100) / 100
}
const result = {
r25: sumGroup(RC_BASIS_ACCOUNTS_BY_RATE.r25),
r12: sumGroup(RC_BASIS_ACCOUNTS_BY_RATE.r12),
r6: sumGroup(RC_BASIS_ACCOUNTS_BY_RATE.r6),
}
for (const [account, rate] of dynamic?.rcBasisRateByAccount ?? []) {
const total = totals.get(account)
if (!total) continue
const key = rate === 0.25 ? 'r25' : rate === 0.12 ? 'r12' : 'r6'
result[key] = roundOre(result[key] + total.debit - total.credit)
}
return result
}
/**
* 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.
*
* It reuses the `RC_BASIS_MISSING` code on purpose: it is the same defect the
* aggregate check describes, only detected per verifikat, and the worklist in
* VatChecksCard keys its own visibility off that code.
*/
export function rcBasisGapFinding(gapCount: number): VatDeclarationCheck {
const subject = gapCount === 1 ? '1 verifikation' : `${gapCount} verifikationer`
const remedy =
gapCount === 1
? 'Korrigera verifikationen i listan nedan innan du lämnar in.'
: 'Korrigera verifikationerna i listan nedan innan du lämnar in.'
return {
code: 'RC_BASIS_MISSING',
status: 'ERROR',
message:
`${subject} i perioden har fiktiv moms (2614/2624/2634) utan basbelopp ` +
'på 44xx/45xx, så ruta 20-24 är för låga. Vid omvänd skattskyldighet ska ' +
'både beskattningsunderlaget (ruta 20-24) och den fiktiva momsen ' +
`(ruta 30-32) redovisas; Skatteverket avvisar annars med felkod FK004. ${remedy}`,
rutor: ['ruta20', 'ruta21', 'ruta22', 'ruta23', 'ruta24', 'ruta30', 'ruta31', 'ruta32'],
}
}
/**
* Shown when the per-verifikat scan could not run. A WARNING, not an ERROR: a
* network hiccup must not lock a user out of a statutory filing deadline, and
* there is nothing for them to correct. But it must exist, because an empty
* check list renders as "Inga fel hittades i underlaget för perioden", and
* that is a claim we have not earned when the scan never answered.
*/
export function rcBasisScanUnavailableFinding(): VatDeclarationCheck {
return {
code: 'RC_BASIS_MISSING',
status: 'WARNING',
message:
'Kontrollen av enskilda verifikationer kunde inte köras, så vi vet inte ' +
'om någon verifikation har fiktiv moms (2614/2624/2634) utan basbelopp ' +
'på 44xx/45xx. Ladda om sidan för att försöka igen. Om listan nedan ' +
'innehåller verifikationer ska de korrigeras innan du lämnar in.',
rutor: ['ruta20', 'ruta21', 'ruta22', 'ruta23', 'ruta24', 'ruta30', 'ruta31', 'ruta32'],
}
}
/**
* Fold the per-verifikat scan into the check list the UI renders and gates on.
* Everything downstream (banner, stegen counters, Skicka) reads the returned
* array, so there is one list and no second opinion.
*/
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.
if (checks.some((c) => c.code === 'RC_BASIS_MISSING')) return checks
if (scan.status === 'pending') return checks
if (scan.status === 'unavailable') return [...checks, rcBasisScanUnavailableFinding()]
if (scan.gapCount <= 0) return checks
// 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
}
/**
* The single send gate. Everything that claims "all clear" or enables filing
* must read this over the SAME array, or the two will drift apart again.
*/
export function isFilingBlocked(checks: VatDeclarationCheck[]): boolean {
return checks.some((c) => c.status === 'ERROR')
}