300 lines
12 KiB
TypeScript
300 lines
12 KiB
TypeScript
/**
|
|
* gnubok_vat_close_check: momsdeklaration completeness gate.
|
|
*
|
|
* Regression cover for the pre-flight hole this tool used to have. Its only
|
|
* declaration check was `acquisitionAndImportBase > 0 && ruta48 === 0`, and
|
|
* ruta 48 aggregates 2641/2642/2645/2646/2647/2649: a single ordinary domestic
|
|
* receipt in the period made it unreachable, and there was no basbelopp check
|
|
* (rutor 20-24 against rutor 30-32) at all. A declaration with fiktiv moms and
|
|
* no underlag, the FK004 shape Skatteverket rejects, came back as "Klart för
|
|
* stängning".
|
|
*
|
|
* The tool now runs the SHARED core checks (lib/reports/vat-declaration-checks)
|
|
* over the full SKV 4700 projection, folded through the same filing gate the
|
|
* web UI uses, so these assertions are really asserting "MCP agrees with the
|
|
* web UI".
|
|
*
|
|
* Only the bank reconciliation is mocked: it queries shapes this fixture has no
|
|
* opinion about, and an unreconciled bank would add an unrelated blocker.
|
|
*/
|
|
import { describe, it, expect, vi } from 'vitest'
|
|
|
|
vi.mock('@/lib/reconciliation/bank-reconciliation', () => ({
|
|
getReconciliationStatus: vi.fn(async () => ({
|
|
is_reconciled: true,
|
|
difference: 0,
|
|
unmatched_transaction_count: 0,
|
|
unmatched_gl_line_count: 0,
|
|
})),
|
|
}))
|
|
|
|
import { computeVatCloseCheck } from '../server'
|
|
|
|
interface MockLine {
|
|
entry?: string
|
|
account_number: string
|
|
debit_amount?: number
|
|
credit_amount?: number
|
|
source_type?: string | null
|
|
}
|
|
|
|
interface MockChartAccount {
|
|
account_number: string
|
|
account_name: string
|
|
default_vat_rate: number | null
|
|
}
|
|
|
|
/**
|
|
* Table-routed Supabase double. journal_entries / journal_entry_lines serve the
|
|
* fixture; everything else (transactions, supplier_invoices, company_settings)
|
|
* comes back empty so no unrelated blocker fires.
|
|
*/
|
|
function mockSupabase(lines: MockLine[], chartAccounts: MockChartAccount[] = []) {
|
|
const entries = [
|
|
...new Map(
|
|
lines.map((l, i) => {
|
|
const id = l.entry ?? `entry-${i}`
|
|
return [
|
|
id,
|
|
{
|
|
id,
|
|
source_type: l.source_type ?? 'manual',
|
|
voucher_series: 'A',
|
|
voucher_number: 100 + i,
|
|
entry_date: '2026-01-15',
|
|
description: 'Fixture',
|
|
status: 'posted',
|
|
},
|
|
]
|
|
}),
|
|
).values(),
|
|
]
|
|
const bareLines = lines.map((l, i) => ({
|
|
id: `line-${String(i).padStart(4, '0')}`,
|
|
journal_entry_id: l.entry ?? `entry-${i}`,
|
|
account_number: l.account_number,
|
|
debit_amount: l.debit_amount ?? 0,
|
|
credit_amount: l.credit_amount ?? 0,
|
|
}))
|
|
|
|
const makeChain = (rows: unknown[]): Record<string, unknown> => {
|
|
const chain: Record<string, unknown> = {}
|
|
const settled = { data: rows, error: null, count: rows.length }
|
|
chain.range = () => settled
|
|
chain.single = async () => ({ data: null, error: null })
|
|
chain.maybeSingle = async () => ({ data: null, error: null })
|
|
chain.then = (resolve: (v: unknown) => void) => resolve(settled)
|
|
for (const m of [
|
|
'order', 'lte', 'gte', 'neq', 'in', 'eq', 'is', 'select',
|
|
'limit', 'contains', 'filter', 'not', 'or',
|
|
]) {
|
|
chain[m] = () => chain
|
|
}
|
|
return chain
|
|
}
|
|
|
|
return {
|
|
from: (table: string) => {
|
|
if (table === 'journal_entries') return makeChain(entries)
|
|
if (table === 'journal_entry_lines') return makeChain(bareLines)
|
|
if (table === 'chart_of_accounts') return makeChain(chartAccounts)
|
|
return makeChain([])
|
|
},
|
|
// The missing-underlag blocker reads the verifikat_without_documents RPC,
|
|
// which answers with an envelope rather than a row set. This fixture has no
|
|
// opinion about underlag, so it answers "none missing"; the predicate itself
|
|
// is covered by vat-close-check-missing-underlag.test.ts.
|
|
rpc: (fn: string) =>
|
|
fn === 'verifikat_without_documents'
|
|
? Promise.resolve({ data: { ok: true, total_count: 0, verifikat: [] }, error: null })
|
|
: makeChain([]),
|
|
} as never
|
|
}
|
|
|
|
const PERIOD = { period_type: 'monthly', year: 2026, period: 1 }
|
|
|
|
describe('gnubok_vat_close_check: declaration completeness', () => {
|
|
it('includes a null-rate 3011 with matching domestic VAT evidence (#1289)', async () => {
|
|
const result = await computeVatCloseCheck(
|
|
PERIOD,
|
|
'company-1',
|
|
mockSupabase(
|
|
[
|
|
{ entry: 'e1', account_number: '3011', credit_amount: 9725 },
|
|
{ entry: 'e1', account_number: '2611', credit_amount: 2431.25 },
|
|
{ entry: 'e1', account_number: '1510', debit_amount: 12156.25 },
|
|
],
|
|
[{
|
|
account_number: '3011',
|
|
account_name: 'Försäljning tjänster inom Sverige, 25 % moms',
|
|
default_vat_rate: null,
|
|
}],
|
|
),
|
|
)
|
|
|
|
expect(result.rutor.ruta05).toBe(9725)
|
|
expect(result.rutor.ruta10).toBe(2431.25)
|
|
expect(result.declaration_checks.map((finding) => finding.code))
|
|
.not.toContain('OUTPUT_VAT_WITHOUT_SALES_BASE')
|
|
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
|
|
// empty while ruta 30 carries 1 250 kr. This is FK004.
|
|
const result = await computeVatCloseCheck(
|
|
PERIOD,
|
|
'company-1',
|
|
mockSupabase([
|
|
{ entry: 'e1', account_number: '3001', credit_amount: 10000 },
|
|
{ entry: 'e1', account_number: '2611', credit_amount: 2500 },
|
|
{ entry: 'e1', account_number: '1510', debit_amount: 12500 },
|
|
{ entry: 'e2', account_number: '6540', debit_amount: 5000 },
|
|
{ entry: 'e2', account_number: '2440', credit_amount: 5000 },
|
|
{ entry: 'e2', account_number: '2614', credit_amount: 1250 },
|
|
{ entry: 'e2', account_number: '2645', debit_amount: 1250 },
|
|
]),
|
|
)
|
|
|
|
expect(result.ready_to_close).toBe(false)
|
|
expect(result.summary).not.toMatch(/Klart för stängning/)
|
|
expect(result.summary).toMatch(/ofullständigt/)
|
|
|
|
const codes = result.declaration_checks.map((c) => c.code)
|
|
expect(codes).toContain('RC_BASIS_MISSING')
|
|
const finding = result.declaration_checks.find((c) => c.code === 'RC_BASIS_MISSING')!
|
|
expect(finding.status).toBe('ERROR')
|
|
expect(finding.message).toMatch(/FK004/)
|
|
|
|
const blocker = result.blockers.find((b) => b.check_code === 'RC_BASIS_MISSING')!
|
|
expect(blocker.kind).toBe('reverse_charge_input_missing')
|
|
expect(blocker.severity).toBe('high')
|
|
|
|
// The old mirror could not see this at all: ruta 48 carries the 2645 leg.
|
|
expect(result.rutor.ruta48).toBe(1250)
|
|
})
|
|
|
|
it('one ordinary domestic receipt no longer suppresses the reverse-charge check', async () => {
|
|
// The exact unreachability bug: 2641 from a normal receipt puts 200 kr in
|
|
// ruta 48, so `ruta48 === 0` was false and the whole period went unchecked
|
|
// even though the reverse charge is booked on one side only.
|
|
const result = await computeVatCloseCheck(
|
|
PERIOD,
|
|
'company-1',
|
|
mockSupabase([
|
|
{ entry: 'e1', account_number: '6210', debit_amount: 800 },
|
|
{ entry: 'e1', account_number: '2641', debit_amount: 200 },
|
|
{ entry: 'e1', account_number: '1930', credit_amount: 1000 },
|
|
{ entry: 'e2', account_number: '6540', debit_amount: 5000 },
|
|
{ entry: 'e2', account_number: '2440', credit_amount: 5000 },
|
|
{ entry: 'e2', account_number: '2614', credit_amount: 1250 },
|
|
]),
|
|
)
|
|
|
|
expect(result.rutor.ruta48).toBe(200)
|
|
expect(result.rutor.ruta48).not.toBe(0)
|
|
expect(result.ready_to_close).toBe(false)
|
|
|
|
const codes = result.declaration_checks.map((c) => c.code)
|
|
expect(codes).toContain('RC_BASIS_MISSING')
|
|
expect(codes).toContain('RC_INPUT_VAT_MISMATCH')
|
|
|
|
// The input-VAT mismatch stays advisory (partial deduction can explain a
|
|
// shortfall); the missing basbelopp is what blocks.
|
|
const mismatch = result.blockers.find((b) => b.check_code === 'RC_INPUT_VAT_MISMATCH')!
|
|
expect(mismatch.severity).toBe('medium')
|
|
expect(
|
|
result.blockers.some((b) => b.check_code === 'RC_BASIS_MISSING' && b.severity === 'high'),
|
|
).toBe(true)
|
|
})
|
|
|
|
// The check the shared core sharpened, now reachable from MCP: rutor 30-32 are
|
|
// compared against the reverse-charge INPUT accounts (2645/2647), not against
|
|
// the ruta 48 aggregate that ordinary debiterad ingående moms inflates.
|
|
it('sees a completely missing beräknad ingående moms behind a larger ruta 48', async () => {
|
|
const result = await computeVatCloseCheck(
|
|
PERIOD,
|
|
'company-1',
|
|
mockSupabase([
|
|
// EU services purchase, correctly booked on the underlag side: basis on
|
|
// 4535 (ruta 21 = 200 000) and fiktiv utgående moms on 2614 (ruta 30 =
|
|
// 50 000), so there is no FK004 finding to hide behind. The beräknad
|
|
// ingående moms on 2645 was never booked.
|
|
{ entry: 'e1', account_number: '4535', debit_amount: 200000 },
|
|
{ entry: 'e1', account_number: '2440', credit_amount: 200000 },
|
|
{ entry: 'e1', account_number: '2614', credit_amount: 50000 },
|
|
// Ordinary domestic purchases: 60 000 kr of debiterad ingående moms.
|
|
// This is the mask: ruta 48 (60 000) stays ABOVE the RC output (50 000).
|
|
{ entry: 'e2', account_number: '5410', debit_amount: 240000 },
|
|
{ entry: 'e2', account_number: '2641', debit_amount: 60000 },
|
|
{ entry: 'e2', account_number: '1930', credit_amount: 300000 },
|
|
]),
|
|
)
|
|
|
|
expect(result.rutor.ruta30).toBe(50000)
|
|
expect(result.rutor.ruta48).toBe(60000)
|
|
// Precisely the state the aggregate comparison called fine.
|
|
expect(result.rutor.ruta48).toBeGreaterThan(result.rutor.ruta30)
|
|
|
|
const mismatch = result.declaration_checks.find((c) => c.code === 'RC_INPUT_VAT_MISMATCH')!
|
|
expect(mismatch).toBeDefined()
|
|
// Still a WARNING, deliberately: limited avdragsrätt (blandad verksamhet,
|
|
// ML 13 kap 18/24-25 §§) makes a shortfall correct for some filers.
|
|
expect(mismatch.status).toBe('WARNING')
|
|
expect(mismatch.message).toMatch(/2645/)
|
|
// \s, not a literal space: sv-SE groups thousands with a no-break space.
|
|
expect(mismatch.message).toMatch(/50\s000 kr saknas/)
|
|
expect(result.declaration_checks.map((c) => c.code)).not.toContain('RC_BASIS_MISSING')
|
|
|
|
// A warning is not a blocker: ruta 48 is non-zero, so a partial-deduction
|
|
// story exists and the noInputVatAtAll escalation must stay out of it.
|
|
const blocker = result.blockers.find((b) => b.check_code === 'RC_INPUT_VAT_MISMATCH')!
|
|
expect(blocker.kind).toBe('reverse_charge_input_missing')
|
|
expect(blocker.severity).toBe('medium')
|
|
})
|
|
|
|
it('still blocks import output VAT with no deductible input at all (ruta 60 without ruta 48)', async () => {
|
|
// The one case the shared checks do not compare against ruta 48: keep the
|
|
// local escalation so removing the old mirror loses no coverage.
|
|
const result = await computeVatCloseCheck(
|
|
PERIOD,
|
|
'company-1',
|
|
mockSupabase([
|
|
{ entry: 'e1', account_number: '4545', debit_amount: 10000 },
|
|
{ entry: 'e1', account_number: '2440', credit_amount: 10000 },
|
|
{ entry: 'e1', account_number: '2615', credit_amount: 2500 },
|
|
]),
|
|
)
|
|
|
|
expect(result.ready_to_close).toBe(false)
|
|
const blocker = result.blockers.find((b) => b.kind === 'reverse_charge_input_missing')!
|
|
expect(blocker.severity).toBe('high')
|
|
expect(blocker.message).toMatch(/ruta 60\/61\/62/)
|
|
})
|
|
|
|
it('a clean period still passes: reverse charge booked with basbelopp AND both VAT legs', async () => {
|
|
const result = await computeVatCloseCheck(
|
|
PERIOD,
|
|
'company-1',
|
|
mockSupabase([
|
|
{ entry: 'e1', account_number: '3001', credit_amount: 10000 },
|
|
{ entry: 'e1', account_number: '2611', credit_amount: 2500 },
|
|
{ entry: 'e1', account_number: '1510', debit_amount: 12500 },
|
|
// EU services purchase, correctly booked: basis on 4535 (ruta 21),
|
|
// fiktiv utgående på 2614 (ruta 30), beräknad ingående på 2645 (ruta 48).
|
|
{ entry: 'e2', account_number: '4535', debit_amount: 5000 },
|
|
{ entry: 'e2', account_number: '2440', credit_amount: 5000 },
|
|
{ entry: 'e2', account_number: '2614', credit_amount: 1250 },
|
|
{ entry: 'e2', account_number: '2645', debit_amount: 1250 },
|
|
]),
|
|
)
|
|
|
|
expect(result.declaration_checks).toEqual([])
|
|
expect(result.blockers.filter((b) => b.severity === 'high')).toEqual([])
|
|
expect(result.ready_to_close).toBe(true)
|
|
expect(result.summary).toMatch(/Klart för stängning/)
|
|
expect(result.rutor.ruta49).toBe(2500)
|
|
})
|
|
})
|