fix(vat): make the ruta drill-down reconcile with the figure it explains (#2016)
* fix(vat): make the ruta drill-down reconcile with the figure it explains get_vat_declaration_totals drops four classes of entry before summing: posted closing entries, source_type 'vat_settlement', the two kontantmetod year-end reversals, and anything shaped like a momsredovisning. The drill-down behind each ruta filtered on company, status and date only. So expanding a ruta listed verifikat that are not in the number it claims to explain, and the panel shows no total that would reveal the mismatch. On production, 322 posted/reversed entries carrying 26xx lines across 214 companies sit in those excluded classes. A momsdeklaration is räkenskapsinformation under BFL 5 kap. and this drill-down is what a consultant uses to substantiate a filed figure, so the two have to agree exactly. The exclusion CTEs are lifted verbatim from the figure rather than re-derived, because any divergence reintroduces exactly this bug. The new pg test asserts the equality for the whole account set at once, so editing one function and not the other fails CI instead of silently misreporting. opening_balance entries are deliberately kept: the figure exempts them from its `shaped` set, which leaves their lines in the totals, so excluding them here would break the equality in the other direction. That has its own test. Verified the test catches the defect by reinstalling the old function body and watching it fail with the real numbers (2611: drill-down 250/240 vs figure 0/200), then restoring. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(vat): update the existing drill-down pg test to the new signature get_vat_ruta_source_lines gained p_ruta_accounts / p_net_accounts, and production-error-regressions.pg.test.ts still called the old 9-argument form, so pg-real failed with 42883 "function does not exist". I had grepped app/, lib/ and extensions/ for callers and not tests/. Neither fixture in that paging test is settlement-shaped, so paging behaviour is unchanged; the equality itself is covered by the new reconcile test. Also documents, in the tool-pg reset script, that its blanket grant to `anon` (which PostgREST requires) makes that database invalid for the pg-real suite: ~29 of those files assert least privilege and fail there even on unmodified main. That cost a confusing local run. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
Jakob Wennberg
parent
4e1eb3d662
commit
338ac4e913
@@ -128,24 +128,31 @@ describe('production error regressions', () => {
|
||||
],
|
||||
})
|
||||
|
||||
// p_ruta_accounts / p_net_accounts are the settlement-shape detectors the
|
||||
// drill-down gained in 20260828172003 so it drops the same entries as the
|
||||
// filed figure. Neither fixture here is settlement-shaped, so paging is
|
||||
// unaffected; the equality itself is covered by
|
||||
// tests/pg/vat-ruta-drilldown-reconcile.pg.test.ts.
|
||||
const first = await getPool().query(
|
||||
`SELECT * FROM public.get_vat_ruta_source_lines(
|
||||
$1, $2, $3, $4, NULL, NULL, NULL, NULL, 1
|
||||
$1, $2, $3, $4, $5, $6, NULL, NULL, NULL, NULL, 1
|
||||
)`,
|
||||
[ctx.companyId, '2026-03-01', '2026-03-31', ['2611']],
|
||||
[ctx.companyId, '2026-03-01', '2026-03-31', ['2611'], ['2611'], ['2650', '1650']],
|
||||
)
|
||||
expect(first.rows).toHaveLength(1)
|
||||
expect(first.rows[0].voucher_number).toBe(1)
|
||||
|
||||
const second = await getPool().query(
|
||||
`SELECT * FROM public.get_vat_ruta_source_lines(
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, 1
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 1
|
||||
)`,
|
||||
[
|
||||
ctx.companyId,
|
||||
'2026-03-01',
|
||||
'2026-03-31',
|
||||
['2611'],
|
||||
['2611'],
|
||||
['2650', '1650'],
|
||||
first.rows[0].entry_date,
|
||||
first.rows[0].voucher_number,
|
||||
first.rows[0].journal_entry_id,
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* The VAT ruta drill-down must return exactly the lines the filed figure sums.
|
||||
*
|
||||
* `get_vat_declaration_totals` drops four classes of entry before summing:
|
||||
* posted closing entries, source_type 'vat_settlement', the two kontantmetod
|
||||
* year-end reversals, and any entry shaped like a momsredovisning (a line on a
|
||||
* ruta account AND a line on 2650/1650). `get_vat_ruta_source_lines` filtered
|
||||
* on company, status and date only, so expanding a ruta listed verifikat that
|
||||
* were not in the number it claims to explain. 322 posted/reversed entries
|
||||
* across 214 companies sat in those classes on production (2026-08-28).
|
||||
*
|
||||
* A momsdeklaration is räkenskapsinformation (BFL 5 kap.) and this drill-down
|
||||
* is what substantiates a filed figure, so the headline test here is a single
|
||||
* equality: for every account, the drill-down's sum equals the figure's total.
|
||||
* It holds for the whole account set at once, so a future edit to one function
|
||||
* and not the other fails here rather than silently misreporting.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll } from 'vitest'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { getPool } from './setup'
|
||||
import {
|
||||
insertAuthUser,
|
||||
insertCompany,
|
||||
insertFiscalPeriod,
|
||||
insertPostedJournalEntry,
|
||||
} from './fixtures'
|
||||
|
||||
// Mirrors the TS call site: a representative slice of ACCOUNT_RUTA. The full
|
||||
// list is a parameter, never baked into the SQL.
|
||||
const RUTA_ACCOUNTS = ['2611', '2641', '2645', '3001']
|
||||
const NET_ACCOUNTS = ['2650', '1650']
|
||||
const ALL_ACCOUNTS = [...RUTA_ACCOUNTS, ...NET_ACCOUNTS]
|
||||
const BALANCING_ACCOUNT = '2999'
|
||||
|
||||
interface DrillLine {
|
||||
line_id: string
|
||||
journal_entry_id: string
|
||||
voucher_number: number
|
||||
entry_date: string
|
||||
description: string
|
||||
debit_amount: string | number
|
||||
credit_amount: string | number
|
||||
}
|
||||
|
||||
async function figureTotals(companyId: string) {
|
||||
const { rows } = await getPool().query(
|
||||
`SELECT public.get_vat_declaration_totals($1,$2,$3,$4,$5,$6) AS payload`,
|
||||
[companyId, '2026-01-01', '2026-12-31', ALL_ACCOUNTS, RUTA_ACCOUNTS, NET_ACCOUNTS],
|
||||
)
|
||||
const payload = rows[0].payload as {
|
||||
totals: Array<{ account_number: string; debit: number; credit: number }>
|
||||
}
|
||||
return new Map(payload.totals.map((t) => [t.account_number, t]))
|
||||
}
|
||||
|
||||
async function drillDown(companyId: string, accounts: string[]): Promise<DrillLine[]> {
|
||||
const { rows } = await getPool().query(
|
||||
`SELECT * FROM public.get_vat_ruta_source_lines(
|
||||
$1,$2,$3,$4,$5,$6, NULL, NULL, NULL, NULL, 501)`,
|
||||
[companyId, '2026-01-01', '2026-12-31', accounts, RUTA_ACCOUNTS, NET_ACCOUNTS],
|
||||
)
|
||||
return rows as DrillLine[]
|
||||
}
|
||||
|
||||
function sumOf(lines: DrillLine[]) {
|
||||
return lines.reduce(
|
||||
(acc, l) => ({
|
||||
debit: acc.debit + Number(l.debit_amount ?? 0),
|
||||
credit: acc.credit + Number(l.credit_amount ?? 0),
|
||||
}),
|
||||
{ debit: 0, credit: 0 },
|
||||
)
|
||||
}
|
||||
|
||||
async function insertEntry(params: {
|
||||
userId: string
|
||||
companyId: string
|
||||
fiscalPeriodId: string
|
||||
voucherNumber: number
|
||||
sourceType?: string
|
||||
description?: string
|
||||
lines: Array<{ account: string; debit: number; credit: number }>
|
||||
}): Promise<string> {
|
||||
return insertPostedJournalEntry({
|
||||
userId: params.userId,
|
||||
companyId: params.companyId,
|
||||
fiscalPeriodId: params.fiscalPeriodId,
|
||||
voucherNumber: params.voucherNumber,
|
||||
entryDate: '2026-03-15',
|
||||
description: params.description ?? 'drilldown test',
|
||||
sourceType: params.sourceType ?? 'manual',
|
||||
lines: params.lines.map((l) => ({
|
||||
accountNumber: l.account,
|
||||
debitAmount: l.debit,
|
||||
creditAmount: l.credit,
|
||||
})),
|
||||
})
|
||||
}
|
||||
|
||||
describe('VAT ruta drill-down reconciles with the declaration figure', () => {
|
||||
let userId: string
|
||||
let companyId: string
|
||||
let fiscalPeriodId: string
|
||||
let plainEntryId: string
|
||||
|
||||
beforeAll(async () => {
|
||||
userId = await insertAuthUser()
|
||||
companyId = await insertCompany({ createdBy: userId })
|
||||
fiscalPeriodId = await insertFiscalPeriod({ userId, companyId })
|
||||
|
||||
// 1. A plain sale. Must appear in BOTH the figure and the drill-down.
|
||||
plainEntryId = await insertEntry({
|
||||
userId, companyId, fiscalPeriodId, voucherNumber: 1,
|
||||
description: 'Vanlig försäljning',
|
||||
lines: [
|
||||
{ account: '3001', debit: 0, credit: 800 },
|
||||
{ account: '2611', debit: 0, credit: 200 },
|
||||
{ account: BALANCING_ACCOUNT, debit: 1000, credit: 0 },
|
||||
],
|
||||
})
|
||||
|
||||
// 2. A tagged momsredovisning. Excluded from the figure.
|
||||
await insertEntry({
|
||||
userId, companyId, fiscalPeriodId, voucherNumber: 2,
|
||||
sourceType: 'vat_settlement', description: 'Momsredovisning',
|
||||
lines: [
|
||||
{ account: '2611', debit: 200, credit: 0 },
|
||||
{ account: '2650', debit: 0, credit: 200 },
|
||||
],
|
||||
})
|
||||
|
||||
// 3. Settlement SHAPE without the tag: a ruta account plus 2650.
|
||||
await insertEntry({
|
||||
userId, companyId, fiscalPeriodId, voucherNumber: 3,
|
||||
sourceType: 'manual', description: 'Otaggad momsredovisning',
|
||||
lines: [
|
||||
{ account: '2641', debit: 0, credit: 125 },
|
||||
{ account: '2650', debit: 125, credit: 0 },
|
||||
],
|
||||
})
|
||||
|
||||
// 4. The kontantmetod year-end reversal, excluded by description.
|
||||
await insertEntry({
|
||||
userId, companyId, fiscalPeriodId, voucherNumber: 4,
|
||||
sourceType: 'year_end',
|
||||
description: 'Vändning kundfordringar bokslut (kontantmetoden)',
|
||||
lines: [
|
||||
{ account: '2611', debit: 50, credit: 0 },
|
||||
{ account: BALANCING_ACCOUNT, debit: 0, credit: 50 },
|
||||
],
|
||||
})
|
||||
|
||||
// 5. An opening balance. Deliberately NOT excluded: the figure exempts
|
||||
// opening_balance from `shaped`, which keeps its lines in the totals.
|
||||
// Dropping it here would break the equality in the other direction.
|
||||
await insertEntry({
|
||||
userId, companyId, fiscalPeriodId, voucherNumber: 5,
|
||||
sourceType: 'opening_balance', description: 'Ingående balans',
|
||||
lines: [
|
||||
{ account: '2641', debit: 75, credit: 0 },
|
||||
{ account: BALANCING_ACCOUNT, debit: 0, credit: 75 },
|
||||
],
|
||||
})
|
||||
|
||||
// 6. A posted closing entry, excluded once the period points at it.
|
||||
const closingId = await insertEntry({
|
||||
userId, companyId, fiscalPeriodId, voucherNumber: 6,
|
||||
sourceType: 'year_end', description: 'Bokslutsverifikat',
|
||||
lines: [
|
||||
{ account: '2611', debit: 0, credit: 40 },
|
||||
{ account: BALANCING_ACCOUNT, debit: 40, credit: 0 },
|
||||
],
|
||||
})
|
||||
await getPool().query(
|
||||
`UPDATE public.fiscal_periods SET closing_entry_id = $1 WHERE id = $2`,
|
||||
[closingId, fiscalPeriodId],
|
||||
)
|
||||
}, 60_000)
|
||||
|
||||
it('sums identically to the figure, for every account', async () => {
|
||||
// The headline assertion. Any entry the figure drops and the drill-down
|
||||
// keeps (or vice versa) shows up here as a mismatched account.
|
||||
const totals = await figureTotals(companyId)
|
||||
const mismatches: string[] = []
|
||||
|
||||
for (const account of ALL_ACCOUNTS) {
|
||||
const lines = await drillDown(companyId, [account])
|
||||
const drilled = sumOf(lines)
|
||||
const figure = totals.get(account) ?? { debit: 0, credit: 0 }
|
||||
if (
|
||||
Math.round(drilled.debit * 100) !== Math.round(Number(figure.debit) * 100) ||
|
||||
Math.round(drilled.credit * 100) !== Math.round(Number(figure.credit) * 100)
|
||||
) {
|
||||
mismatches.push(
|
||||
`${account}: drill-down ${drilled.debit}/${drilled.credit} vs figure ${figure.debit}/${figure.credit}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
expect(mismatches).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('still returns the ordinary sale it is meant to explain', async () => {
|
||||
// Guards the other direction: a filter that excluded everything would
|
||||
// satisfy the equality above trivially if the figure were empty too.
|
||||
const lines = await drillDown(companyId, ['2611'])
|
||||
expect(lines.length).toBeGreaterThan(0)
|
||||
expect(lines.map((l) => l.journal_entry_id)).toContain(plainEntryId)
|
||||
expect(sumOf(lines).credit).toBe(200)
|
||||
}, 30_000)
|
||||
|
||||
it('excludes tagged settlements, shaped settlements, kontantmetod reversals and closing entries', async () => {
|
||||
const descriptions = (await drillDown(companyId, ALL_ACCOUNTS)).map((l) => l.description)
|
||||
expect(descriptions).not.toContain('Momsredovisning')
|
||||
expect(descriptions).not.toContain('Otaggad momsredovisning')
|
||||
expect(descriptions).not.toContain('Vändning kundfordringar bokslut (kontantmetoden)')
|
||||
expect(descriptions).not.toContain('Bokslutsverifikat')
|
||||
}, 30_000)
|
||||
|
||||
it('keeps opening-balance lines, which the figure also counts', async () => {
|
||||
// The subtle one. `shaped` exempts opening_balance, so its 26xx lines stay
|
||||
// in the totals; excluding them from the drill-down would be a new bug in
|
||||
// the opposite direction.
|
||||
const lines = await drillDown(companyId, ['2641'])
|
||||
expect(lines.map((l) => l.description)).toContain('Ingående balans')
|
||||
}, 30_000)
|
||||
})
|
||||
Reference in New Issue
Block a user