Files
accounted/lib/reports/rc-basis-gaps.ts
T
Mattsson 799fa1246a 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>
2026-08-08 14:45:22 +02:00

210 lines
6.5 KiB
TypeScript

import type { SupabaseClient } from '@supabase/supabase-js'
import {
fetchEntryLines,
fetchLinesByEntryIds,
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'
/**
* Per-voucher detection of FK004: reverse-charge output VAT booked
* (2614/2624/2634) without a matching basbelopp pair on 44xx/45xx.
*
* Used by the momsdeklaration UI to give the user a concrete list of
* verifikationer to correct, rather than a generic "ruta 30-32 utan
* ruta 20-24" warning that doesn't tell them what to fix.
*/
const RC_OUTPUT_ACCOUNTS = ['2614', '2624', '2634'] as const
type RcOutputAccount = typeof RC_OUTPUT_ACCOUNTS[number]
// 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> = {
'2614': 0.25,
'2624': 0.12,
'2634': 0.06,
}
// Default to EU services (matches the booking-template default
// reverse_charge_supplier_type = 'eu_business'). The user can pick a
// different supplier type on the Korrigera form if needed.
const DEFAULT_BASIS_BY_OUTPUT: Record<RcOutputAccount, string> = {
'2614': '4535',
'2624': '4536',
'2634': '4537',
}
export interface RcBasisGap {
entryId: string
voucherNumber: number
voucherSeries: string
entryDate: string
description: string
rcOutputAccount: RcOutputAccount
rcOutputAmount: number
expectedBasisAmount: number
suggestedBasisAccount: string
rate: number
}
interface RcLineRow {
journal_entry_id: string
account_number: string
debit_amount: number
credit_amount: number
// Supabase typings unpredictably model joined relations as either an object
// or an array depending on the FK; we accept both and normalize below.
journal_entries:
| {
id: string
voucher_number: number
voucher_series: string
entry_date: string
description: string
}
| {
id: string
voucher_number: number
voucher_series: string
entry_date: string
description: string
}[]
}
interface EntryFields {
id: string
voucher_number: number
voucher_series: string
entry_date: string
description: string
}
function pickEntry(row: RcLineRow): EntryFields | null {
const j = row.journal_entries
if (Array.isArray(j)) return j.length > 0 ? j[0] : null
return j ?? null
}
interface SiblingLineRow {
id: string
journal_entry_id: string
account_number: string
debit_amount: number
credit_amount: number
}
export async function findRcBasisGaps(
supabase: SupabaseClient,
companyId: string,
periodType: VatPeriodType,
year: number,
period: number,
options: { fiscalPeriodId?: string } = {},
): Promise<RcBasisGap[]> {
// 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>({
supabase,
entryColumns:
'id, voucher_number, voucher_series, entry_date, description, status, company_id',
lineColumns: 'journal_entry_id, account_number, debit_amount, credit_amount',
filterEntries: (q: EntryLinesQuery) =>
q
.eq('company_id', companyId)
.eq('status', 'posted')
.gte('entry_date', start)
.lte('entry_date', end),
filterLines: (q: EntryLinesQuery) =>
q.in('account_number', RC_OUTPUT_ACCOUNTS as unknown as string[]),
})) as RcLineRow[]
if (rcLines.length === 0) return []
const entryIds = [...new Set(rcLines.map((l) => l.journal_entry_id))]
const siblingLines = await fetchLinesByEntryIds<SiblingLineRow>(
supabase,
entryIds,
'id, journal_entry_id, account_number, debit_amount, credit_amount',
)
const basisByEntry = new Map<string, number>()
for (const line of siblingLines) {
if (RC_BASIS_ACCOUNTS.has(line.account_number)) {
const prev = basisByEntry.get(line.journal_entry_id) || 0
basisByEntry.set(
line.journal_entry_id,
prev + (Number(line.debit_amount) || 0) - (Number(line.credit_amount) || 0),
)
}
}
// Aggregate RC output per (entry, account): a voucher may have multiple
// 2614 lines (rare) and we want to flag the total shortfall.
const aggregated = new Map<string, { row: RcLineRow; amount: number }>()
for (const line of rcLines) {
const key = `${line.journal_entry_id}:${line.account_number}`
const amount = (Number(line.credit_amount) || 0) - (Number(line.debit_amount) || 0)
const existing = aggregated.get(key)
if (existing) {
existing.amount += amount
} else {
aggregated.set(key, { row: line, amount })
}
}
const eps = 0.5
const gaps: RcBasisGap[] = []
for (const { row, amount } of aggregated.values()) {
if (amount <= eps) continue
const account = row.account_number as RcOutputAccount
const rate = RATE_BY_OUTPUT[account]
if (!rate) continue
const expectedBasis = Math.round((amount / rate) * 100) / 100
const actualBasis = basisByEntry.get(row.journal_entry_id) || 0
if (actualBasis + eps >= expectedBasis) continue
const entry = pickEntry(row)
if (!entry) continue
gaps.push({
entryId: row.journal_entry_id,
voucherNumber: entry.voucher_number,
voucherSeries: entry.voucher_series,
entryDate: entry.entry_date,
description: entry.description,
rcOutputAccount: account,
rcOutputAmount: amount,
expectedBasisAmount: expectedBasis,
suggestedBasisAccount: DEFAULT_BASIS_BY_OUTPUT[account],
rate,
})
}
gaps.sort((a, b) => {
if (a.voucherSeries !== b.voucherSeries) {
return a.voucherSeries.localeCompare(b.voucherSeries)
}
return a.voucherNumber - b.voucherNumber
})
return gaps
}