/** * pg-real test for get_vat_declaration_totals. * * The RPC backs the momsdeklaration and the settlement proposal: one SQL * pass returns per-account totals, settlement-shaped entries, and per- * source_type entry counts. The settlement-shape exclusion (#984) used to * live in JS (lib/reports/vat-declaration.ts) with mocked-client unit * tests; the behavior now lives here, against real Postgres: * * - tagged vat_settlement entries never reach the totals; * - an untagged entry touching BOTH a declaration account and 2650/1650 * is "shaped": excluded from totals, surfaced in * settlement_shaped_entries; * - opening-balance entries are exempt from shaping (carried-in 26xx * balances are unsettled VAT); * - a plain 2650 payment (no declaration account) is NOT shaped; * - source_type_counts covers all posted/reversed entries in the period, * including tagged settlements. */ import { describe, it, expect } from 'vitest' import { randomUUID } from 'node:crypto' import { getPool } from './setup' import { insertAuthUser, insertCompany, insertFiscalPeriod } from './fixtures' // Mirrors the TS call site (lib/reports/vat-declaration.ts): a small // representative slice of ACCOUNT_RUTA is enough since the full list is a // parameter, not baked into the SQL. const RUTA_ACCOUNTS = ['2611', '2621', '2641', '2645', '3001'] const NET_ACCOUNTS = ['2650', '1650'] const ALL_ACCOUNTS = [...RUTA_ACCOUNTS, ...NET_ACCOUNTS] interface RpcPayload { totals: Array<{ account_number: string; debit: number; credit: number }> settlement_shaped_entries: Array<{ id: string status: string entry_date: string source_type: string | null voucher_series: string | null voucher_number: number | null }> source_type_counts: Record } async function callRpc( companyId: string, start = '2026-01-01', end = '2026-12-31', ): Promise { const { rows } = await getPool().query( `SELECT public.get_vat_declaration_totals($1, $2, $3, $4, $5, $6) AS payload`, [companyId, start, end, ALL_ACCOUNTS, RUTA_ACCOUNTS, NET_ACCOUNTS], ) return rows[0].payload as RpcPayload } function totalsByAccount(payload: RpcPayload) { return new Map(payload.totals.map((t) => [t.account_number, t])) } async function insertJournalEntry(params: { userId: string companyId: string fiscalPeriodId: string voucherNumber: number status?: 'draft' | 'posted' | 'reversed' sourceType?: string entryDate?: string lines: Array<{ account: string; debit: number; credit: number }> }): Promise { const id = randomUUID() // Insert directly, bypassing commit_journal_entry's voucher sequencing — // fine for a read-side RPC that only aggregates line/account references. await getPool().query( `INSERT INTO public.journal_entries (id, user_id, company_id, fiscal_period_id, voucher_number, voucher_series, entry_date, description, source_type, status) VALUES ($1, $2, $3, $4, $5, 'A', $6, 'VAT RPC test', $7, $8)`, [ id, params.userId, params.companyId, params.fiscalPeriodId, params.voucherNumber, params.entryDate ?? '2026-03-15', params.sourceType ?? 'manual', params.status ?? 'posted', ], ) for (const line of params.lines) { await getPool().query( `INSERT INTO public.journal_entry_lines (journal_entry_id, account_number, debit_amount, credit_amount) VALUES ($1, $2, $3, $4)`, [id, line.account, line.debit, line.credit], ) } return id } async function seedCompany() { const userId = await insertAuthUser() const companyId = await insertCompany({ createdBy: userId }) const fiscalPeriodId = await insertFiscalPeriod({ userId, companyId }) return { userId, companyId, fiscalPeriodId } } describe('get_vat_declaration_totals RPC', () => { it('aggregates per-account debit/credit sums for posted entries', async () => { const ctx = await seedCompany() await insertJournalEntry({ ...ctx, voucherNumber: 1, sourceType: 'invoice_created', lines: [ { account: '3001', debit: 0, credit: 10000 }, { account: '2611', debit: 0, credit: 2500 }, { account: '1930', debit: 12500, credit: 0 }, ], }) await insertJournalEntry({ ...ctx, voucherNumber: 2, sourceType: 'bank_transaction', lines: [ { account: '2641', debit: 250, credit: 0 }, { account: '1930', debit: 0, credit: 1250 }, { account: '6110', debit: 1000, credit: 0 }, ], }) const payload = await callRpc(ctx.companyId) const totals = totalsByAccount(payload) expect(totals.get('3001')).toMatchObject({ debit: 0, credit: 10000 }) expect(totals.get('2611')).toMatchObject({ debit: 0, credit: 2500 }) expect(totals.get('2641')).toMatchObject({ debit: 250, credit: 0 }) // Non-VAT accounts (1930, 6110) never appear: they are outside p_accounts. expect(totals.has('1930')).toBe(false) expect(totals.has('6110')).toBe(false) expect(payload.settlement_shaped_entries).toEqual([]) expect(payload.source_type_counts).toEqual({ invoice_created: 1, bank_transaction: 1, }) }) it('excludes draft entries and entries outside the period', async () => { const ctx = await seedCompany() await insertJournalEntry({ ...ctx, voucherNumber: 1, status: 'draft', lines: [{ account: '2611', debit: 0, credit: 999 }], }) await insertJournalEntry({ ...ctx, voucherNumber: 2, entryDate: '2025-12-31', lines: [{ account: '2611', debit: 0, credit: 777 }], }) await insertJournalEntry({ ...ctx, voucherNumber: 3, entryDate: '2026-06-30', lines: [{ account: '2611', debit: 0, credit: 100 }], }) const payload = await callRpc(ctx.companyId, '2026-01-01', '2026-12-31') expect(totalsByAccount(payload).get('2611')).toMatchObject({ debit: 0, credit: 100 }) expect(payload.source_type_counts).toEqual({ manual: 1 }) }) it('excludes tagged vat_settlement entries from totals without shaping them', async () => { const ctx = await seedCompany() await insertJournalEntry({ ...ctx, voucherNumber: 1, sourceType: 'invoice_created', lines: [{ account: '2611', debit: 0, credit: 2500 }], }) // The app's own settlement flow: tagged, filtered by source_type alone. await insertJournalEntry({ ...ctx, voucherNumber: 2, sourceType: 'vat_settlement', lines: [ { account: '2611', debit: 2500, credit: 0 }, { account: '2650', debit: 0, credit: 2500 }, ], }) const payload = await callRpc(ctx.companyId) expect(totalsByAccount(payload).get('2611')).toMatchObject({ debit: 0, credit: 2500 }) expect(payload.settlement_shaped_entries).toEqual([]) // The metadata scan always counted tagged settlements: preserved. expect(payload.source_type_counts).toEqual({ invoice_created: 1, vat_settlement: 1, }) }) it('shapes an untagged manual momsomföring: excluded from totals, surfaced for gating (#984)', async () => { const ctx = await seedCompany() await insertJournalEntry({ ...ctx, voucherNumber: 1, sourceType: 'invoice_created', lines: [ { account: '2611', debit: 0, credit: 2500 }, { account: '2641', debit: 1000, credit: 0 }, { account: '3001', debit: 0, credit: 10000 }, ], }) // Manual settlement clearing the period to 2650, booked without the // vat_settlement source_type (e.g. before #980 shipped). const shapedId = await insertJournalEntry({ ...ctx, voucherNumber: 2, sourceType: 'manual', entryDate: '2026-03-31', lines: [ { account: '2611', debit: 2500, credit: 0 }, { account: '2641', debit: 0, credit: 1000 }, { account: '2650', debit: 0, credit: 1500 }, ], }) const payload = await callRpc(ctx.companyId) const totals = totalsByAccount(payload) // Without the shape exclusion every ruta reads 0 after the settlement. expect(totals.get('2611')).toMatchObject({ debit: 0, credit: 2500 }) expect(totals.get('2641')).toMatchObject({ debit: 1000, credit: 0 }) expect(totals.get('3001')).toMatchObject({ debit: 0, credit: 10000 }) // The shaped entry's 2650 line is excluded along with the rest of it. expect(totals.has('2650')).toBe(false) expect(payload.settlement_shaped_entries).toHaveLength(1) expect(payload.settlement_shaped_entries[0]).toMatchObject({ id: shapedId, status: 'posted', source_type: 'manual', voucher_series: 'A', voucher_number: 2, }) }) it('shapes a storno of a settlement so annullera never re-inflates the rutor', async () => { const ctx = await seedCompany() await insertJournalEntry({ ...ctx, voucherNumber: 1, sourceType: 'invoice_created', lines: [{ account: '2611', debit: 0, credit: 100 }], }) // The tagged settlement itself is filtered by source_type; its storno // reversal is untagged and would otherwise re-credit 2611. await insertJournalEntry({ ...ctx, voucherNumber: 2, sourceType: 'storno', lines: [ { account: '2611', debit: 0, credit: 100 }, { account: '2650', debit: 100, credit: 0 }, ], }) const payload = await callRpc(ctx.companyId) expect(totalsByAccount(payload).get('2611')).toMatchObject({ debit: 0, credit: 100 }) expect(payload.settlement_shaped_entries).toHaveLength(1) expect(payload.settlement_shaped_entries[0]).toMatchObject({ source_type: 'storno' }) }) it('keeps opening-balance entries: carried-in 26xx balances are unsettled VAT', async () => { const ctx = await seedCompany() // Migrating company: undeclared input VAT and a prior VAT debt carried // in through the same opening-balance entry. Touches both a declaration // account and 2650, but the shape rule exempts opening balances. await insertJournalEntry({ ...ctx, voucherNumber: 1, sourceType: 'opening_balance', entryDate: '2026-01-01', lines: [ { account: '2641', debit: 500, credit: 0 }, { account: '2650', debit: 0, credit: 300 }, { account: '1930', debit: 0, credit: 200 }, ], }) const payload = await callRpc(ctx.companyId) const totals = totalsByAccount(payload) expect(totals.get('2641')).toMatchObject({ debit: 500, credit: 0 }) expect(totals.get('2650')).toMatchObject({ debit: 0, credit: 300 }) expect(payload.settlement_shaped_entries).toEqual([]) }) it('does not shape a plain VAT payment on 2650 (no declaration account touched)', async () => { const ctx = await seedCompany() await insertJournalEntry({ ...ctx, voucherNumber: 1, sourceType: 'invoice_created', lines: [{ account: '2611', debit: 0, credit: 100 }], }) // Paying last period's VAT debt: 2650 against the bank account. await insertJournalEntry({ ...ctx, voucherNumber: 2, sourceType: 'bank_transaction', lines: [ { account: '2650', debit: 75, credit: 0 }, { account: '1930', debit: 0, credit: 75 }, ], }) const payload = await callRpc(ctx.companyId) const totals = totalsByAccount(payload) expect(totals.get('2611')).toMatchObject({ debit: 0, credit: 100 }) expect(totals.get('2650')).toMatchObject({ debit: 75, credit: 0 }) expect(payload.settlement_shaped_entries).toEqual([]) }) it('scopes everything to the requested company', async () => { const a = await seedCompany() const b = await seedCompany() await insertJournalEntry({ ...a, voucherNumber: 1, lines: [{ account: '2611', debit: 0, credit: 100 }], }) await insertJournalEntry({ ...b, voucherNumber: 1, lines: [{ account: '2611', debit: 0, credit: 999 }], }) const payload = await callRpc(a.companyId) expect(totalsByAccount(payload).get('2611')).toMatchObject({ debit: 0, credit: 100 }) expect(payload.source_type_counts).toEqual({ manual: 1 }) }) it('returns empty sections for a company without entries', async () => { const ctx = await seedCompany() const payload = await callRpc(ctx.companyId) expect(payload.totals).toEqual([]) expect(payload.settlement_shaped_entries).toEqual([]) expect(payload.source_type_counts).toEqual({}) }) })