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>
This commit is contained in:
Mattsson
2026-08-15 23:45:04 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent edfdbe2d2a
commit 86f0b70fdd
43 changed files with 1017 additions and 359 deletions
@@ -392,4 +392,38 @@ describe('gnubok_update_account', () => {
expect(result.preview.current.account_name).toBe('Förbrukningsinventarier')
expect(result.preview.changes).toEqual({ account_name: 'Verktyg', is_active: false })
})
it('preserves an existing booking rate when only treatment changes', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({
data: {
account_number: '4056', account_name: 'EU-varor', default_vat_rate: 0.12,
default_vat_treatment: null, is_active: true,
},
})
const result = (await updateAccount.execute(
{ account_number: '4056', default_vat_treatment: 'reverse_charge_eu_goods', dry_run: true },
'company-1', 'user-1', supabase as never,
)) as { preview: { changes: Record<string, unknown> } }
expect(result.preview.changes).toEqual({
default_vat_treatment: 'reverse_charge_eu_goods',
})
})
it('can clear a treatment to restore BAS fallback', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({
data: {
account_number: '3041', account_name: 'Försäljning', default_vat_rate: 0.25,
default_vat_treatment: 'standard_25', is_active: true,
},
})
const result = (await updateAccount.execute(
{ account_number: '3041', default_vat_treatment: null, dry_run: true },
'company-1', 'user-1', supabase as never,
)) as { preview: { changes: Record<string, unknown> } }
expect(result.preview.changes).toEqual({ default_vat_treatment: null })
})
})
@@ -168,9 +168,12 @@ describe('tools/list payload size guard', () => {
// Both property descriptions trimmed to one sentence first; headroom
// before the change was under 20 tokens, so even the trimmed wire
// contract crossed.
// * 59.5K to 59.7K with account VAT treatments: create_account and
// update_account both expose the 12-value treatment vocabulary. The
// descriptions are minimal; the enum values are the wire contract.
// Long-term answer to growth is leaning harder on gnubok_search_tools: if this
// fires again, prefer trimming descriptions or making a tool opt-in via search
// before bumping further.
expect(approxTokens).toBeLessThan(59_500)
expect(approxTokens).toBeLessThan(59_700)
})
})
@@ -41,7 +41,9 @@ interface MockLine {
interface MockChartAccount {
account_number: string
account_name: string
account_class: number
default_vat_rate: number | null
default_vat_treatment: string | null
}
/**
@@ -127,7 +129,9 @@ describe('gnubok_vat_close_check: declaration completeness', () => {
[{
account_number: '3011',
account_name: 'Försäljning tjänster inom Sverige, 25 % moms',
account_class: 3,
default_vat_rate: null,
default_vat_treatment: null,
}],
),
)
@@ -139,6 +143,54 @@ describe('gnubok_vat_close_check: declaration completeness', () => {
expect(result.ready_to_close).toBe(true)
})
it('honors an explicit override of a standard BAS revenue account', async () => {
const result = await computeVatCloseCheck(
PERIOD,
'company-1',
mockSupabase(
[
{ entry: 'e1', account_number: '3011', credit_amount: 1000 },
{ entry: 'e1', account_number: '1510', debit_amount: 1000 },
],
[{
account_number: '3011',
account_name: 'Momsfri försäljning',
account_class: 3,
default_vat_rate: 0,
default_vat_treatment: 'exempt',
}],
),
)
expect(result.rutor.ruta05).toBe(0)
})
it('accepts a custom EU goods basis account in the MCP close check', async () => {
const result = await computeVatCloseCheck(
PERIOD,
'company-1',
mockSupabase(
[
{ entry: 'e1', account_number: '4056', debit_amount: 1000 },
{ entry: 'e1', account_number: '2440', credit_amount: 1000 },
{ entry: 'e1', account_number: '2614', credit_amount: 250 },
{ entry: 'e1', account_number: '2645', debit_amount: 250 },
],
[{
account_number: '4056',
account_name: 'Inköp varor EU 25 %',
account_class: 4,
default_vat_rate: 0.25,
default_vat_treatment: 'reverse_charge_eu_goods',
}],
),
)
expect(result.declaration_checks.map((finding) => finding.code))
.not.toContain('RC_BASIS_MISSING')
expect(result.ready_to_close).toBe(true)
})
it('refuses the #1164 declaration: fiktiv moms on 2614/2645 with no basbelopp on 44xx/45xx', async () => {
// Both VAT legs of a reverse-charge purchase booked, but the cost went
// straight to 6540 instead of the 4535 basis account, so rutor 20-24 stay
@@ -17,6 +17,9 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import type { VatDeclarationRutor } from '@/types'
const { mockFindRcBasisGaps } = vi.hoisted(() => ({
mockFindRcBasisGaps: vi.fn(),
}))
const mockSkvRequest = vi.fn()
vi.mock('@/extensions/general/skatteverket/lib/api-client', async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, unknown>
@@ -47,10 +50,8 @@ vi.mock('@/lib/reports/vat-declaration', async (importOriginal) => {
}
})
// The per-verifikat FK004 scan has its own coverage in lib/reports/__tests__;
// here it must simply not add findings of its own.
vi.mock('@/lib/reports/rc-basis-gaps', () => ({
findRcBasisGaps: vi.fn(async () => []),
findRcBasisGaps: (...args: unknown[]) => mockFindRcBasisGaps(...args),
}))
import { tools } from '../server'
@@ -87,10 +88,12 @@ function makeRutor(partial: Partial<VatDeclarationRutor> = {}): VatDeclarationRu
function setDeclaration(
rutor: VatDeclarationRutor,
rcInput?: Record<string, { debit: number; credit: number }>,
rcBasisByRate?: { r25: number; r12: number; r6: number },
) {
mockCalculateVatDeclaration.mockResolvedValue({
rutor,
...(rcInput ? { rcInputAccountTotals: rcInput } : {}),
...(rcBasisByRate ? { rcBasisByRate } : {}),
})
}
@@ -116,6 +119,7 @@ function skvOk(body: unknown = { kontrollResultat: { status: 'OK', resultat: []
let prevEnv: string | undefined
beforeEach(() => {
vi.clearAllMocks()
mockFindRcBasisGaps.mockResolvedValue([])
prevEnv = process.env.SKATTEVERKET_ENABLED
process.env.SKATTEVERKET_ENABLED = 'true'
})
@@ -215,6 +219,22 @@ describe('gnubok_vat_declaration_validate', () => {
expect(result.completeness_ok).toBe(true)
})
it('uses declaration rate evidence when a correction gap is covered in aggregate', async () => {
mockFindRcBasisGaps.mockResolvedValue([{}])
setDeclaration(
makeRutor({ ruta20: 5000, ruta30: 1250, ruta48: 1250 }),
rcInput({ '2645': 1250 }),
{ r25: 5000, r12: 0, r6: 0 },
)
skvOk()
const result = await run()
expect(result.completeness_checks.find((c) => c.code === 'RC_BASIS_MISSING')?.status)
.toBe('WARNING')
expect(result.completeness_ok).toBe(true)
})
it('arithmetic_ok is false when Skatteverket returns an ERROR, independently of completeness', async () => {
setDeclaration(CLEAN)
skvOk({
@@ -9,6 +9,8 @@ interface AccountSummary {
normal_balance: string
is_active: boolean
default_vat_code: string | null
default_vat_rate: number | null
default_vat_treatment: string | null
}
export const chartOfAccountsResource: McpResource = {
@@ -25,7 +27,7 @@ export const chartOfAccountsResource: McpResource = {
accounts = await fetchAllRows<AccountSummary>(({ from, to }) =>
supabase
.from('chart_of_accounts')
.select('account_number, account_name, account_class, account_type, normal_balance, is_active, default_vat_code')
.select('account_number, account_name, account_class, account_type, normal_balance, is_active, default_vat_code, default_vat_rate, default_vat_treatment')
.eq('company_id', companyId)
.order('account_number', { ascending: true })
.range(from, to)
+83 -57
View File
@@ -48,7 +48,7 @@ import {
rcInputTotalsFromDeclaration,
calculateVatDeclaration,
} from '@/lib/reports/vat-declaration'
import { fetchDynamicRuta05Accounts } from '@/lib/reports/vat-revenue-accounts'
import { fetchDynamicVatAccounts } from '@/lib/reports/vat-revenue-accounts'
// The momsdeklaration completeness checks live in core (lib/reports) and are
// shared with the web UI's "Kontroll av underlaget" gate. The MCP surface
// imports them instead of mirroring them: a hand-rolled copy here is exactly
@@ -88,6 +88,12 @@ import {
} from './tool-namespace'
import { getRiskLevel } from '@/lib/pending-operations/risk-tiers'
import { normalizeVatRateToDecimal } from '@/lib/vat/supplier-invoice-line-checks'
import {
ACCOUNT_VAT_TREATMENTS,
defaultRateForVatTreatment,
isAccountVatTreatment,
isVatTreatmentAllowedForAccountClass,
} from '@/lib/vat/account-vat-treatment'
import { CreateSupplierParamsSchema } from '@/lib/pending-operations/schemas/create-supplier'
import { accountClassTypeConflict } from '@/lib/pending-operations/schemas/account'
import { getBASReference } from '@/lib/bookkeeping/bas-reference'
@@ -1359,33 +1365,15 @@ const SKV_AGI_STATUS_OUTPUT_SCHEMA = {
// (the original sale's VAT silently disappears) and over-credit Period N+M
// (a reversal with no original), incorrect per ML 2023:200.
/** Common BAS taxable-revenue accounts that contribute to ruta 05.
*
* Conservative expansion beyond 3001/3002/3003. Excludes 3004 (momsfri,
* exempt) and 3108/3305/3308 (handled by ruta35/40/39). 3106 covers the
* rare case of taxable EU goods (momspliktig EU-leverans, e.g. when the
* buyer's VAT number is invalid).
*
* This hand-maintained widening predates #1261 and is kept so no company
* loses a figure it already saw. It is no longer the only path: a company's
* own class 3 konto marked with a moms-sats is resolved at runtime by
* fetchDynamicRuta05Accounts and unioned in below, which is what actually
* covers non-standard charts (Accounted's BAS chart ships no varugrupp
* accounts at all). */
const RUTA_05_ACCOUNTS = [
// The 30xx gruppkonto. ACCOUNT_RUTA maps it to ruta05, so leaving it out here
// made a balance on 3000 appear in the filed projection but not in
// report.rutor.ruta05.
// Keep the MCP report's historical ruta 05 widening for accounts that do not
// have an explicit treatment. The effective resolver adds custom ruta 05
// accounts and removes any of these when the company overrides their treatment.
const RUTA_05_COMPATIBILITY_ACCOUNTS = [
'3000',
// Domestic sales by VAT rate (canonical BAS)
'3001', '3002', '3003', '3005', '3006', '3007', '3008',
// Taxable EU goods (momspliktig, buyer's VAT number invalid or buyer is private)
'3106',
// Domestic services (alternative numbering some companies use)
'3041', '3042', '3043', '3044', '3045', '3046', '3047', '3048',
// Domestic goods (alternative numbering)
'3051', '3052', '3053', '3054', '3055', '3056', '3057', '3058',
// Other domestic taxable
'3071', '3072', '3073', '3074', '3075', '3076', '3077', '3078',
] as const
@@ -1408,6 +1396,7 @@ export interface VatReportResult {
export interface VatReportWithRutor {
report: VatReportResult
dynamicVatAccounts: Awaited<ReturnType<typeof fetchDynamicVatAccounts>>
/**
* The FULL SKV 4700 projection of the same ledger aggregate, via core's
* `rutorFromTotals`. `report.rutor` is the trimmed agent-facing view: it has
@@ -1546,43 +1535,36 @@ export async function computeVatReportWithRutor(
accountTotals.set(acc, existing)
}
function creditBalance(acc: string): number {
const t = accountTotals.get(acc)
return t ? Math.round((t.credit - t.debit) * 100) / 100 : 0
}
function debitBalance(acc: string): number {
const t = accountTotals.get(acc)
return t ? Math.round((t.debit - t.credit) * 100) / 100 : 0
}
// The company's own momspliktiga intäktskonton join the hand-maintained list.
// Deduped: an account can appear in both (e.g. 3041 with a moms-sats set),
// and counting it twice would inflate ruta 05.
const dynamicRuta05 = await fetchDynamicRuta05Accounts(supabase, companyId)
const ruta05Accounts = [...new Set([...RUTA_05_ACCOUNTS, ...dynamicRuta05.accounts])]
const ruta05 = ruta05Accounts.reduce((sum, acc) => sum + creditBalance(acc), 0)
const ruta10 = creditBalance('2611')
const ruta11 = creditBalance('2621')
const ruta12 = creditBalance('2631')
const ruta30 = creditBalance('2614')
const ruta31 = creditBalance('2624')
const ruta32 = creditBalance('2634')
const ruta35 = creditBalance('3108') // EU intra-community goods supplies (momsfri leverans till EU)
const ruta39 = creditBalance('3308')
const ruta40 = creditBalance('3305')
function creditBalance(acc: string): number {
return -debitBalance(acc)
}
const dynamicVatAccounts = await fetchDynamicVatAccounts(supabase, companyId)
const declarationRutor = rutorFromTotals(accountTotals, dynamicVatAccounts)
const {
ruta10, ruta11, ruta12, ruta30, ruta31, ruta32,
ruta35, ruta39, ruta40, ruta48, ruta49, ruta60, ruta61, ruta62,
} = declarationRutor
const reportRuta05Accounts = new Set<string>(
RUTA_05_COMPATIBILITY_ACCOUNTS.filter(
(account) => !dynamicVatAccounts.explicitAccounts.has(account),
),
)
for (const [account, mapping] of dynamicVatAccounts.mappingByAccount) {
if (mapping.box === 'ruta05') reportRuta05Accounts.add(account)
}
const ruta05 = [...reportRuta05Accounts]
.reduce((sum, account) => sum + creditBalance(account), 0)
// Import VAT (since 2015 declared via momsdeklaration, not Tullverket): the
// importer books output VAT to 2615/2625/2635 (ruta 60/61/62) and the
// matching deductible input to 2645 (rolls into ruta 48 below).
const ruta60 = creditBalance('2615')
const ruta61 = creditBalance('2625')
const ruta62 = creditBalance('2635')
const calculatedInput2645 = debitBalance('2645')
const calculatedInput2647 = debitBalance('2647')
const ruta48 = debitBalance('2641') + calculatedInput2645 + calculatedInput2647
const ruta49 = Math.round(
(ruta10 + ruta11 + ruta12 + ruta30 + ruta31 + ruta32 + ruta60 + ruta61 + ruta62 - ruta48) * 100
) / 100
const monthNames = ['Januari', 'Februari', 'Mars', 'April', 'Maj', 'Juni',
'Juli', 'Augusti', 'September', 'Oktober', 'November', 'December']
@@ -1642,7 +1624,8 @@ export async function computeVatReportWithRutor(
// (incl. rutor 20-24 and 50) instead of the trimmed report view.
return {
report,
declarationRutor: rutorFromTotals(accountTotals, dynamicRuta05.accounts),
declarationRutor,
dynamicVatAccounts,
accountTotals,
}
}
@@ -1676,6 +1659,8 @@ async function runVatCompletenessChecks(
year: number,
period: number,
accountTotals?: VatCheckAccountTotals,
dynamicVatAccounts?: Awaited<ReturnType<typeof fetchDynamicVatAccounts>>,
rcBasisByRate?: { r25: number; r12: number; r6: number },
): Promise<VatDeclarationCheck[]> {
let scan: RcBasisGapScan
try {
@@ -1688,7 +1673,10 @@ async function runVatCompletenessChecks(
// caller supplied the account totals; without them the per-voucher gaps
// keep their blocking ERROR tier rather than guessing.
const evidence = accountTotals
? { rutor, rcBasisByRate: rcBasisTotalsByRate(accountTotals) }
? {
rutor,
rcBasisByRate: rcBasisByRate ?? rcBasisTotalsByRate(accountTotals, dynamicVatAccounts),
}
: undefined
return withRcBasisGapFindings(runVatDeclarationChecks(rutor, accountTotals), scan, evidence)
}
@@ -2073,7 +2061,7 @@ export async function computeVatCloseCheck(
// step 4b: they need rutor 20-24 and 50, which the report view omits, plus
// the per-account totals so the RC input comparison reads 2645/2647
// instead of the ruta 48 aggregate.
const { report: vatReport, declarationRutor, accountTotals } =
const { report: vatReport, declarationRutor, dynamicVatAccounts, accountTotals } =
await computeVatReportWithRutor(args, companyId, supabase)
const { start, end, type: periodType, year, period } = vatReport.period
@@ -2184,6 +2172,7 @@ export async function computeVatCloseCheck(
Number(year),
Number(period),
accountTotals,
dynamicVatAccounts,
)
// Zero deductible input VAT against self-assessed utgående moms is
@@ -6409,6 +6398,11 @@ export const tools: McpTool[] = [
description: { type: 'string' },
default_vat_code: { type: 'string' },
default_vat_rate: { type: 'number', enum: [0, 0.06, 0.12, 0.25], description: 'Fraction (0.25 = 25%). Livsmedel: 0.06 from 2026-04-01 (temporary cut from 0.12, reverts 2027-12-31).' },
default_vat_treatment: {
type: 'string',
enum: [...ACCOUNT_VAT_TREATMENTS],
description: 'VAT return treatment.',
},
sru_code: { type: 'string', description: 'Prefilled for BAS numbers.' },
dry_run: { type: 'boolean', description: 'Validate and preview without staging.' },
idempotency_key: { type: 'string', description: 'Per-operation UUID for safe retries (24h TTL).' },
@@ -6470,6 +6464,17 @@ export const tools: McpTool[] = [
if (vatRate !== undefined && ![0, 0.06, 0.12, 0.25].includes(vatRate)) {
throw new Error('default_vat_rate must be one of 0, 0.06, 0.12, 0.25 (fraction, not percent)')
}
const vatTreatment = args.default_vat_treatment
if (vatTreatment !== undefined && vatTreatment !== null && !isAccountVatTreatment(vatTreatment)) {
throw new Error('default_vat_treatment is not supported')
}
const accountClass = Number(accountNumber[0])
if (vatTreatment && !isVatTreatmentAllowedForAccountClass(vatTreatment, accountClass)) {
throw new Error('default_vat_treatment is not valid for this account class')
}
const effectiveVatRate = vatTreatment && vatRate === undefined
? defaultRateForVatTreatment(vatTreatment, accountClass)
: vatRate
const params: Record<string, unknown> = {
account_number: accountNumber,
@@ -6479,7 +6484,8 @@ export const tools: McpTool[] = [
plan_type: ref ? 'full_bas' : 'k1',
description: String(args.description ?? '').trim() || ref?.description || undefined,
default_vat_code: String(args.default_vat_code ?? '').trim() || undefined,
default_vat_rate: vatRate,
default_vat_rate: effectiveVatRate,
default_vat_treatment: vatTreatment,
sru_code: String(args.sru_code ?? '').trim() || ref?.sru_code || undefined,
}
@@ -6514,6 +6520,11 @@ export const tools: McpTool[] = [
description: { type: 'string' },
default_vat_code: { type: 'string' },
default_vat_rate: { type: 'number', enum: [0, 0.06, 0.12, 0.25], description: 'Default VAT rate as a fraction (0.25 = 25%). Livsmedel: 0.06 from 2026-04-01 (temporary cut from 0.12, reverts 2027-12-31).' },
default_vat_treatment: {
type: ['string', 'null'],
enum: [...ACCOUNT_VAT_TREATMENTS, null],
description: 'VAT return treatment.',
},
sru_code: { type: 'string' },
is_active: { type: 'boolean', description: 'false deactivates (hides from pickers, keeps history); true (re)activates.' },
dry_run: { type: 'boolean' },
@@ -6535,7 +6546,7 @@ export const tools: McpTool[] = [
const { data: current, error: fetchErr } = await supabase
.from('chart_of_accounts')
.select('account_number, account_name, description, default_vat_code, default_vat_rate, sru_code, is_active')
.select('account_number, account_name, description, default_vat_code, default_vat_rate, default_vat_treatment, sru_code, is_active')
.eq('company_id', companyId)
.eq('account_number', accountNumber)
.maybeSingle()
@@ -6548,17 +6559,30 @@ export const tools: McpTool[] = [
if (vatRate !== undefined && ![0, 0.06, 0.12, 0.25].includes(vatRate)) {
throw new Error('default_vat_rate must be one of 0, 0.06, 0.12, 0.25 (fraction, not percent)')
}
const vatTreatment = args.default_vat_treatment
if (vatTreatment !== undefined && vatTreatment !== null && !isAccountVatTreatment(vatTreatment)) {
throw new Error('default_vat_treatment is not supported')
}
const accountClass = Number(accountNumber[0])
if (vatTreatment && !isVatTreatmentAllowedForAccountClass(vatTreatment, accountClass)) {
throw new Error('default_vat_treatment is not valid for this account class')
}
const params: Record<string, unknown> = { account_number: accountNumber }
const changes: Record<string, unknown> = {}
for (const key of ['account_name', 'description', 'default_vat_code', 'default_vat_rate', 'sru_code', 'is_active']) {
for (const key of ['account_name', 'description', 'default_vat_code', 'default_vat_rate', 'default_vat_treatment', 'sru_code', 'is_active']) {
if (args[key] !== undefined) {
params[key] = args[key]
changes[key] = args[key]
}
}
if (vatTreatment && vatRate === undefined && current.default_vat_rate == null) {
const derivedRate = defaultRateForVatTreatment(vatTreatment, accountClass)
params.default_vat_rate = derivedRate
changes.default_vat_rate = derivedRate
}
if (Object.keys(changes).length === 0) {
throw new Error('Nothing to update: pass at least one of account_name, description, default_vat_code, default_vat_rate, sru_code, is_active.')
throw new Error('Nothing to update: pass at least one account field.')
}
return stagePendingOperation(supabase, companyId, userId, 'update_account',
@@ -11221,6 +11245,8 @@ export const tools: McpTool[] = [
const completenessChecks = await runVatCompletenessChecks(
supabase, companyId, declaration.rutor, periodType, year, period,
rcInputTotalsFromDeclaration(declaration),
undefined,
declaration.rcBasisByRate,
)
const completenessOk = !isFilingBlocked(completenessChecks)