fix(vat): keep the RC-basis worklist visible until every voucher is fixed (#1164)

Correcting a single voucher cleared the momsdeklaration's RC_BASIS_MISSING
error and the whole per-voucher worklist with it: the check tested mere
presence of ruta 20-24 basis, the stepper re-derived its landing step and
yanked the user to Granska mid-work, and the remounted checks card never
refetched gaps once the aggregate check stopped firing. The declaration
then claimed "klart" while the remaining vouchers still under-reported
rutor 20-24 (FK004).

- Make RC_BASIS_MISSING/RC_OUTPUT_MISSING proportional: compare reported
  basis against the basis the per-rate output boxes imply (moms/sats),
  with a 0.5% + 1 kr tolerance for per-voucher ore rounding.
- Fetch the rc-basis-gaps worklist once per period, ungated from the
  aggregate check, so remaining rows survive remounts.
- Latch the automatic stepper landing once per period so a refetch after
  a korrigering cannot navigate the user off Kontrollera.
- Resolve rc-basis-gaps against the rakenskapsar (fiscal_period_id) for
  helarsmoms, matching the declaration totals; a calendar span hid gap
  vouchers in the tail of an extended first year.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-24 22:10:20 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 98e1a48c2b
commit 98886e68d8
9 changed files with 375 additions and 30 deletions
+13 -13
View File
@@ -107,10 +107,9 @@ export function VatChecksCard({
// Gap fetch tagged with the key it was requested under: loading is derived
// by comparing tags, so the effect never sets state synchronously. Fixed
// rows are removed via removedIds (the fetch itself is not re-run after a
// korrigering: the period key is unchanged). The key is period-only, NOT
// gated on RC_BASIS_MISSING: the aggregate check clears as soon as ONE
// voucher is corrected, and the remaining unfixed rows must survive the
// declaration refetch that follows each korrigering.
// korrigering: the period key is unchanged). The key is period-only so the
// list survives the declaration refetch that follows each korrigering, and
// survives a remount even when the aggregate check no longer fires.
const gapsKey = `${periodType}:${year}:${period}:${fiscalPeriodId ?? ''}`
const [gapsResult, setGapsResult] = useState<{
key: string
@@ -145,25 +144,26 @@ export function VatChecksCard({
const gapsFetched = gapsResult !== null && gapsResult.key === gapsKey
useEffect(() => {
// Fetch once per period, and only while the aggregate check flags a gap:
// the fetched list then outlives the check, which clears after the first
// correction even though other vouchers may remain broken.
if (!hasRcBasisGaps || gapsFetched) return
// Fetch once per period, NOT gated on the aggregate check: the check
// compares period totals and can clear (or never fire) while individual
// vouchers still miss their basis pair. Gating the fetch on it meant a
// remount after the first korrigering showed "inga fel" with the
// remaining broken vouchers silently hidden.
if (gapsFetched) return
let cancelled = false
const params = new URLSearchParams({
periodType,
year: String(year),
period: String(period),
})
// Forward-compatible: the route reads calendar params today, but yearly
// (helårsmoms) declarations resolve against the räkenskapsår.
// Yearly (helårsmoms) resolves against the räkenskapsår server-side.
if (fiscalPeriodId) params.set('fiscal_period_id', fiscalPeriodId)
fetch(`/api/reports/vat-declaration/rc-basis-gaps?${params.toString()}`)
.then(async (r) => {
const j = await r.json().catch(() => null)
if (cancelled) return
// A failed fetch must not masquerade as "no gaps found": the check
// above says there ARE gaps, so an empty list here would mislead.
// A failed fetch must not masquerade as "no gaps found": an empty
// list would mislead whenever gaps actually exist.
if (!r.ok || j?.error) setGapsResult({ key: gapsKey, gaps: [], failed: true })
else setGapsResult({ key: gapsKey, gaps: j?.data?.gaps || [] })
})
@@ -173,7 +173,7 @@ export function VatChecksCard({
return () => {
cancelled = true
}
}, [hasRcBasisGaps, gapsFetched, gapsKey, periodType, year, period, fiscalPeriodId])
}, [gapsFetched, gapsKey, periodType, year, period, fiscalPeriodId])
const gaps = gapsFetched
? gapsResult.gaps.filter((g) => !removedIds.has(g.entryId))
+10
View File
@@ -1627,6 +1627,16 @@ export function VatDeclarationView() {
const checksBlocked = checks.some((c) => c.status === 'ERROR')
const errorCount = checks.filter((c) => c.status === 'ERROR').length
const warningCount = checks.filter((c) => c.status === 'WARNING').length
// Latch the automatic landing step once per period, as a render-phase
// adjustment when the period's declaration first settles. Deriving it live
// from checksBlocked navigated the user away mid-work: the refetch after a
// korrigering can clear the aggregate error while the Kontrollera worklist
// still holds broken vouchers, and the view would jump to Granska under
// their feet. The period-change effect below resets chosenStep to null,
// which re-arms this latch for the next period.
if (chosenStep === null && upToDate && data && !error) {
setChosenStep(checksBlocked ? 1 : 2)
}
const activeStep = chosenStep ?? (checksBlocked ? 1 : 2)
// Settings not settled yet — the picker defaults and the gate both depend