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
+127
View File
@@ -0,0 +1,127 @@
/**
* Tests for findRcBasisGaps: per-voucher FK004 detection.
*
* Mocks the entry-lines fetch layer and resolvePeriodDates. The period must
* resolve through resolvePeriodDates (not the calendar arithmetic) so yearly
* (helårsmoms) worklists cover extended/broken räkenskapsår: a calendar span
* hid gap vouchers that the declaration totals still included.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { SupabaseClient } from '@supabase/supabase-js'
const resolvePeriodDatesMock = vi.fn()
vi.mock('../vat-declaration', () => ({
resolvePeriodDates: (...args: unknown[]) => resolvePeriodDatesMock(...args),
}))
const fetchEntryLinesMock = vi.fn()
const fetchLinesByEntryIdsMock = vi.fn()
vi.mock('@/lib/bookkeeping/entry-lines', () => ({
fetchEntryLines: (...args: unknown[]) => fetchEntryLinesMock(...args),
fetchLinesByEntryIds: (...args: unknown[]) => fetchLinesByEntryIdsMock(...args),
}))
import { findRcBasisGaps } from '../rc-basis-gaps'
const supabase = {} as SupabaseClient
function rcLine(entryId: string, voucherNumber: number, credit: number) {
return {
journal_entry_id: entryId,
account_number: '2614',
debit_amount: 0,
credit_amount: credit,
journal_entries: {
id: entryId,
voucher_number: voucherNumber,
voucher_series: 'A',
entry_date: '2026-05-20',
description: `Voucher ${voucherNumber}`,
},
}
}
describe('findRcBasisGaps', () => {
beforeEach(() => {
vi.clearAllMocks()
resolvePeriodDatesMock.mockResolvedValue({ start: '2025-07-17', end: '2026-12-31' })
fetchEntryLinesMock.mockResolvedValue([])
fetchLinesByEntryIdsMock.mockResolvedValue([])
})
it('resolves the period via resolvePeriodDates with the fiscal period id', async () => {
await findRcBasisGaps(supabase, 'company-1', 'yearly', 2026, 1, { fiscalPeriodId: 'fp-1' })
expect(resolvePeriodDatesMock).toHaveBeenCalledWith(
supabase, 'company-1', 'yearly', 2026, 1, 'fp-1',
)
})
it('filters entries on the resolved bounds, not the calendar year', async () => {
await findRcBasisGaps(supabase, 'company-1', 'yearly', 2026, 1, { fiscalPeriodId: 'fp-1' })
const { filterEntries } = fetchEntryLinesMock.mock.calls[0][0]
const calls: Array<[string, ...unknown[]]> = []
const q = new Proxy(
{},
{
get:
(_t, method: string) =>
(...args: unknown[]) => {
calls.push([method, ...args])
return q
},
},
)
filterEntries(q)
expect(calls).toContainEqual(['gte', 'entry_date', '2025-07-17'])
expect(calls).toContainEqual(['lte', 'entry_date', '2026-12-31'])
})
it('flags vouchers whose RC output VAT lacks a matching basis pair', async () => {
fetchEntryLinesMock.mockResolvedValue([
rcLine('entry-1', 8, 527.29), // no basis lines at all
rcLine('entry-2', 9, 250), // fully booked basis
])
fetchLinesByEntryIdsMock.mockResolvedValue([
{
id: 'l-1',
journal_entry_id: 'entry-2',
account_number: '4535',
debit_amount: 1000,
credit_amount: 0,
},
])
const gaps = await findRcBasisGaps(supabase, 'company-1', 'monthly', 2026, 5)
expect(gaps).toHaveLength(1)
expect(gaps[0]).toMatchObject({
entryId: 'entry-1',
voucherNumber: 8,
rcOutputAccount: '2614',
rcOutputAmount: 527.29,
expectedBasisAmount: 2109.16,
suggestedBasisAccount: '4535',
rate: 0.25,
})
})
it('flags a voucher whose basis is materially short of the expected amount', async () => {
fetchEntryLinesMock.mockResolvedValue([rcLine('entry-1', 8, 2500)])
fetchLinesByEntryIdsMock.mockResolvedValue([
{
id: 'l-1',
journal_entry_id: 'entry-1',
account_number: '4535',
debit_amount: 4000, // expected 10000
credit_amount: 0,
},
])
const gaps = await findRcBasisGaps(supabase, 'company-1', 'monthly', 2026, 5)
expect(gaps).toHaveLength(1)
expect(gaps[0].expectedBasisAmount).toBe(10000)
})
})
@@ -53,6 +53,61 @@ describe('runVatDeclarationChecks', () => {
expect(fk004?.message).toMatch(/ruta 20-24/)
})
// Regression (2026-07-24): the check compared presence, not proportion, so
// correcting ONE voucher out of ~39 cleared the error while ~51 tkr of
// basis was still missing and the declaration claimed to be ready.
it('flags ERROR when RC basis is only partially reported', () => {
const rutor: VatDeclarationRutor = {
...emptyRutor,
ruta21: 2109.16, // one corrected voucher
ruta30: 13446.18, // fiktiv moms for ~39 vouchers → expects ~53 785 kr basis
ruta48: 13446.18,
ruta49: 0,
}
const finding = runVatDeclarationChecks(rutor).find((f) => f.code === 'RC_BASIS_MISSING')
expect(finding?.status).toBe('ERROR')
expect(finding?.message).toMatch(/saknas/)
})
it('does not flag RC basis inside the per-voucher rounding tolerance', () => {
const rutor: VatDeclarationRutor = {
...emptyRutor,
ruta21: 9990, // expected 10000, tolerance max(1, 0.5%) = 50
ruta30: 2500,
ruta48: 2500,
ruta49: 0,
}
const findings = runVatDeclarationChecks(rutor)
expect(findings.find((f) => f.code === 'RC_BASIS_MISSING')).toBeUndefined()
expect(findings.find((f) => f.code === 'RC_OUTPUT_MISSING')).toBeUndefined()
})
it('flags ERROR when a whole voucher of basis is missing beyond the tolerance', () => {
const rutor: VatDeclarationRutor = {
...emptyRutor,
ruta21: 9000, // expected 10000: one ~1000 kr voucher missing
ruta30: 2500,
ruta48: 2500,
ruta49: 0,
}
expect(
runVatDeclarationChecks(rutor).find((f) => f.code === 'RC_BASIS_MISSING')?.status,
).toBe('ERROR')
})
it('flags ERROR when basis exceeds what the output VAT accounts for', () => {
const rutor: VatDeclarationRutor = {
...emptyRutor,
ruta21: 60000, // expected only 10000 from ruta30: fiktiv moms missing
ruta30: 2500,
ruta48: 2500,
ruta49: 0,
}
expect(
runVatDeclarationChecks(rutor).find((f) => f.code === 'RC_OUTPUT_MISSING')?.status,
).toBe('ERROR')
})
it('flags ERROR when basis is present but no output RC VAT', () => {
const rutor: VatDeclarationRutor = {
...emptyRutor,
+9 -2
View File
@@ -4,7 +4,7 @@ import {
fetchLinesByEntryIds,
type EntryLinesQuery,
} from '@/lib/bookkeeping/entry-lines'
import { calculatePeriodDates } from './vat-declaration'
import { resolvePeriodDates } from './vat-declaration'
import type { VatPeriodType } from '@/types'
/**
@@ -107,8 +107,15 @@ export async function findRcBasisGaps(
periodType: VatPeriodType,
year: number,
period: number,
options: { fiscalPeriodId?: string } = {},
): Promise<RcBasisGap[]> {
const { start, end } = calculatePeriodDates(periodType, year, period)
// Same period resolution as the declaration itself: helårsmoms covers the
// räkenskapsår, not the calendar year, so a calendar span would hide gap
// vouchers from the tail of an extended/broken fiscal year while the
// declaration totals (and the aggregate check) still include them.
const { start, end } = await resolvePeriodDates(
supabase, companyId, periodType, year, period, options.fiscalPeriodId
)
// Two-step entry-lines fetch (see lib/bookkeeping/entry-lines.ts).
const rcLines = (await fetchEntryLines<unknown>({
+32 -14
View File
@@ -72,32 +72,50 @@ export function runVatDeclarationChecks(rutor: VatDeclarationRutor): VatDeclarat
// and we don't want a 0.01 rounding scrap to trip a sanity check.
const eps = 0.5
// FK004 mirror: output RC VAT exists, basis missing.
if (rcOutput > eps && rcBasis <= eps) {
// The basis the per-rate output boxes imply (basbelopp = fiktiv moms / sats).
// The RC checks compare reported basis against this instead of testing mere
// presence: a binary present/absent test clears as soon as ONE voucher in
// the period carries a basis pair, silently passing a declaration where the
// remaining vouchers still under-report rutor 20-24 (FK004).
const expectedRcBasis =
rutor.ruta30 / 0.25 + rutor.ruta31 / 0.12 + rutor.ruta32 / 0.06
// Per-voucher öre rounding (basis derived as moms/sats vs the invoiced
// amount) accumulates with voucher count; 0.5% with a 1 kr floor absorbs
// that without hiding a genuinely missing voucher.
const rcTolerance = Math.max(1, expectedRcBasis * 0.005)
// FK004 mirror: output RC VAT exists, basis missing or too low.
if (rcOutput > eps && rcBasis + rcTolerance < expectedRcBasis) {
const shortfall = Math.round(expectedRcBasis - rcBasis)
findings.push({
code: 'RC_BASIS_MISSING',
status: 'ERROR',
message:
'Du har redovisat utgående moms på inköp (ruta 30-32) men inget ' +
'basbelopp för omvänd skattskyldighet (ruta 20-24). Skatteverket ' +
'kräver att båda sidor finns med (ML 13 kap; SKV felkod FK004). ' +
'Kontrollera att leverantörsfakturor med omvänd skattskyldighet ' +
'är bokförda med basbelopp på 44xx/45xx-konton.',
'Den utgående momsen på inköp (ruta 30-32) motsvarar ett basbelopp ' +
`på cirka ${Math.round(expectedRcBasis).toLocaleString('sv-SE')} kr, ` +
'men ruta 20-24 innehåller bara ' +
`${Math.round(rcBasis).toLocaleString('sv-SE')} kr: cirka ` +
`${shortfall.toLocaleString('sv-SE')} kr saknas. Skatteverket kräver ` +
'att båda sidor finns med (ML 13 kap; SKV felkod FK004). Kontrollera ' +
'att leverantörsfakturor med omvänd skattskyldighet är bokförda med ' +
'basbelopp på 44xx/45xx-konton.',
rutor: ['ruta20', 'ruta21', 'ruta22', 'ruta23', 'ruta24', 'ruta30', 'ruta31', 'ruta32'],
})
}
// Mirror: basis present but no output VAT, equally broken, often a
// half-finished manual posting.
if (rcBasis > eps && rcOutput <= eps) {
// Mirror: more basis than the output VAT accounts for, fiktiv moms missing
// for some vouchers. Covers both the all-output-missing case and a partial
// one; often a half-finished manual posting.
if (rcBasis > eps && rcBasis > expectedRcBasis + rcTolerance) {
findings.push({
code: 'RC_OUTPUT_MISSING',
status: 'ERROR',
message:
'Du har redovisat basbelopp för omvänd skattskyldighet (ruta 20-24) ' +
'men ingen utgående moms (ruta 30-32). Vid omvänd skattskyldighet ' +
'måste köparen redovisa både underlag och fiktiv moms (ML 13 kap). ' +
'Kontrollera att fiktiv moms är bokförd på 2614/2624/2634.',
'Basbeloppet för omvänd skattskyldighet (ruta 20-24) är större än ' +
'vad den utgående momsen (ruta 30-32) motsvarar. Vid omvänd ' +
'skattskyldighet måste köparen redovisa både underlag och fiktiv ' +
'moms (ML 13 kap). Kontrollera att fiktiv moms är bokförd på ' +
'2614/2624/2634 för varje inköp.',
rutor: ['ruta20', 'ruta21', 'ruta22', 'ruta23', 'ruta24', 'ruta30', 'ruta31', 'ruta32'],
})
}