Files
accounted/extensions/general/mcp-server/__tests__/vat-report-compute.test.ts
T
Jakob WennbergandClaude Fable 5 9d91ee0168 fix(vat): keep the momsrapport intact after a manual nollställning (#990)
* fix(vat): keep the momsrapport intact after a manual nollställning (#984)

The momsrapport already excludes settlement verifikat tagged with
source_type 'vat_settlement' (#983), but settlements booked any other
way still zeroed every ruta the moment they were posted: manual
momsomföringar booked before the tagged flow existed (the report in
issue #984), SIE-imported settlements, and storno reversals of a
settlement, which inverted the sign instead and silently doubled the
rutor after an annullera.

Exclude settlement entries by SHAPE as well: an entry with at least one
line on a declaration account (ACCOUNT_RUTA) and at least one on a
settlement net account (2650/1650) is bookkeeping about the declaration,
not VAT-bearing activity, in both the web projection
(fetchVatAccountTotals) and the MCP twin (computeVatReport).
Opening-balance entries are exempt: carried-in 26xx balances are
unsettled VAT that belongs in the next declaration.

Shape-detected POSTED settlements now also gate the "Skapa verifikat"
button through existing_entries, since the proposal re-clears the full
period and booking it on top of a manual settlement would corrupt the
26xx balances. Stornos never gate, so annullera still re-enables
booking.

Fixes #984

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(mcp): stable id order for the paginated VAT-report line fetch (CodeRabbit)

fetchAllRows pages with .range(); without a unique .order() rows can
shift across page boundaries once a period exceeds 1000 lines, skipping
or double-counting journal lines in the rutor. Same discipline as the
web projection (fetch-all.ts). Pre-existing, but the query was already
being touched for #984.

Also documents the shape-rule triage from the compliance-bot review in
DECISIONS.md: compound business-VAT-plus-2650 verifikat stay a known
accepted residual (a direction guard would break the storno exclusion),
and the opening-balance concern is false for app flows (SIE import and
set_opening_balances both tag source_type 'opening_balance').

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 21:37:30 +02:00

293 lines
12 KiB
TypeScript

/**
* Focused tests for computeVatReport: the shared VAT computation used by
* gnubok_get_vat_report and gnubok_vat_review_widget. These exist because the
* tools/call integration tests can't reach into the rutor math; this file
* mocks Supabase to feed synthetic journal entry lines and asserts the rutor
* shape, ruta48 inclusion of 2647, ruta49 formula, and the one-sided
* reverse-charge warning.
*/
import { describe, it, expect } from 'vitest'
import { computeVatReport, tools } from '../server'
interface MockLine {
account_number: string
debit_amount: number
credit_amount: number
journal_entry_id?: string
journal_entries?: { source_type: string | null } | Array<{ source_type: string | null }>
}
function mockSupabaseWithLines(lines: MockLine[]) {
// Build a chain that matches the call path in computeVatReport:
// .from('journal_entry_lines').select(...).eq(...).in(...).gte(...).lte(...).range(from, to)
// computeVatReport now paginates via fetchAllRows, so the terminal call is
// `.range(from, to)`. Returning all lines on the first page (always < the
// 1000-row PAGE_SIZE for these fixtures) makes fetchAllRows stop after one page.
const terminal = { data: lines, error: null }
const chain: Record<string, () => unknown> = {}
chain.range = () => terminal
chain.order = () => chain
chain.lte = () => chain
chain.gte = () => chain
chain.neq = () => chain
chain.in = () => chain
chain.eq = () => chain
chain.select = () => chain
chain.from = () => chain
return { from: chain.from } as never
}
describe('computeVatReport', () => {
it('aggregates 2611 → ruta10, 2641 → ruta48, includes 2647 → ruta48', async () => {
const lines: MockLine[] = [
// Domestic 25% sale: 1000 + 250 VAT
{ account_number: '3001', debit_amount: 0, credit_amount: 1000 },
{ account_number: '2611', debit_amount: 0, credit_amount: 250 },
// Domestic input VAT 25%
{ account_number: '2641', debit_amount: 100, credit_amount: 0 },
// Domestic reverse-charge input VAT (2647)
{ account_number: '2647', debit_amount: 50, credit_amount: 0 },
]
const result = await computeVatReport(
{ period_type: 'monthly', year: 2026, period: 1 },
'company-1',
mockSupabaseWithLines(lines)
)
expect(result.rutor.ruta05).toBe(1000)
expect(result.rutor.ruta10).toBe(250)
expect(result.rutor.ruta11).toBe(0)
expect(result.rutor.ruta12).toBe(0)
// Ruta 48 = 2641 (100) + 2647 (50) = 150
expect(result.rutor.ruta48).toBe(150)
// Ruta 49 = 250 - 150 = 100 (positive = pay)
expect(result.rutor.ruta49).toBe(100)
expect(result.summary).toContain('Moms att betala')
expect(result.warnings).toEqual([])
})
it('aggregates reverse-charge output VAT into ruta30/31/32 and the ruta49 formula', async () => {
const lines: MockLine[] = [
// Reverse-charge purchase 25%: both sides booked correctly
{ account_number: '2614', debit_amount: 0, credit_amount: 500 }, // ruta30
{ account_number: '2645', debit_amount: 500, credit_amount: 0 }, // matching input → ruta48
// Reverse-charge purchase 6%
{ account_number: '2634', debit_amount: 0, credit_amount: 30 }, // ruta32
{ account_number: '2645', debit_amount: 30, credit_amount: 0 },
]
const result = await computeVatReport(
{ period_type: 'quarterly', year: 2026, period: 1 },
'company-1',
mockSupabaseWithLines(lines)
)
expect(result.rutor.ruta30).toBe(500)
expect(result.rutor.ruta31).toBe(0)
expect(result.rutor.ruta32).toBe(30)
expect(result.rutor.ruta48).toBe(530) // 500 + 30 from 2645
// Ruta 49 = (10+11+12+30+31+32) - 48 = 0+0+0+500+0+30 - 530 = 0
expect(result.rutor.ruta49).toBe(0)
expect(result.warnings).toEqual([])
})
it('emits a one-sided-reverse-charge warning when 2614 is booked without 2645 OR 2647', async () => {
const lines: MockLine[] = [
// Output booked but matching input missing (the most common reverse-charge error)
{ account_number: '2614', debit_amount: 0, credit_amount: 500 },
// Neither 2645 nor 2647 present
]
const result = await computeVatReport(
{ period_type: 'monthly', year: 2026, period: 1 },
'company-1',
mockSupabaseWithLines(lines)
)
expect(result.rutor.ruta30).toBe(500)
expect(result.rutor.ruta48).toBe(0)
// Without the matching input, ruta49 is inflated by 500: the warning surfaces this.
expect(result.rutor.ruta49).toBe(500)
expect(result.warnings.length).toBe(1)
expect(result.warnings[0]).toMatch(/Omvänd betalningsskyldighet/)
// Both 2645 (EU) and 2647 (domestic) are mentioned so users know what to look for.
expect(result.warnings[0]).toMatch(/2645/)
expect(result.warnings[0]).toMatch(/2647/)
})
it('does NOT warn when reverse-charge output is balanced by 2647 (domestic, no 2645)', async () => {
// Domestic reverse charge per ML 16:13 (byggtjänster, electronics > 100k SEK): // matching input lands on 2647, not 2645. The earlier check missed this.
const lines: MockLine[] = [
{ account_number: '2614', debit_amount: 0, credit_amount: 500 }, // ruta30
{ account_number: '2647', debit_amount: 500, credit_amount: 0 }, // domestic input → ruta48
]
const result = await computeVatReport(
{ period_type: 'monthly', year: 2026, period: 1 },
'company-1',
mockSupabaseWithLines(lines)
)
expect(result.rutor.ruta30).toBe(500)
expect(result.rutor.ruta48).toBe(500)
expect(result.rutor.ruta49).toBe(0)
// No warning: the domestic mirror is correctly booked.
expect(result.warnings).toEqual([])
})
it('expanded ruta05 includes alternative BAS revenue accounts (3041/3051/3071) AND taxable EU goods (3106)', async () => {
const lines: MockLine[] = [
{ account_number: '3001', debit_amount: 0, credit_amount: 1000 },
{ account_number: '3041', debit_amount: 0, credit_amount: 500 }, // service 25%
{ account_number: '3051', debit_amount: 0, credit_amount: 300 }, // goods 25%
{ account_number: '3071', debit_amount: 0, credit_amount: 200 }, // other domestic
{ account_number: '3106', debit_amount: 0, credit_amount: 100 }, // momspliktig EU goods
]
const result = await computeVatReport(
{ period_type: 'yearly', year: 2026, period: 1 },
'company-1',
mockSupabaseWithLines(lines)
)
expect(result.rutor.ruta05).toBe(2100)
})
it('excludes 3004 (momsfri) from ruta05: exempt sales must NOT be in the taxable base', async () => {
const lines: MockLine[] = [
{ account_number: '3001', debit_amount: 0, credit_amount: 1000 },
{ account_number: '3004', debit_amount: 0, credit_amount: 500 }, // exempt: must be excluded
]
const result = await computeVatReport(
{ period_type: 'yearly', year: 2026, period: 1 },
'company-1',
mockSupabaseWithLines(lines)
)
expect(result.rutor.ruta05).toBe(1000)
})
it('aggregates 3108 → ruta35 (EU intra-community goods, momsfri leverans till EU)', async () => {
const lines: MockLine[] = [
// Domestic taxable sale
{ account_number: '3001', debit_amount: 0, credit_amount: 1000 },
// EU goods supply, momsfri (zero-rated to EU customer with valid VAT number)
{ account_number: '3108', debit_amount: 0, credit_amount: 5000 },
]
const result = await computeVatReport(
{ period_type: 'quarterly', year: 2026, period: 1 },
'company-1',
mockSupabaseWithLines(lines)
)
expect(result.rutor.ruta05).toBe(1000) // 3108 NOT in ruta05 (it's reported separately)
expect(result.rutor.ruta35).toBe(5000) // The new ruta we just added
expect(result.rutor.ruta39).toBe(0)
expect(result.rutor.ruta40).toBe(0)
})
it('excludes a manual settlement-shaped entry from the rutor (#984)', async () => {
const lines: MockLine[] = [
// Business activity on e1.
{ journal_entry_id: 'e1', account_number: '3001', debit_amount: 0, credit_amount: 1000, journal_entries: { source_type: 'invoice_created' } },
{ journal_entry_id: 'e1', account_number: '2611', debit_amount: 0, credit_amount: 250, journal_entries: { source_type: 'invoice_created' } },
// Manual momsomföring on e2 (no vat_settlement tag): would zero ruta10.
{ journal_entry_id: 'e2', account_number: '2611', debit_amount: 250, credit_amount: 0, journal_entries: { source_type: 'manual' } },
{ journal_entry_id: 'e2', account_number: '2650', debit_amount: 0, credit_amount: 250, journal_entries: { source_type: 'manual' } },
]
const result = await computeVatReport(
{ period_type: 'monthly', year: 2026, period: 1 },
'company-1',
mockSupabaseWithLines(lines)
)
expect(result.rutor.ruta05).toBe(1000)
expect(result.rutor.ruta10).toBe(250)
expect(result.rutor.ruta49).toBe(250)
})
it('settlement-shape exclusion handles the array-typed embed and exempts opening balances', async () => {
const lines: MockLine[] = [
// Storno of a settlement, with the embed in array form (the client's
// inferred shape): must be excluded from ruta10.
{ journal_entry_id: 'e3', account_number: '2611', debit_amount: 0, credit_amount: 100, journal_entries: [{ source_type: 'storno' }] },
{ journal_entry_id: 'e3', account_number: '2650', debit_amount: 100, credit_amount: 0, journal_entries: [{ source_type: 'storno' }] },
// Opening balance carrying undeclared input VAT and a prior VAT debt:
// stays IN the projection.
{ journal_entry_id: 'ib', account_number: '2641', debit_amount: 500, credit_amount: 0, journal_entries: [{ source_type: 'opening_balance' }] },
{ journal_entry_id: 'ib', account_number: '2650', debit_amount: 0, credit_amount: 300, journal_entries: [{ source_type: 'opening_balance' }] },
]
const result = await computeVatReport(
{ period_type: 'monthly', year: 2026, period: 1 },
'company-1',
mockSupabaseWithLines(lines)
)
expect(result.rutor.ruta10).toBe(0)
expect(result.rutor.ruta48).toBe(500)
expect(result.rutor.ruta49).toBe(-500)
})
it('refund summary string when ruta49 is negative', async () => {
const lines: MockLine[] = [
{ account_number: '2641', debit_amount: 100, credit_amount: 0 },
// No output VAT; pure refund position.
]
const result = await computeVatReport(
{ period_type: 'monthly', year: 2026, period: 1 },
'company-1',
mockSupabaseWithLines(lines)
)
expect(result.rutor.ruta49).toBe(-100)
expect(result.summary).toContain('Moms att få tillbaka')
})
it('exposes a rich outputSchema on both VAT tools (not bare {type:object})', () => {
for (const name of ['gnubok_get_vat_report', 'gnubok_vat_review_widget']) {
const tool = tools.find((t) => t.name === name)
expect(tool, `tool ${name}`).toBeDefined()
const schema = tool!.outputSchema as Record<string, unknown> | undefined
expect(schema).toBeDefined()
expect(schema!.type).toBe('object')
const props = schema!.properties as Record<string, unknown>
// The schema must declare period, period_label, rutor, summary, warnings.
expect(props).toHaveProperty('period')
expect(props).toHaveProperty('rutor')
expect(props).toHaveProperty('summary')
expect(props).toHaveProperty('warnings')
// rutor must declare each ruta the runtime returns.
const rutorProps = (props.rutor as { properties: Record<string, unknown> }).properties
for (const r of ['ruta05', 'ruta10', 'ruta11', 'ruta12', 'ruta30', 'ruta31', 'ruta32', 'ruta35', 'ruta39', 'ruta40', 'ruta48', 'ruta49']) {
expect(rutorProps, `tool ${name} rutor.${r}`).toHaveProperty(r)
}
}
})
it('rejects bad period_type / out-of-range period / out-of-range year', async () => {
const supabase = mockSupabaseWithLines([])
await expect(
computeVatReport({ period_type: 'weekly', year: 2026, period: 1 }, 'c', supabase)
).rejects.toThrow(/period_type/)
await expect(
computeVatReport({ period_type: 'monthly', year: 2026, period: 13 }, 'c', supabase)
).rejects.toThrow(/period must be 1-12/)
await expect(
computeVatReport({ period_type: 'quarterly', year: 2026, period: 5 }, 'c', supabase)
).rejects.toThrow(/period must be 1-4/)
await expect(
computeVatReport({ period_type: 'monthly', year: 1900, period: 1 }, 'c', supabase)
).rejects.toThrow(/year must be between/)
})
})