diff --git a/app/api/bookkeeping/journal-entries/__tests__/list-filters.pg.test.ts b/app/api/bookkeeping/journal-entries/__tests__/list-filters.pg.test.ts index fd3d2452..4a2dcd0c 100644 --- a/app/api/bookkeeping/journal-entries/__tests__/list-filters.pg.test.ts +++ b/app/api/bookkeeping/journal-entries/__tests__/list-filters.pg.test.ts @@ -1,7 +1,7 @@ import { randomUUID } from 'node:crypto' import { describe, expect, it } from 'vitest' import { getPool } from '@/tests/pg/setup' -import { seedCompany, insertBalancedLines } from '@/tests/pg/fixtures' +import { seedCompany } from '@/tests/pg/fixtures' // Covers the p_exclude_draft / p_collapse_corrections / p_series params on // list_fiscal_period_entries_with_related (migrations 20260621130500 + @@ -15,8 +15,10 @@ import { seedCompany, insertBalancedLines } from '@/tests/pg/fixtures' // total_count must stay in lockstep with the filtered set so pagination holds. describe('list_fiscal_period_entries_with_related: draft + correction filters', () => { // Insert a journal_entry directly so we can set the storno/correction link - // columns the fixtures don't expose. Posted/reversed rows get balanced lines - // so any deferred balance check is satisfied. + // columns the fixtures don't expose. Header and balanced lines go in ONE + // transaction: check_balance_on_posted_insert is deferred to commit, so a + // posted header committed alone (autocommit per query) is rejected with + // "has zero total" before the lines could ever land. async function insertEntry(p: { userId: string companyId: string @@ -32,27 +34,45 @@ describe('list_fiscal_period_entries_with_related: draft + correction filters', withLines?: boolean }): Promise { const id = randomUUID() - 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, reverses_id, correction_of_id) - VALUES ($1,$2,$3,$4,$5,$11,$12,$6,$7,$8,$9,$10)`, - [ - id, - p.userId, - p.companyId, - p.fiscalPeriodId, - p.voucherNumber, - p.description, - p.sourceType, - p.status, - p.reversesId ?? null, - p.correctionOfId ?? null, - p.voucherSeries ?? 'A', - p.entryDate ?? '2026-06-01', - ], - ) - if (p.withLines) await insertBalancedLines(id) + const client = await getPool().connect() + try { + await client.query('BEGIN') + await client.query( + `INSERT INTO public.journal_entries + (id, user_id, company_id, fiscal_period_id, voucher_number, voucher_series, + entry_date, description, source_type, status, reverses_id, correction_of_id) + VALUES ($1,$2,$3,$4,$5,$11,$12,$6,$7,$8,$9,$10)`, + [ + id, + p.userId, + p.companyId, + p.fiscalPeriodId, + p.voucherNumber, + p.description, + p.sourceType, + p.status, + p.reversesId ?? null, + p.correctionOfId ?? null, + p.voucherSeries ?? 'A', + p.entryDate ?? '2026-06-01', + ], + ) + if (p.withLines) { + await client.query( + `INSERT INTO public.journal_entry_lines + (journal_entry_id, account_number, debit_amount, credit_amount) + VALUES ($1, '1930', 1000, 0), + ($1, '3001', 0, 1000)`, + [id], + ) + } + await client.query('COMMIT') + } catch (err) { + await client.query('ROLLBACK').catch(() => {}) + throw err + } finally { + client.release() + } return id } diff --git a/app/api/sandbox/seed/route.ts b/app/api/sandbox/seed/route.ts index 26c470e4..c5c8d7c3 100644 --- a/app/api/sandbox/seed/route.ts +++ b/app/api/sandbox/seed/route.ts @@ -507,12 +507,25 @@ export async function POST(request: Request) { historyVoucherNumbers.push(historyVoucherNumber as number) } + // Inserted as draft and posted after the lines land: PostgREST autocommits + // each request, and check_balance_on_posted_insert (migration + // 20260806130000) rejects a posted header whose transaction carries no + // lines. The draft-to-posted UPDATE below fires check_balance_on_post + // against the finished verifikat instead. + // + // committed_at note: this route runs under the requester's authenticated + // client, and set_committed_at() (migration 20260806160000) preserves a + // preset committed_at only for trusted roles, so any backdated + // committed_at supplied here is overwritten with now() at posting. That + // is deliberate: an end-user role must never control the audit timestamp, + // and sandbox companies are disposable. const { data: insertedHistoryEntries, error: historyEntryError } = await supabase .from('journal_entries') .insert( ledgerHistory.entries.map((historyEntry, index) => ({ ...historyEntry, voucher_number: historyVoucherNumbers[index], + status: 'draft', })), ) .select('id, voucher_number') @@ -542,6 +555,13 @@ export async function POST(request: Request) { ) if (historyLinesError) throw historyLinesError + const { error: historyPostError } = await supabase + .from('journal_entries') + .update({ status: 'posted' }) + .in('id', historyEntryIds) + .eq('company_id', companyId) + if (historyPostError) throw historyPostError + // The history is the company's books from before it arrived in Accounted: // its kvitton live in the previous system's binder, not here. Left // unflagged, every one of these vouchers would land on Hem as "Verifikat @@ -577,7 +597,8 @@ export async function POST(request: Request) { description: 'Faktura F-2026001, Björk & Partner AB', source_type: 'invoice_created', source_id: invoiceMap['F-2026001'], - status: 'posted', + // Draft until the lines exist; see the ledger-history comment above. + status: 'draft', committed_at: toDateStr(thirtyDaysAgo), }) .select('id') @@ -603,7 +624,8 @@ export async function POST(request: Request) { description: 'Betalning faktura F-2026001, Björk & Partner AB', source_type: 'invoice_paid', source_id: invoiceMap['F-2026001'], - status: 'posted', + // Draft until the lines exist; see the ledger-history comment above. + status: 'draft', committed_at: toDateStr(fifteenDaysAgo), }) .select('id') @@ -675,6 +697,13 @@ export async function POST(request: Request) { if (jelError) throw jelError + const { error: invoicePostError } = await supabase + .from('journal_entries') + .update({ status: 'posted' }) + .in('id', [je1.id, je2.id]) + .eq('company_id', companyId) + if (invoicePostError) throw invoicePostError + // 11. Create transactions const { data: txRows, error: txError } = await supabase .from('transactions') @@ -1166,7 +1195,8 @@ export async function POST(request: Request) { const { data: insertedSalaryEntry, error: salaryEntryError } = await supabase .from('journal_entries') - .insert({ ...voucher.entry, voucher_number: salaryVoucherNumber }) + // Draft until the lines exist; see the ledger-history comment above. + .insert({ ...voucher.entry, voucher_number: salaryVoucherNumber, status: 'draft' }) .select('id') .single() if (salaryEntryError) throw salaryEntryError @@ -1185,6 +1215,13 @@ export async function POST(request: Request) { runEntryLinks[voucher.runColumn] = insertedSalaryEntry.id } + const { error: salaryPostError } = await supabase + .from('journal_entries') + .update({ status: 'posted' }) + .in('id', Object.values(runEntryLinks)) + .eq('company_id', companyId) + if (salaryPostError) throw salaryPostError + const { error: linkRunError } = await supabase .from('salary_runs') .update(runEntryLinks) diff --git a/lib/bookkeeping/__tests__/engine.pg.test.ts b/lib/bookkeeping/__tests__/engine.pg.test.ts index f8e107a2..8ee0ef0c 100644 --- a/lib/bookkeeping/__tests__/engine.pg.test.ts +++ b/lib/bookkeeping/__tests__/engine.pg.test.ts @@ -7,6 +7,84 @@ import { } from '@/tests/pg/fixtures' describe('engine.pg: triggers & RPCs that mocks cannot catch', () => { + it('rejects a directly inserted posted journal entry with no lines', async () => { + const { userId, companyId, fiscalPeriodId } = await seedCompany() + + await expect( + getPool().query( + `INSERT INTO public.journal_entries + (user_id, company_id, fiscal_period_id, voucher_number, voucher_series, + entry_date, description, source_type, status) + VALUES ($1, $2, $3, 1, 'A', '2026-06-01', 'Direct posted insert', 'manual', 'posted')`, + [userId, companyId, fiscalPeriodId], + ), + ).rejects.toThrow(/has zero total/i) + }) + + it('rejects an unbalanced directly inserted posted journal entry at constraint time', async () => { + const { userId, companyId, fiscalPeriodId } = await seedCompany() + const client = await getPool().connect() + + try { + await client.query('BEGIN') + const inserted = await client.query<{ id: string }>( + `INSERT INTO public.journal_entries + (user_id, company_id, fiscal_period_id, voucher_number, voucher_series, + entry_date, description, source_type, status) + VALUES ($1, $2, $3, 1, 'A', '2026-06-01', 'Direct posted insert', 'manual', 'posted') + RETURNING id`, + [userId, companyId, fiscalPeriodId], + ) + await client.query( + `INSERT INTO public.journal_entry_lines + (journal_entry_id, account_number, debit_amount, credit_amount) + VALUES ($1, '1930', 100, 0)`, + [inserted.rows[0]!.id], + ) + + await expect( + client.query('SET CONSTRAINTS check_balance_on_posted_insert IMMEDIATE'), + ).rejects.toThrow(/not balanced/i) + } finally { + await client.query('ROLLBACK').catch(() => {}) + client.release() + } + }) + + it('allows balanced lines to follow a posted header in the same transaction', async () => { + const { userId, companyId, fiscalPeriodId } = await seedCompany() + const client = await getPool().connect() + + try { + await client.query('BEGIN') + const inserted = await client.query<{ id: string }>( + `INSERT INTO public.journal_entries + (user_id, company_id, fiscal_period_id, voucher_number, voucher_series, + entry_date, description, source_type, status) + VALUES ($1, $2, $3, 1, 'A', '2026-06-01', 'Direct posted insert', 'manual', 'posted') + RETURNING id`, + [userId, companyId, fiscalPeriodId], + ) + await client.query( + `INSERT INTO public.journal_entry_lines + (journal_entry_id, account_number, debit_amount, credit_amount) + VALUES ($1, '1930', 100, 0), + ($1, '3001', 0, 100)`, + [inserted.rows[0]!.id], + ) + + await client.query('SET CONSTRAINTS check_balance_on_posted_insert IMMEDIATE') + const persisted = await client.query<{ status: string }>( + `SELECT status FROM public.journal_entries WHERE id = $1`, + [inserted.rows[0]!.id], + ) + expect(persisted.rows[0]!.status).toBe('posted') + } finally { + await client.query('ROLLBACK').catch(() => {}) + client.release() + } + }) + it('rejects INSERT into journal_entries when the fiscal period is closed', async () => { const { userId, companyId, fiscalPeriodId } = await seedCompany({ isClosed: true }) @@ -51,9 +129,9 @@ describe('engine.pg: triggers & RPCs that mocks cannot catch', () => { it('rejects UPDATE to a posted journal entry (committed immutability)', async () => { const { userId, companyId, fiscalPeriodId } = await seedCompany() - // Bypass commit_journal_entry by inserting directly as 'posted'. The - // immutability trigger fires on UPDATE, not INSERT, so this is legal - // setup on the superuser connection. + // Bypass commit_journal_entry with the direct-posted fixture. It inserts + // balanced lines in the same transaction so the deferred insert balance + // trigger accepts the setup before immutability is exercised below. const entryId = await insertDraftJournalEntry({ userId, companyId, diff --git a/lib/bookkeeping/__tests__/opening-balance-replacement.pg.test.ts b/lib/bookkeeping/__tests__/opening-balance-replacement.pg.test.ts index 39708b82..e3534b94 100644 --- a/lib/bookkeeping/__tests__/opening-balance-replacement.pg.test.ts +++ b/lib/bookkeeping/__tests__/opening-balance-replacement.pg.test.ts @@ -52,50 +52,60 @@ async function seedOpeningBalance(params: { expect(accountIds.get('2010')).toBeTruthy() const oldEntryId = randomUUID() - const voucher = await getPool().query<{ next_number: number }>( - `SELECT COALESCE(max(voucher_number), 0)::int + 1 AS next_number - FROM public.journal_entries - WHERE company_id = $1 - AND fiscal_period_id = $2 - AND voucher_series = 'A'`, - [params.companyId, params.fiscalPeriodId], - ) - 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', '2026-01-01', - 'Old opening balance', 'opening_balance', 'posted')`, - [ - oldEntryId, - params.userId, - params.companyId, - params.fiscalPeriodId, - voucher.rows[0]!.next_number, - ], - ) - await getPool().query( - `INSERT INTO public.journal_entry_lines - (journal_entry_id, account_number, account_id, debit_amount, - credit_amount, currency, dimensions, sort_order) - VALUES - ($1, '1930', $2, $4, 0, 'SEK', '{}'::jsonb, 0), - ($1, '2010', $3, 0, $4, 'SEK', '{}'::jsonb, 1)`, - [oldEntryId, accountIds.get('1930'), accountIds.get('2010'), amount], - ) - await getPool().query( - `INSERT INTO public.voucher_sequences - (company_id, user_id, fiscal_period_id, voucher_series, last_number) - VALUES ($1, $2, $3, 'A', $4) - ON CONFLICT (company_id, fiscal_period_id, voucher_series) - DO UPDATE SET last_number = GREATEST(public.voucher_sequences.last_number, $4)`, - [ - params.companyId, - params.userId, - params.fiscalPeriodId, - voucher.rows[0]!.next_number, - ], - ) + const client = await getClient() + try { + await client.query('BEGIN') + const voucher = await client.query<{ next_number: number }>( + `SELECT COALESCE(max(voucher_number), 0)::int + 1 AS next_number + FROM public.journal_entries + WHERE company_id = $1 + AND fiscal_period_id = $2 + AND voucher_series = 'A'`, + [params.companyId, params.fiscalPeriodId], + ) + await client.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', '2026-01-01', + 'Old opening balance', 'opening_balance', 'posted')`, + [ + oldEntryId, + params.userId, + params.companyId, + params.fiscalPeriodId, + voucher.rows[0]!.next_number, + ], + ) + await client.query( + `INSERT INTO public.journal_entry_lines + (journal_entry_id, account_number, account_id, debit_amount, + credit_amount, currency, dimensions, sort_order) + VALUES + ($1, '1930', $2, $4, 0, 'SEK', '{}'::jsonb, 0), + ($1, '2010', $3, 0, $4, 'SEK', '{}'::jsonb, 1)`, + [oldEntryId, accountIds.get('1930'), accountIds.get('2010'), amount], + ) + await client.query( + `INSERT INTO public.voucher_sequences + (company_id, user_id, fiscal_period_id, voucher_series, last_number) + VALUES ($1, $2, $3, 'A', $4) + ON CONFLICT (company_id, fiscal_period_id, voucher_series) + DO UPDATE SET last_number = GREATEST(public.voucher_sequences.last_number, $4)`, + [ + params.companyId, + params.userId, + params.fiscalPeriodId, + voucher.rows[0]!.next_number, + ], + ) + await client.query('COMMIT') + } catch (error) { + await client.query('ROLLBACK').catch(() => {}) + throw error + } finally { + client.release() + } if (params.link !== false) { await getPool().query( `UPDATE public.fiscal_periods diff --git a/lib/import/__tests__/sie-import-out-of-order.pg.test.ts b/lib/import/__tests__/sie-import-out-of-order.pg.test.ts index bcc9990e..ccee15b2 100644 --- a/lib/import/__tests__/sie-import-out-of-order.pg.test.ts +++ b/lib/import/__tests__/sie-import-out-of-order.pg.test.ts @@ -36,63 +36,73 @@ async function insertPostedEntry(params: { reversesId?: string | null }): Promise { const id = randomUUID() - const voucher = await getPool().query<{ next_number: number }>( - `SELECT COALESCE(MAX(voucher_number), 0) + 1 AS next_number - FROM public.journal_entries - WHERE company_id = $1 - AND fiscal_period_id = $2 - AND voucher_series = 'A'`, - [params.companyId, params.fiscalPeriodId], - ) + const client = await getClient() + try { + await client.query('BEGIN') + const voucher = await client.query<{ next_number: number }>( + `SELECT COALESCE(MAX(voucher_number), 0) + 1 AS next_number + FROM public.journal_entries + WHERE company_id = $1 + AND fiscal_period_id = $2 + AND voucher_series = 'A'`, + [params.companyId, params.fiscalPeriodId], + ) - 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, reverses_id) - VALUES ($1, $2, $3, $4, $5, 'A', $6, $7, $8, 'posted', $9)`, - [ - id, - params.userId, - params.companyId, - params.fiscalPeriodId, - voucher.rows[0]!.next_number, - params.entryDate, - params.description, - params.sourceType, - params.reversesId ?? null, - ], - ) - - await getPool().query( - `INSERT INTO public.voucher_sequences - (company_id, user_id, fiscal_period_id, voucher_series, last_number) - VALUES ($1, $2, $3, 'A', $4) - ON CONFLICT (company_id, fiscal_period_id, voucher_series) - DO UPDATE SET last_number = GREATEST( - public.voucher_sequences.last_number, - EXCLUDED.last_number - )`, - [ - params.companyId, - params.userId, - params.fiscalPeriodId, - voucher.rows[0]!.next_number, - ], - ) - - for (const line of params.lines) { - await getPool().query( - `INSERT INTO public.journal_entry_lines - (journal_entry_id, account_number, debit_amount, credit_amount, line_description) - VALUES ($1, $2, $3, $4, $5)`, + await client.query( + `INSERT INTO public.journal_entries + (id, user_id, company_id, fiscal_period_id, voucher_number, + voucher_series, entry_date, description, source_type, status, reverses_id) + VALUES ($1, $2, $3, $4, $5, 'A', $6, $7, $8, 'posted', $9)`, [ id, - line.account_number, - line.debit_amount, - line.credit_amount, - line.line_description ?? null, + params.userId, + params.companyId, + params.fiscalPeriodId, + voucher.rows[0]!.next_number, + params.entryDate, + params.description, + params.sourceType, + params.reversesId ?? null, ], ) + + await client.query( + `INSERT INTO public.voucher_sequences + (company_id, user_id, fiscal_period_id, voucher_series, last_number) + VALUES ($1, $2, $3, 'A', $4) + ON CONFLICT (company_id, fiscal_period_id, voucher_series) + DO UPDATE SET last_number = GREATEST( + public.voucher_sequences.last_number, + EXCLUDED.last_number + )`, + [ + params.companyId, + params.userId, + params.fiscalPeriodId, + voucher.rows[0]!.next_number, + ], + ) + + for (const line of params.lines) { + await client.query( + `INSERT INTO public.journal_entry_lines + (journal_entry_id, account_number, debit_amount, credit_amount, line_description) + VALUES ($1, $2, $3, $4, $5)`, + [ + id, + line.account_number, + line.debit_amount, + line.credit_amount, + line.line_description ?? null, + ], + ) + } + await client.query('COMMIT') + } catch (error) { + await client.query('ROLLBACK').catch(() => {}) + throw error + } finally { + client.release() } return id diff --git a/lib/import/__tests__/sie-import.replace.pg.test.ts b/lib/import/__tests__/sie-import.replace.pg.test.ts index ffb26e3b..c6c50c78 100644 --- a/lib/import/__tests__/sie-import.replace.pg.test.ts +++ b/lib/import/__tests__/sie-import.replace.pg.test.ts @@ -1,6 +1,6 @@ import { randomUUID } from 'node:crypto' import { describe, expect, it } from 'vitest' -import { getPool, runAsServiceRole, withUserContext } from '@/tests/pg/setup' +import { getClient, getPool, runAsServiceRole, withUserContext } from '@/tests/pg/setup' import { seedCompany, insertAuthUser, insertCompanyMember } from '@/tests/pg/fixtures' // Covers the Fortnox re-sync flow: @@ -72,29 +72,39 @@ async function insertPostedEntry(params: { entryDate?: string }): Promise { const id = randomUUID() - 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, $6, $7, 'Test entry', $8, 'posted')`, - [ - id, - params.userId, - params.companyId, - params.fiscalPeriodId, - params.voucherNumber, - params.voucherSeries ?? 'A', - params.entryDate ?? '2026-06-01', - params.sourceType, - ], - ) - await getPool().query( - `INSERT INTO public.journal_entry_lines - (journal_entry_id, account_number, debit_amount, credit_amount) - VALUES ($1, '1930', 100, 0), - ($1, '3001', 0, 100)`, - [id], - ) + const client = await getClient() + try { + await client.query('BEGIN') + await client.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, $6, $7, 'Test entry', $8, 'posted')`, + [ + id, + params.userId, + params.companyId, + params.fiscalPeriodId, + params.voucherNumber, + params.voucherSeries ?? 'A', + params.entryDate ?? '2026-06-01', + params.sourceType, + ], + ) + await client.query( + `INSERT INTO public.journal_entry_lines + (journal_entry_id, account_number, debit_amount, credit_amount) + VALUES ($1, '1930', 100, 0), + ($1, '3001', 0, 100)`, + [id], + ) + await client.query('COMMIT') + } catch (error) { + await client.query('ROLLBACK').catch(() => {}) + throw error + } finally { + client.release() + } return id } diff --git a/lib/invoices/__tests__/link-voucher-currency.pg.test.ts b/lib/invoices/__tests__/link-voucher-currency.pg.test.ts index a8ee3d5f..b2d458c3 100644 --- a/lib/invoices/__tests__/link-voucher-currency.pg.test.ts +++ b/lib/invoices/__tests__/link-voucher-currency.pg.test.ts @@ -21,7 +21,7 @@ */ import { describe, it, expect } from 'vitest' import { randomUUID } from 'node:crypto' -import { getPool } from '@/tests/pg/setup' +import { getClient, getPool } from '@/tests/pg/setup' import { insertAuthUser, insertCompany, @@ -137,27 +137,37 @@ async function seedVoucher(params: { amountInCurrency?: number | null }): Promise { const id = randomUUID() - 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', '2026-05-05', 'Betalning', 'manual', 'posted')`, - [id, params.userId, params.companyId, params.fiscalPeriodId, nextSeq() % 2_000_000_000], - ) - await getPool().query( - `INSERT INTO public.journal_entry_lines - (journal_entry_id, account_number, debit_amount, credit_amount, currency, amount_in_currency) - VALUES ($1, $2, $3, 0, $5, $6), - ($1, $4, 0, $3, $5, $6)`, - [ - id, - params.debitAccount, - params.sekAmount, - params.creditAccount, - params.lineCurrency ?? 'SEK', - params.amountInCurrency ?? null, - ], - ) + const client = await getClient() + try { + await client.query('BEGIN') + await client.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', '2026-05-05', 'Betalning', 'manual', 'posted')`, + [id, params.userId, params.companyId, params.fiscalPeriodId, nextSeq() % 2_000_000_000], + ) + await client.query( + `INSERT INTO public.journal_entry_lines + (journal_entry_id, account_number, debit_amount, credit_amount, currency, amount_in_currency) + VALUES ($1, $2, $3, 0, $5, $6), + ($1, $4, 0, $3, $5, $6)`, + [ + id, + params.debitAccount, + params.sekAmount, + params.creditAccount, + params.lineCurrency ?? 'SEK', + params.amountInCurrency ?? null, + ], + ) + await client.query('COMMIT') + } catch (error) { + await client.query('ROLLBACK').catch(() => {}) + throw error + } finally { + client.release() + } return id } @@ -562,21 +572,31 @@ describe('link_supplier_invoice_to_voucher: amount resolved in the invoice curre total: 1000, }) const voucherId = randomUUID() - 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', '2026-05-05', 'Betalning', 'manual', 'posted')`, - [voucherId, userId, companyId, fiscalPeriodId, nextSeq() % 2_000_000_000], - ) - await getPool().query( - `INSERT INTO public.journal_entry_lines - (journal_entry_id, account_number, debit_amount, credit_amount, currency, amount_in_currency) - VALUES ($1, '2440', 6900, 0, 'EUR', 600), - ($1, '2441', 4600, 0, 'EUR', 400), - ($1, '1930', 0, 11500, 'EUR', 1000)`, - [voucherId], - ) + const client = await getClient() + try { + await client.query('BEGIN') + await client.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', '2026-05-05', 'Betalning', 'manual', 'posted')`, + [voucherId, userId, companyId, fiscalPeriodId, nextSeq() % 2_000_000_000], + ) + await client.query( + `INSERT INTO public.journal_entry_lines + (journal_entry_id, account_number, debit_amount, credit_amount, currency, amount_in_currency) + VALUES ($1, '2440', 6900, 0, 'EUR', 600), + ($1, '2441', 4600, 0, 'EUR', 400), + ($1, '1930', 0, 11500, 'EUR', 1000)`, + [voucherId], + ) + await client.query('COMMIT') + } catch (error) { + await client.query('ROLLBACK').catch(() => {}) + throw error + } finally { + client.release() + } const result = await callLinkSupplierInvoice({ supplierInvoiceId, diff --git a/lib/invoices/__tests__/voucher-matching.pg.test.ts b/lib/invoices/__tests__/voucher-matching.pg.test.ts index 94d426c2..d9a3e1c8 100644 --- a/lib/invoices/__tests__/voucher-matching.pg.test.ts +++ b/lib/invoices/__tests__/voucher-matching.pg.test.ts @@ -14,7 +14,7 @@ */ import { describe, it, expect } from 'vitest' import { randomUUID } from 'node:crypto' -import { getPool } from '@/tests/pg/setup' +import { getClient, getPool } from '@/tests/pg/setup' import { insertAuthUser, insertCompany, @@ -64,20 +64,30 @@ async function seedPostedVoucher(params: { }): Promise { const id = randomUUID() const amount = params.amount ?? 1000 - 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', '2026-05-05', 'Inbetalning', 'manual', 'posted')`, - [id, params.userId, params.companyId, params.fiscalPeriodId, Math.floor(Math.random() * 100000)], - ) - await getPool().query( - `INSERT INTO public.journal_entry_lines - (journal_entry_id, account_number, debit_amount, credit_amount) - VALUES ($1, '1930', $2, 0), - ($1, '1510', 0, $2)`, - [id, amount], - ) + const client = await getClient() + try { + await client.query('BEGIN') + await client.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', '2026-05-05', 'Inbetalning', 'manual', 'posted')`, + [id, params.userId, params.companyId, params.fiscalPeriodId, Math.floor(Math.random() * 100000)], + ) + await client.query( + `INSERT INTO public.journal_entry_lines + (journal_entry_id, account_number, debit_amount, credit_amount) + VALUES ($1, '1930', $2, 0), + ($1, '1510', 0, $2)`, + [id, amount], + ) + await client.query('COMMIT') + } catch (error) { + await client.query('ROLLBACK').catch(() => {}) + throw error + } finally { + client.release() + } return id } @@ -96,20 +106,30 @@ async function seedVoucherDebitCredit(params: { }): Promise { const id = randomUUID() const amount = params.amount ?? 1000 - 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', '2026-05-05', 'Inbetalning', 'manual', 'posted')`, - [id, params.userId, params.companyId, params.fiscalPeriodId, Math.floor(Math.random() * 100000)], - ) - await getPool().query( - `INSERT INTO public.journal_entry_lines - (journal_entry_id, account_number, debit_amount, credit_amount) - VALUES ($1, $2, $3, 0), - ($1, $4, 0, $3)`, - [id, params.debitAccount, amount, params.creditAccount], - ) + const client = await getClient() + try { + await client.query('BEGIN') + await client.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', '2026-05-05', 'Inbetalning', 'manual', 'posted')`, + [id, params.userId, params.companyId, params.fiscalPeriodId, Math.floor(Math.random() * 100000)], + ) + await client.query( + `INSERT INTO public.journal_entry_lines + (journal_entry_id, account_number, debit_amount, credit_amount) + VALUES ($1, $2, $3, 0), + ($1, $4, 0, $3)`, + [id, params.debitAccount, amount, params.creditAccount], + ) + await client.query('COMMIT') + } catch (error) { + await client.query('ROLLBACK').catch(() => {}) + throw error + } finally { + client.release() + } return id } diff --git a/scripts/seed-demo-account.ts b/scripts/seed-demo-account.ts index 1e561b03..378d525f 100644 --- a/scripts/seed-demo-account.ts +++ b/scripts/seed-demo-account.ts @@ -284,6 +284,10 @@ async function postEntry( const gaps = VOUCHER_GAPS[fy] while (gaps && gaps.has(next)) next++ ctx.voucher[fy] = next + // Inserted as draft and posted after the lines land: supabase-js autocommits + // each request, and check_balance_on_posted_insert rejects a posted header + // whose transaction carries no lines. The draft-to-posted UPDATE fires + // check_balance_on_post against the finished verifikat instead. const { data: je, error } = await sb .from('journal_entries') .insert({ @@ -296,7 +300,7 @@ async function postEntry( description, source_type: sourceType, source_id: opts.sourceId ?? null, - status: 'posted', + status: 'draft', committed_at: new Date(date).toISOString(), created_via: 'system', }) @@ -320,6 +324,13 @@ async function postEntry( ) if (lineErr) throw new Error(`lines for "${description}": ${lineErr.message}`) + const { error: postErr } = await sb + .from('journal_entries') + .update({ status: 'posted' }) + .eq('id', je.id) + .eq('company_id', ctx.companyId) + if (postErr) throw new Error(`post "${description}": ${postErr.message}`) + await sb .from('voucher_sequences') .upsert( diff --git a/scripts/seed-export-data.mjs b/scripts/seed-export-data.mjs index e27db31e..ff00e82d 100644 --- a/scripts/seed-export-data.mjs +++ b/scripts/seed-export-data.mjs @@ -377,7 +377,8 @@ async function main() { description: `Faktura ${inv.invoice_number}: ${customers.find(c => c.id === inv.customer_id).name}`, source_type: 'invoice_created', source_id: inv.id, - status: 'posted', + // Draft until the lines are inserted; posted in the batch UPDATE below. + status: 'draft', committed_at: new Date().toISOString(), }) @@ -420,7 +421,7 @@ async function main() { description: `Betalning ${invoices[4].invoice_number}: Suomen Softworks Oy`, source_type: 'invoice_paid', source_id: invoices[4].id, - status: 'posted', + status: 'draft', committed_at: new Date().toISOString(), }) @@ -457,7 +458,7 @@ async function main() { entry_date: daysAgo(25), description: 'Kursdifferens vid betalning', source_type: 'invoice_paid', - status: 'posted', + status: 'draft', committed_at: new Date().toISOString(), }) @@ -483,6 +484,10 @@ async function main() { sort_order: 2, }) + // Headers go in as drafts and are posted after the lines land: supabase-js + // autocommits each request, and check_balance_on_posted_insert rejects a + // posted header whose transaction carries no lines. The draft-to-posted + // UPDATE fires check_balance_on_post against the finished verifikat instead. console.log(`\nCreating ${journalEntries.length} journal entries...`) const { error: jeError } = await supabase.from('journal_entries').insert(journalEntries) if (jeError) { @@ -498,6 +503,15 @@ async function main() { process.exit(1) } + const { error: postError } = await supabase + .from('journal_entries') + .update({ status: 'posted' }) + .in('id', journalEntries.map(e => e.id)) + if (postError) { + console.error('Failed to post journal entries:', postError.message) + process.exit(1) + } + for (const je of journalEntries) { console.log(` ✓ A${je.voucher_number}: ${je.description}`) } diff --git a/supabase/migrations/20260806130000_enforce_balance_on_posted_insert.sql b/supabase/migrations/20260806130000_enforce_balance_on_posted_insert.sql new file mode 100644 index 00000000..360903fb --- /dev/null +++ b/supabase/migrations/20260806130000_enforce_balance_on_posted_insert.sql @@ -0,0 +1,11 @@ +-- Direct inserts of posted journal entries previously bypassed +-- check_balance_on_post, which only covers draft-to-posted updates. Reuse the +-- same deferred balance function so an atomic transaction may insert the +-- header and lines together, while zero-line or unbalanced posted entries are +-- rejected when the transaction constraints are evaluated. +CREATE CONSTRAINT TRIGGER check_balance_on_posted_insert + AFTER INSERT ON public.journal_entries + DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW + WHEN (NEW.status = 'posted') + EXECUTE FUNCTION public.check_journal_entry_balance(); diff --git a/supabase/migrations/20260806150000_preserve_preset_committed_at.sql b/supabase/migrations/20260806150000_preserve_preset_committed_at.sql new file mode 100644 index 00000000..a2111394 --- /dev/null +++ b/supabase/migrations/20260806150000_preserve_preset_committed_at.sql @@ -0,0 +1,30 @@ +-- Byte-for-byte the same function as 20260806160000 (see that file for the +-- full rationale). Two versions exist because the PR's Supabase preview +-- branch applied 150000 while this change was still iterating: deleting the +-- file orphans the preview's migration tracker ("Remote migration versions +-- not found in local migrations directory"), and an earlier interim body +-- (preserve-for-everyone, no actor check) must not live on as a standalone +-- applyable unit. With identical content in both files, any environment that +-- applies either or both, in any order, lands on the same guarded function. +CREATE OR REPLACE FUNCTION public.set_committed_at() +RETURNS trigger +LANGUAGE plpgsql +SET search_path = public +AS $$ +DECLARE + v_jwt_role text; +BEGIN + IF OLD.status = 'draft' AND NEW.status = 'posted' THEN + v_jwt_role := coalesce( + nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'role', + nullif(current_setting('request.jwt.claim.role', true), ''), + '' + ); + IF NEW.committed_at IS NULL + OR NOT (v_jwt_role = '' OR v_jwt_role = 'service_role') THEN + NEW.committed_at := now(); + END IF; + END IF; + RETURN NEW; +END; +$$; diff --git a/supabase/migrations/20260806160000_preserve_preset_committed_at_trusted.sql b/supabase/migrations/20260806160000_preserve_preset_committed_at_trusted.sql new file mode 100644 index 00000000..62a27290 --- /dev/null +++ b/supabase/migrations/20260806160000_preserve_preset_committed_at_trusted.sql @@ -0,0 +1,50 @@ +-- set_committed_at() (migration 017) stamped committed_at := now() on every +-- draft-to-posted transition, discarding any committed_at the row already +-- carried. Seeding flows that backdate history (seed-demo-account, +-- seed-export-data) post drafts whose committed_at is the historical booking +-- time, and stamping now() over it makes every seeded verifikat look booked +-- today, skewing the booking-lag stats and audit views the demo exists to +-- show. +-- +-- Preserving a preset value for EVERY writer would be a hole, not a fix: +-- RLS lets a company member insert a draft (any column, committed_at +-- included) and post it, and committed_at is what the löpande-bokföring +-- timeliness checks (BFL 5 kap) and behandlingshistorik (BFNAR 2013:2 kap 8) +-- read as the genuine transition time. So the preset value survives only for +-- backend writers: service_role, or no JWT claims at all (direct SQL, +-- maintenance, pg tests). End-user callers always get the tamper-proof now() +-- stamp. +-- +-- The actor test reads the JWT claims role, the same primitive as the +-- commit_journal_entry tenant guard. current_user would be the WRONG +-- primitive here: commit_journal_entry is SECURITY DEFINER and granted to +-- authenticated, so inside it current_user resolves to the function owner +-- while the claims still identify the end-user caller; a current_user-based +-- guard would let a member preset committed_at on a direct-inserted draft +-- and launder it through the RPC. The engine path is unchanged either way: +-- its drafts never carry committed_at, so posting always stamps. +-- +-- File 20260806150000 carries this same final body (see its header for why +-- both versions exist); applying either or both yields the same function. +CREATE OR REPLACE FUNCTION public.set_committed_at() +RETURNS trigger +LANGUAGE plpgsql +SET search_path = public +AS $$ +DECLARE + v_jwt_role text; +BEGIN + IF OLD.status = 'draft' AND NEW.status = 'posted' THEN + v_jwt_role := coalesce( + nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'role', + nullif(current_setting('request.jwt.claim.role', true), ''), + '' + ); + IF NEW.committed_at IS NULL + OR NOT (v_jwt_role = '' OR v_jwt_role = 'service_role') THEN + NEW.committed_at := now(); + END IF; + END IF; + RETURN NEW; +END; +$$; diff --git a/tests/pg/account-usage-counts-rpc.pg.test.ts b/tests/pg/account-usage-counts-rpc.pg.test.ts index 4ab29085..bf3d7c83 100644 --- a/tests/pg/account-usage-counts-rpc.pg.test.ts +++ b/tests/pg/account-usage-counts-rpc.pg.test.ts @@ -11,7 +11,12 @@ import { describe, it, expect } from 'vitest' import { randomUUID } from 'node:crypto' import { getPool } from './setup' -import { insertAuthUser, insertCompany, insertFiscalPeriod } from './fixtures' +import { + insertAuthUser, + insertCompany, + insertFiscalPeriod, + insertPostedJournalEntry, +} from './fixtures' async function insertJournalEntry(params: { userId: string @@ -21,6 +26,23 @@ async function insertJournalEntry(params: { status: 'draft' | 'posted' lines: Array<{ account: string; debit: number; credit: number }> }): Promise { + if (params.status === 'posted') { + return insertPostedJournalEntry({ + userId: params.userId, + companyId: params.companyId, + fiscalPeriodId: params.fiscalPeriodId, + voucherNumber: params.voucherNumber, + entryDate: '2026-03-15', + description: 'Usage test', + sourceType: 'manual', + lines: params.lines.map((line) => ({ + accountNumber: line.account, + debitAmount: line.debit, + creditAmount: line.credit, + })), + }) + } + const id = randomUUID() // Insert directly, bypassing commit_journal_entry's voucher sequencing — // fine for a read-side RPC that only aggregates line/account references. diff --git a/tests/pg/assets.pg.test.ts b/tests/pg/assets.pg.test.ts index b47c1a95..117121d0 100644 --- a/tests/pg/assets.pg.test.ts +++ b/tests/pg/assets.pg.test.ts @@ -21,6 +21,7 @@ import { insertCompany, insertCompanyMember, insertFiscalPeriod, + insertPostedJournalEntry, } from './fixtures' async function insertAsset(params: { @@ -86,21 +87,19 @@ async function insertPostedEntry(params: { fiscalPeriodId: string voucherNumber?: number }): Promise { - const id = randomUUID() - 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', '2025-12-31', 'Test', 'year_end', 'posted')`, - [id, params.userId, params.companyId, params.fiscalPeriodId, params.voucherNumber ?? 1], - ) - await getPool().query( - `INSERT INTO public.journal_entry_lines - (journal_entry_id, account_number, debit_amount, credit_amount) - VALUES ($1, '7832', 12000, 0), ($1, '1229', 0, 12000)`, - [id], - ) - return id + return insertPostedJournalEntry({ + userId: params.userId, + companyId: params.companyId, + fiscalPeriodId: params.fiscalPeriodId, + voucherNumber: params.voucherNumber ?? 1, + entryDate: '2025-12-31', + description: 'Test', + sourceType: 'year_end', + lines: [ + { accountNumber: '7832', debitAmount: 12000, creditAmount: 0 }, + { accountNumber: '1229', debitAmount: 0, creditAmount: 12000 }, + ], + }) } let companyA: { userId: string; companyId: string; fiscalPeriodId: string } diff --git a/tests/pg/bulk-book-transactions.pg.test.ts b/tests/pg/bulk-book-transactions.pg.test.ts index a63c90ea..8a57eda6 100644 --- a/tests/pg/bulk-book-transactions.pg.test.ts +++ b/tests/pg/bulk-book-transactions.pg.test.ts @@ -5,6 +5,7 @@ import { insertCompany, insertCompanyMember, insertFiscalPeriod, + insertPostedJournalEntry, } from '@/tests/pg/fixtures' import { getPool, withUserContext } from '@/tests/pg/setup' @@ -422,21 +423,20 @@ describe('bulk_book_transactions: document inheritance (PR #608)', () => { // Manually pre-post a day-summary verifikat the user wants the txs // linked to. Bank net must equal sum(tx.amount) = 300. - const jeId = randomUUID() - 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, 1, 'A', '2026-06-05', 'Manual day summary', 'manual', 'posted')`, - [jeId, userId, companyId, fiscalPeriodId], - ) - await getPool().query( - `INSERT INTO public.journal_entry_lines (journal_entry_id, account_number, debit_amount, credit_amount, currency, sort_order) - VALUES ($1, '1930', 300, 0, 'SEK', 0), - ($1, '3001', 0, 240, 'SEK', 1), - ($1, '2611', 0, 60, 'SEK', 2)`, - [jeId], - ) + const jeId = await insertPostedJournalEntry({ + userId, + companyId, + fiscalPeriodId, + voucherNumber: 1, + entryDate: '2026-06-05', + description: 'Manual day summary', + sourceType: 'manual', + lines: [ + { accountNumber: '1930', debitAmount: 300, creditAmount: 0, currency: 'SEK', sortOrder: 0 }, + { accountNumber: '3001', debitAmount: 0, creditAmount: 240, currency: 'SEK', sortOrder: 1 }, + { accountNumber: '2611', debitAmount: 0, creditAmount: 60, currency: 'SEK', sortOrder: 2 }, + ], + }) await withUserContext(userId, async (client) => { const r = await client.query<{ bulk_book_transactions: RpcResult & { docs_linked?: number } }>( diff --git a/tests/pg/closing-entry-detach.pg.test.ts b/tests/pg/closing-entry-detach.pg.test.ts index 31d7ec99..978d6270 100644 --- a/tests/pg/closing-entry-detach.pg.test.ts +++ b/tests/pg/closing-entry-detach.pg.test.ts @@ -21,23 +21,49 @@ async function insertStornoOf(params: { sourceType?: string }): Promise { const id = randomUUID() - 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, reverses_id) - VALUES ($1, $2, $3, $4, $5, 'A', '2026-12-31', 'Makulering', $6, $7, $8)`, - [ - id, - params.userId, - params.companyId, - params.fiscalPeriodId, - params.voucherNumber, - params.sourceType ?? 'storno', - params.status ?? 'posted', - params.reversesId, - ], - ) - return id + const status = params.status ?? 'posted' + const client = await getPool().connect() + + try { + await client.query('BEGIN') + await client.query( + `INSERT INTO public.journal_entries + (id, user_id, company_id, fiscal_period_id, voucher_number, voucher_series, + entry_date, description, source_type, status, reverses_id) + VALUES ($1, $2, $3, $4, $5, 'A', '2026-12-31', 'Makulering', $6, $7, $8)`, + [ + id, + params.userId, + params.companyId, + params.fiscalPeriodId, + params.voucherNumber, + params.sourceType ?? 'storno', + status, + params.reversesId, + ], + ) + + if (status === 'posted') { + // Mirror the default closing-entry fixture so the storno is a valid, + // balanced voucher while this suite focuses on the reversal chain. + await client.query( + `INSERT INTO public.journal_entry_lines + (journal_entry_id, account_number, debit_amount, credit_amount) + VALUES ($1, '3001', 1000, 0), + ($1, '1930', 0, 1000)`, + [id], + ) + await client.query('SET CONSTRAINTS check_balance_on_posted_insert IMMEDIATE') + } + + await client.query('COMMIT') + return id + } catch (error) { + await client.query('ROLLBACK').catch(() => {}) + throw error + } finally { + client.release() + } } describe('closing_entry_id detach escape hatch', () => { diff --git a/tests/pg/committed-at-preservation.pg.test.ts b/tests/pg/committed-at-preservation.pg.test.ts new file mode 100644 index 00000000..8bb2340c --- /dev/null +++ b/tests/pg/committed-at-preservation.pg.test.ts @@ -0,0 +1,143 @@ +import { describe, it, expect } from 'vitest' +import { getPool, withUserContext, runAsServiceRole } from './setup' +import { seedCompany, insertDraftJournalEntry, insertBalancedLines } from './fixtures' + +// set_committed_at() after migration 20260806160000: on draft-to-posted the +// preset committed_at survives ONLY for backend writers, decided by the JWT +// claims role (service_role, or no claims at all: direct SQL and pg tests). +// Seeding flows backdate history that way. For end-user callers the stamp +// stays tamper-proof: RLS lets a member insert a draft with any committed_at, +// and the timeliness checks (BFL 5 kap) read committed_at as the genuine +// transition time, so an authenticated caller must never control it: not via +// a direct UPDATE, and not by laundering the post through the SECURITY +// DEFINER commit_journal_entry RPC (which is why the guard reads JWT claims, +// not current_user). Drafts with no committed_at are stamped now() for +// everyone. + +const BACKDATED = '2026-03-15T10:00:00Z' +const BACKDATED_ISO = '2026-03-15T10:00:00.000Z' + +async function seedBackdatedDraft(): Promise<{ entryId: string; userId: string }> { + const { userId, companyId, fiscalPeriodId } = await seedCompany() + const entryId = await insertDraftJournalEntry({ + userId, + companyId, + fiscalPeriodId, + entryDate: '2026-03-15', + committedAt: BACKDATED, + }) + await insertBalancedLines(entryId) + return { entryId, userId } +} + +describe('set_committed_at trusted-writer preservation', () => { + it('preserves a backdated committed_at when posting as postgres', async () => { + const { entryId } = await seedBackdatedDraft() + const pool = getPool() + await pool.query(`UPDATE public.journal_entries SET status = 'posted' WHERE id = $1`, [ + entryId, + ]) + const { rows } = await pool.query<{ committed_at: Date }>( + `SELECT committed_at FROM public.journal_entries WHERE id = $1`, + [entryId], + ) + expect(rows[0].committed_at.toISOString()).toBe(BACKDATED_ISO) + }) + + it('preserves a backdated committed_at when posting as service_role', async () => { + const { entryId } = await seedBackdatedDraft() + const committedAt = await runAsServiceRole(async (client) => { + await client.query(`UPDATE public.journal_entries SET status = 'posted' WHERE id = $1`, [ + entryId, + ]) + const { rows } = await client.query<{ committed_at: Date }>( + `SELECT committed_at FROM public.journal_entries WHERE id = $1`, + [entryId], + ) + return rows[0].committed_at + }) + expect(committedAt.toISOString()).toBe(BACKDATED_ISO) + }) + + it('overwrites a preset committed_at when an authenticated member posts', async () => { + const { entryId, userId } = await seedBackdatedDraft() + const before = Date.now() + const committedAt = await withUserContext(userId, async (client) => { + const updated = await client.query( + `UPDATE public.journal_entries SET status = 'posted' WHERE id = $1 RETURNING id`, + [entryId], + ) + // RLS must actually let the member's UPDATE through; 0 rows would make + // the assertion below pass vacuously against the seeded value. + expect(updated.rowCount).toBe(1) + const { rows } = await client.query<{ committed_at: Date }>( + `SELECT committed_at FROM public.journal_entries WHERE id = $1`, + [entryId], + ) + return rows[0].committed_at + }) + const after = Date.now() + expect(committedAt.toISOString()).not.toBe(BACKDATED_ISO) + expect(committedAt.getTime()).toBeGreaterThanOrEqual(before - 60_000) + expect(committedAt.getTime()).toBeLessThanOrEqual(after + 60_000) + }) + + it('overwrites a preset committed_at when an authenticated member posts via commit_journal_entry', async () => { + // The laundering path: the RPC is SECURITY DEFINER, so current_user + // inside it is the function owner. The guard must still see the caller's + // JWT claims and stamp now(). + const { userId, companyId, fiscalPeriodId } = await seedCompany() + const entryId = await insertDraftJournalEntry({ + userId, + companyId, + fiscalPeriodId, + entryDate: '2026-03-15', + committedAt: BACKDATED, + }) + await insertBalancedLines(entryId) + + const before = Date.now() + const committedAt = await withUserContext(userId, async (client) => { + const committed = await client.query<{ voucher_number: number }>( + `SELECT * FROM public.commit_journal_entry($1, $2)`, + [companyId, entryId], + ) + expect(committed.rows[0].voucher_number).toBeGreaterThan(0) + const { rows } = await client.query<{ committed_at: Date }>( + `SELECT committed_at FROM public.journal_entries WHERE id = $1`, + [entryId], + ) + return rows[0].committed_at + }) + const after = Date.now() + expect(committedAt.toISOString()).not.toBe(BACKDATED_ISO) + expect(committedAt.getTime()).toBeGreaterThanOrEqual(before - 60_000) + expect(committedAt.getTime()).toBeLessThanOrEqual(after + 60_000) + }) + + it('stamps now() when the draft carries no committed_at', async () => { + const { userId, companyId, fiscalPeriodId } = await seedCompany() + const entryId = await insertDraftJournalEntry({ + userId, + companyId, + fiscalPeriodId, + entryDate: '2026-03-15', + }) + await insertBalancedLines(entryId) + + const pool = getPool() + const before = Date.now() + await pool.query(`UPDATE public.journal_entries SET status = 'posted' WHERE id = $1`, [ + entryId, + ]) + const after = Date.now() + const { rows } = await pool.query<{ committed_at: Date | null }>( + `SELECT committed_at FROM public.journal_entries WHERE id = $1`, + [entryId], + ) + expect(rows[0].committed_at).not.toBeNull() + // Stamped at posting time, not the (older) entry_date. + expect(rows[0].committed_at!.getTime()).toBeGreaterThanOrEqual(before - 60_000) + expect(rows[0].committed_at!.getTime()).toBeLessThanOrEqual(after + 60_000) + }) +}) diff --git a/tests/pg/document-surfaces-unification.pg.test.ts b/tests/pg/document-surfaces-unification.pg.test.ts index 0345bbe0..bbcd1219 100644 --- a/tests/pg/document-surfaces-unification.pg.test.ts +++ b/tests/pg/document-surfaces-unification.pg.test.ts @@ -5,6 +5,7 @@ import { getPool } from './setup' import { seedCompany, insertDraftJournalEntry, + insertPostedJournalEntry, insertBalancedLines, insertTransaction, } from './fixtures' @@ -169,18 +170,19 @@ describe('document surfaces unification', () => { fiscalPeriodId = s.fiscalPeriodId const mkJe = async (n: number, sourceType: string) => { - const id = await insertDraftJournalEntry({ + return insertPostedJournalEntry({ userId, companyId, fiscalPeriodId, - status: 'posted', voucherNumber: n, entryDate: `2026-06-${String(n).padStart(2, '0')}`, description: `${sourceType} ${n}`, sourceType, + lines: [ + { accountNumber: '1930', debitAmount: n * 100, creditAmount: 0 }, + { accountNumber: '3001', debitAmount: 0, creditAmount: n * 100 }, + ], }) - await insertBalancedLines(id, n * 100) - return id } jeBankNoDoc = await mkJe(1, 'bank_transaction') @@ -331,17 +333,19 @@ describe('document surfaces unification', () => { let voucher = 1 const expected: string[] = [] for (const sourceType of NEEDS_DOC_SOURCE_TYPES) { - const id = await insertDraftJournalEntry({ + const id = await insertPostedJournalEntry({ userId: s.userId, companyId: s.companyId, fiscalPeriodId: s.fiscalPeriodId, - status: 'posted', voucherNumber: voucher, entryDate: '2026-06-15', description: sourceType, sourceType, + lines: [ + { accountNumber: '1930', debitAmount: 100 * voucher, creditAmount: 0 }, + { accountNumber: '3001', debitAmount: 0, creditAmount: 100 * voucher }, + ], }) - await insertBalancedLines(id, 100 * voucher) expected.push(id) voucher++ } @@ -385,18 +389,19 @@ describe('transaction-pinned document backfill (migration 20260724090000 §4)', const s = await seedCompany() const mkPostedJe = async (n: number, fiscalPeriodId: string) => { - const id = await insertDraftJournalEntry({ + return insertPostedJournalEntry({ userId: s.userId, companyId: s.companyId, fiscalPeriodId, - status: 'posted', voucherNumber: n, entryDate: '2026-06-15', description: `backfill ${n}`, sourceType: 'supplier_invoice_paid', + lines: [ + { accountNumber: '1930', debitAmount: 100 * n, creditAmount: 0 }, + { accountNumber: '3001', debitAmount: 0, creditAmount: 100 * n }, + ], }) - await insertBalancedLines(id, 100 * n) - return id } // Case A (Emil's flow): doc pinned to the tx, never propagated. @@ -534,6 +539,21 @@ describe('floating supplier-invoice document backfill (migration 20260727180000) // the verifikat view still displayed the PDF. const s = await seedCompany() const mkJe = async (n: number, status: 'posted' | 'reversed', sourceType: string) => { + if (status === 'posted') { + return insertPostedJournalEntry({ + userId: s.userId, + companyId: s.companyId, + fiscalPeriodId: s.fiscalPeriodId, + voucherNumber: n, + entryDate: '2026-06-15', + description: `anchor ${n}`, + sourceType, + lines: [ + { accountNumber: '1930', debitAmount: 100 * n, creditAmount: 0 }, + { accountNumber: '3001', debitAmount: 0, creditAmount: 100 * n }, + ], + }) + } const id = await insertDraftJournalEntry({ userId: s.userId, companyId: s.companyId, @@ -580,18 +600,19 @@ describe('floating supplier-invoice document backfill (migration 20260727180000) it('prefers the registration verifikat and never steals an anchored doc', async () => { const s = await seedCompany() const mkJe = async (n: number, sourceType: string) => { - const id = await insertDraftJournalEntry({ + return insertPostedJournalEntry({ userId: s.userId, companyId: s.companyId, fiscalPeriodId: s.fiscalPeriodId, - status: 'posted', voucherNumber: n, entryDate: '2026-06-15', description: `prefer ${n}`, sourceType, + lines: [ + { accountNumber: '1930', debitAmount: 100 * n, creditAmount: 0 }, + { accountNumber: '3001', debitAmount: 0, creditAmount: 100 * n }, + ], }) - await insertBalancedLines(id, 100 * n) - return id } const jeReg = await mkJe(1, 'supplier_invoice_registered') @@ -637,17 +658,19 @@ describe('floating supplier-invoice document backfill (migration 20260727180000) it('skips closed periods: the period-lock trigger would reject the write anyway', async () => { const s = await seedCompany() - const je = await insertDraftJournalEntry({ + const je = await insertPostedJournalEntry({ userId: s.userId, companyId: s.companyId, fiscalPeriodId: s.fiscalPeriodId, - status: 'posted', voucherNumber: 1, entryDate: '2026-06-15', description: 'closed period', sourceType: 'supplier_invoice_paid', + lines: [ + { accountNumber: '1930', debitAmount: 100, creditAmount: 0 }, + { accountNumber: '3001', debitAmount: 0, creditAmount: 100 }, + ], }) - await insertBalancedLines(je, 100) const supplierId = await insertSupplier({ userId: s.userId, companyId: s.companyId }) const doc = await attachDocument({ userId: s.userId, diff --git a/tests/pg/fixtures.ts b/tests/pg/fixtures.ts index 768559ce..84403c7d 100644 --- a/tests/pg/fixtures.ts +++ b/tests/pg/fixtures.ts @@ -179,6 +179,10 @@ export async function insertDraftJournalEntry(params: { // fires on draft->posted UPDATE), so committed_at stays null unless set here. committedAt?: string | null }): Promise { + if (params.status === 'posted') { + return insertPostedJournalEntry(params) + } + const id = randomUUID() await getPool().query( `INSERT INTO public.journal_entries @@ -204,6 +208,95 @@ export async function insertDraftJournalEntry(params: { return id } +export interface PostedJournalEntryLine { + accountNumber: string + debitAmount: number + creditAmount: number + currency?: string + lineDescription?: string | null + sortOrder?: number + dimensions?: Record +} + +// Insert a posted entry and all of its lines in one transaction. This is the +// only valid shape for pg fixtures that intentionally exercise a direct posted +// INSERT: check_balance_on_posted_insert is deferred until the lines exist, but +// still executes before COMMIT. +export async function insertPostedJournalEntry(params: { + userId: string + companyId: string + fiscalPeriodId: string + entryDate?: string + description?: string + voucherSeries?: string + voucherNumber?: number + sourceType?: string + sourceId?: string | null + createdAt?: string + committedAt?: string | null + lines?: PostedJournalEntryLine[] +}): Promise { + const id = randomUUID() + const lines = params.lines ?? [ + { accountNumber: '1930', debitAmount: 1000, creditAmount: 0 }, + { accountNumber: '3001', debitAmount: 0, creditAmount: 1000 }, + ] + const client = await getPool().connect() + + try { + await client.query('BEGIN') + await client.query( + `INSERT INTO public.journal_entries + (id, user_id, company_id, fiscal_period_id, voucher_number, voucher_series, + entry_date, description, source_type, source_id, status, created_at, committed_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 'posted', + COALESCE($11::timestamptz, now()), $12::timestamptz)`, + [ + id, + params.userId, + params.companyId, + params.fiscalPeriodId, + params.voucherNumber ?? 0, + params.voucherSeries ?? 'A', + params.entryDate ?? '2026-06-01', + params.description ?? 'Test entry', + params.sourceType ?? 'manual', + params.sourceId ?? null, + params.createdAt ?? null, + params.committedAt ?? null, + ], + ) + + for (const [index, line] of lines.entries()) { + await client.query( + `INSERT INTO public.journal_entry_lines + (journal_entry_id, account_number, debit_amount, credit_amount, + currency, line_description, sort_order, dimensions) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb)`, + [ + id, + line.accountNumber, + line.debitAmount, + line.creditAmount, + line.currency ?? 'SEK', + line.lineDescription ?? null, + line.sortOrder ?? index, + JSON.stringify(line.dimensions ?? {}), + ], + ) + } + + await client.query('SET CONSTRAINTS check_balance_on_posted_insert IMMEDIATE') + await client.query('COMMIT') + return id + } catch (error) { + await client.query('ROLLBACK').catch(() => {}) + throw error + } finally { + client.release() + } +} + // Insert a balanced pair of journal entry lines (1 debit row + 1 credit row // at the given amount). Needed before commit_journal_entry() because the // balance constraint trigger fires on draft→posted. diff --git a/tests/pg/get_account_gl_lines_for_matching.pg.test.ts b/tests/pg/get_account_gl_lines_for_matching.pg.test.ts index 183c4ad6..ae0a1ab1 100644 --- a/tests/pg/get_account_gl_lines_for_matching.pg.test.ts +++ b/tests/pg/get_account_gl_lines_for_matching.pg.test.ts @@ -17,13 +17,13 @@ * is covered in mark-entry-as-opening-balance.pg.test.ts.) */ import { describe, it, expect } from 'vitest' -import { randomUUID } from 'node:crypto' import { getPool } from './setup' import { insertAuthUser, insertCashAccount, insertCompany, insertFiscalPeriod, + insertPostedJournalEntry as insertAtomicPostedJournalEntry, insertTransaction, } from './fixtures' @@ -38,37 +38,25 @@ async function insertPostedJournalEntry(params: { /** Line rows to book; defaults to the classic 1930 debit / 2091 credit pair. */ lines?: Array<{ account: string; debit: number; credit: number }> }): Promise { - const id = randomUUID() const amount = params.amount ?? 1000 - 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, $7, $8, 'posted')`, - [ - id, - params.userId, - params.companyId, - params.fiscalPeriodId, - params.voucherNumber, - params.entryDate, - `Test ${params.sourceType}`, - params.sourceType, - ], - ) const lines = params.lines ?? [ { account: '1930', debit: amount, credit: 0 }, { account: '2091', debit: 0, credit: amount }, ] - for (const line of 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 + return insertAtomicPostedJournalEntry({ + userId: params.userId, + companyId: params.companyId, + fiscalPeriodId: params.fiscalPeriodId, + voucherNumber: params.voucherNumber, + entryDate: params.entryDate, + description: `Test ${params.sourceType}`, + sourceType: params.sourceType, + lines: lines.map((line) => ({ + accountNumber: line.account, + debitAmount: line.debit, + creditAmount: line.credit, + })), + }) } describe('get_account_gl_lines_for_matching RPC: N:1 candidates', () => { diff --git a/tests/pg/get_unlinked_1930_lines.pg.test.ts b/tests/pg/get_unlinked_1930_lines.pg.test.ts index 9eed2840..cc4e1191 100644 --- a/tests/pg/get_unlinked_1930_lines.pg.test.ts +++ b/tests/pg/get_unlinked_1930_lines.pg.test.ts @@ -8,9 +8,13 @@ * default p_account_number of '1930' keeps these assertions valid. */ import { describe, it, expect } from 'vitest' -import { randomUUID } from 'node:crypto' import { getPool } from './setup' -import { insertAuthUser, insertCompany, insertFiscalPeriod } from './fixtures' +import { + insertAuthUser, + insertCompany, + insertFiscalPeriod, + insertPostedJournalEntry as insertAtomicPostedJournalEntry, +} from './fixtures' async function insertPostedJournalEntry(params: { userId: string @@ -21,38 +25,26 @@ async function insertPostedJournalEntry(params: { voucherNumber: number amount?: number }): Promise { - const id = randomUUID() const amount = params.amount ?? 1000 // Insert as posted directly. This bypasses commit_journal_entry's voucher // sequencing; that's fine for testing the read-side RPC, which only cares // about (account_number, status, source_type, date_range, link presence). - 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, $7, $8, 'posted')`, - [ - id, - params.userId, - params.companyId, - params.fiscalPeriodId, - params.voucherNumber, - params.entryDate, - `Test ${params.sourceType}`, - params.sourceType, - ], - ) // Balanced pair on 1930 + 2091 (balanserad vinst/förlust, the realistic // carried-forward counterpart for an IB on a bank account; harmless for the // other source_types where the test only cares about the 1930 side). - await getPool().query( - `INSERT INTO public.journal_entry_lines - (journal_entry_id, account_number, debit_amount, credit_amount) - VALUES ($1, '1930', $2, 0), - ($1, '2091', 0, $2)`, - [id, amount], - ) - return id + return insertAtomicPostedJournalEntry({ + userId: params.userId, + companyId: params.companyId, + fiscalPeriodId: params.fiscalPeriodId, + voucherNumber: params.voucherNumber, + entryDate: params.entryDate, + description: `Test ${params.sourceType}`, + sourceType: params.sourceType, + lines: [ + { accountNumber: '1930', debitAmount: amount, creditAmount: 0 }, + { accountNumber: '2091', debitAmount: 0, creditAmount: amount }, + ], + }) } describe('get_unlinked_gl_lines RPC: opening_balance exclusion', () => { diff --git a/tests/pg/gl_lines_rpc_tenant_guard.pg.test.ts b/tests/pg/gl_lines_rpc_tenant_guard.pg.test.ts index 8561177d..6cf48ee0 100644 --- a/tests/pg/gl_lines_rpc_tenant_guard.pg.test.ts +++ b/tests/pg/gl_lines_rpc_tenant_guard.pg.test.ts @@ -10,9 +10,11 @@ * the reconciliation cron) untouched. */ import { describe, it, expect } from 'vitest' -import { randomUUID } from 'node:crypto' import { getPool, withUserContext } from './setup' -import { seedCompany } from './fixtures' +import { + insertPostedJournalEntry as insertAtomicPostedJournalEntry, + seedCompany, +} from './fixtures' async function insertPostedJournalEntry(params: { userId: string @@ -22,23 +24,20 @@ async function insertPostedJournalEntry(params: { voucherNumber: number amount?: number }): Promise { - const id = randomUUID() const amount = params.amount ?? 1500 - 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, 'Bank tx', 'bank_transaction', 'posted')`, - [id, params.userId, params.companyId, params.fiscalPeriodId, params.voucherNumber, params.entryDate], - ) - await getPool().query( - `INSERT INTO public.journal_entry_lines - (journal_entry_id, account_number, debit_amount, credit_amount) - VALUES ($1, '1930', $2, 0), - ($1, '2091', 0, $2)`, - [id, amount], - ) - return id + return insertAtomicPostedJournalEntry({ + userId: params.userId, + companyId: params.companyId, + fiscalPeriodId: params.fiscalPeriodId, + voucherNumber: params.voucherNumber, + entryDate: params.entryDate, + description: 'Bank tx', + sourceType: 'bank_transaction', + lines: [ + { accountNumber: '1930', debitAmount: amount, creditAmount: 0 }, + { accountNumber: '2091', debitAmount: 0, creditAmount: amount }, + ], + }) } const UNLINKED = `SELECT journal_entry_id FROM public.get_unlinked_gl_lines($1)` diff --git a/tests/pg/kpi-report-aggregates-rpc.pg.test.ts b/tests/pg/kpi-report-aggregates-rpc.pg.test.ts index b6ed018b..5ae3f961 100644 --- a/tests/pg/kpi-report-aggregates-rpc.pg.test.ts +++ b/tests/pg/kpi-report-aggregates-rpc.pg.test.ts @@ -78,35 +78,49 @@ async function insertJournalEntry(params: { lines: Array<{ account: string; debit: number; credit: number }> }): Promise { const id = randomUUID() + const status = params.status ?? 'posted' + const client = await getPool().connect() // 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, reverses_id, correction_of_id) - VALUES ($1, $2, $3, $4, $5, 'A', $6, 'KPI RPC test', $7, $8, $9, $10)`, - [ - id, - params.userId, - params.companyId, - params.fiscalPeriodId, - params.voucherNumber, - params.entryDate ?? '2026-03-15', - params.sourceType ?? 'manual', - params.status ?? 'posted', - params.reversesId ?? null, - params.correctionOfId ?? null, - ], - ) - 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], + try { + await client.query('BEGIN') + await client.query( + `INSERT INTO public.journal_entries + (id, user_id, company_id, fiscal_period_id, voucher_number, voucher_series, + entry_date, description, source_type, status, reverses_id, correction_of_id) + VALUES ($1, $2, $3, $4, $5, 'A', $6, 'KPI RPC test', $7, $8, $9, $10)`, + [ + id, + params.userId, + params.companyId, + params.fiscalPeriodId, + params.voucherNumber, + params.entryDate ?? '2026-03-15', + params.sourceType ?? 'manual', + status, + params.reversesId ?? null, + params.correctionOfId ?? null, + ], ) + for (const line of params.lines) { + await client.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], + ) + } + if (status === 'posted') { + await client.query('SET CONSTRAINTS check_balance_on_posted_insert IMMEDIATE') + } + await client.query('COMMIT') + return id + } catch (error) { + await client.query('ROLLBACK').catch(() => {}) + throw error + } finally { + client.release() } - return id } async function seedCompany() { @@ -165,6 +179,8 @@ async function seedFullScenario() { lines: [ { account: '8310', debit: 0, credit: 200 }, { account: '8410', debit: 500, credit: 0 }, + // Balance outside classes 3-8 so the monthly assertions stay focused. + { account: '2999', debit: 0, credit: 300 }, ], }) @@ -265,7 +281,11 @@ describe('get_kpi_report_aggregates RPC', () => { await insertJournalEntry({ ...ctx, voucherNumber: 2, sourceType: 'storno', entryDate: '2026-04-02', reversesId: plainReversed, - lines: [{ account: '5010', debit: 0, credit: 100 }], + lines: [ + { account: '5010', debit: 0, credit: 100 }, + // Keep the posted storno balanced without changing the asserted P&L account. + { account: '2999', debit: 100, credit: 0 }, + ], }) const payload = await callRpc(ctx.companyId, ctx.fiscalPeriodId) diff --git a/tests/pg/ledger-deep-context-rpc.pg.test.ts b/tests/pg/ledger-deep-context-rpc.pg.test.ts index f2d7264f..9cb600e2 100644 --- a/tests/pg/ledger-deep-context-rpc.pg.test.ts +++ b/tests/pg/ledger-deep-context-rpc.pg.test.ts @@ -14,21 +14,7 @@ import { describe, it, expect, beforeAll } from 'vitest' import { randomUUID } from 'node:crypto' import { getPool } from './setup' -import { seedCompany, insertDraftJournalEntry } from './fixtures' - -async function insertLines( - journalEntryId: string, - lines: Array<{ account: string; debit: number; credit: number }>, -): Promise { - for (const line of lines) { - await getPool().query( - `INSERT INTO public.journal_entry_lines - (journal_entry_id, account_number, debit_amount, credit_amount) - VALUES ($1, $2, $3, $4)`, - [journalEntryId, line.account, line.debit, line.credit], - ) - } -} +import { seedCompany, insertPostedJournalEntry } from './fixtures' async function bookMerchant(params: { userId: string @@ -46,19 +32,18 @@ async function bookMerchant(params: { /** transactions.exchange_rate: the rate recorded on the row. Null = none. */ exchangeRate?: number | null }): Promise { - const entryId = await insertDraftJournalEntry({ + const entryId = await insertPostedJournalEntry({ userId: params.userId, companyId: params.companyId, fiscalPeriodId: params.fiscalPeriodId, entryDate: params.date, - status: 'posted', voucherNumber: params.voucherNumber, sourceType: 'bank_transaction', + lines: [ + { accountNumber: params.expenseAccount, debitAmount: params.amount, creditAmount: 0 }, + { accountNumber: '1930', debitAmount: 0, creditAmount: params.amount }, + ], }) - await insertLines(entryId, [ - { account: params.expenseAccount, debit: params.amount, credit: 0 }, - { account: '1930', debit: 0, credit: params.amount }, - ]) await getPool().query( `INSERT INTO public.transactions (id, company_id, user_id, currency, amount, amount_sek, exchange_rate, diff --git a/tests/pg/ledger-usage-stats-rpc.pg.test.ts b/tests/pg/ledger-usage-stats-rpc.pg.test.ts index 3d377825..cefc4f98 100644 --- a/tests/pg/ledger-usage-stats-rpc.pg.test.ts +++ b/tests/pg/ledger-usage-stats-rpc.pg.test.ts @@ -16,6 +16,7 @@ import { getPool } from './setup' import { seedCompany, insertDraftJournalEntry, + insertPostedJournalEntry, } from './fixtures' async function insertLines( @@ -79,22 +80,21 @@ async function bookMerchant(params: { voucherNumber: number sourceType?: string }): Promise { - const entryId = await insertDraftJournalEntry({ + const entryId = await insertPostedJournalEntry({ userId: params.userId, companyId: params.companyId, fiscalPeriodId: params.fiscalPeriodId, entryDate: params.date, - status: 'posted', voucherNumber: params.voucherNumber, sourceType: params.sourceType ?? 'bank_transaction', // Booked 3 days after the transaction: exercises the committed_at-based // lag (entry_date == transaction date would give 0). committedAt: plusDays(params.date, 3), + lines: [ + { accountNumber: params.expenseAccount, debitAmount: 500, creditAmount: 0 }, + { accountNumber: '1930', debitAmount: 0, creditAmount: 500 }, + ], }) - await insertLines(entryId, [ - { account: params.expenseAccount, debit: 500, credit: 0 }, - { account: '1930', debit: 0, credit: 500 }, - ]) await insertBookedTransaction({ companyId: params.companyId, userId: params.userId, @@ -274,15 +274,15 @@ describe('get_ledger_usage_stats', () => { { account: '4010', debit: 300, credit: 0 }, { account: '1930', debit: 0, credit: 300 }, ]) - const stornoId = await insertDraftJournalEntry({ + const stornoId = await insertPostedJournalEntry({ userId, companyId, fiscalPeriodId, - entryDate: '2026-06-15', status: 'posted', voucherNumber: 9, + entryDate: '2026-06-15', voucherNumber: 9, sourceType: 'storno', + lines: [ + { accountNumber: '1930', debitAmount: 300, creditAmount: 0 }, + { accountNumber: '4010', debitAmount: 0, creditAmount: 300 }, + ], }) - await insertLines(stornoId, [ - { account: '1930', debit: 300, credit: 0 }, - { account: '4010', debit: 0, credit: 300 }, - ]) // Legacy shape: a transaction still linked to the storno entry (predates // reverseEntry() unlinking). Must not create a counterparty pattern. await insertBookedTransaction({ @@ -313,18 +313,18 @@ describe('get_ledger_usage_stats', () => { // (regression for 20260708110000; observed on prod as 2614). let rcVoucher = 20 for (const d of ['2026-05-20', '2026-06-18']) { - const rcEntry = await insertDraftJournalEntry({ + const rcEntry = await insertPostedJournalEntry({ userId, companyId, fiscalPeriodId, - entryDate: d, status: 'posted', + entryDate: d, voucherNumber: rcVoucher++, sourceType: 'bank_transaction', committedAt: plusDays(d, 3), + lines: [ + { accountNumber: '5420', debitAmount: 500, creditAmount: 0 }, + { accountNumber: '2645', debitAmount: 125, creditAmount: 0 }, + { accountNumber: '2614', debitAmount: 0, creditAmount: 125 }, + { accountNumber: '1930', debitAmount: 0, creditAmount: 500 }, + ], }) - await insertLines(rcEntry, [ - { account: '5420', debit: 500, credit: 0 }, - { account: '2645', debit: 125, credit: 0 }, - { account: '2614', debit: 0, credit: 125 }, - { account: '1930', debit: 0, credit: 500 }, - ]) await insertBookedTransaction({ companyId, userId, journalEntryId: rcEntry, merchantName: 'GOOGLE WO', category: 'expense_software', date: d, diff --git a/tests/pg/link-voucher-rpcs-tenant-guard.pg.test.ts b/tests/pg/link-voucher-rpcs-tenant-guard.pg.test.ts index 2b95161f..2eaabce9 100644 --- a/tests/pg/link-voucher-rpcs-tenant-guard.pg.test.ts +++ b/tests/pg/link-voucher-rpcs-tenant-guard.pg.test.ts @@ -13,7 +13,7 @@ import { describe, it, expect } from 'vitest' import { randomUUID } from 'node:crypto' import { getPool, withUserContext } from './setup' -import { seedCompany } from './fixtures' +import { insertPostedJournalEntry, seedCompany } from './fixtures' let arrivalSeq = 0 @@ -79,33 +79,27 @@ async function seedPostedVoucher(params: { side: 'ar' | 'ap' amount?: number }): Promise { - const id = randomUUID() const amount = params.amount ?? 1000 - 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', '2026-05-05', 'Betalning', 'manual', 'posted')`, - [id, params.userId, params.companyId, params.fiscalPeriodId, Math.floor(Math.random() * 100000)], - ) - if (params.side === 'ar') { - await getPool().query( - `INSERT INTO public.journal_entry_lines - (journal_entry_id, account_number, debit_amount, credit_amount) - VALUES ($1, '1930', $2, 0), - ($1, '1510', 0, $2)`, - [id, amount], - ) - } else { - await getPool().query( - `INSERT INTO public.journal_entry_lines - (journal_entry_id, account_number, debit_amount, credit_amount) - VALUES ($1, '2440', $2, 0), - ($1, '1930', 0, $2)`, - [id, amount], - ) - } - return id + const lines = params.side === 'ar' + ? [ + { accountNumber: '1930', debitAmount: amount, creditAmount: 0 }, + { accountNumber: '1510', debitAmount: 0, creditAmount: amount }, + ] + : [ + { accountNumber: '2440', debitAmount: amount, creditAmount: 0 }, + { accountNumber: '1930', debitAmount: 0, creditAmount: amount }, + ] + + return insertPostedJournalEntry({ + userId: params.userId, + companyId: params.companyId, + fiscalPeriodId: params.fiscalPeriodId, + voucherNumber: Math.floor(Math.random() * 100000), + entryDate: '2026-05-05', + description: 'Betalning', + sourceType: 'manual', + lines, + }) } const LINK_INVOICE = `SELECT public.link_invoice_to_voucher($1, $2, $3, $4, $5) AS result` diff --git a/tests/pg/production-error-regressions.pg.test.ts b/tests/pg/production-error-regressions.pg.test.ts index a9464682..2330c767 100644 --- a/tests/pg/production-error-regressions.pg.test.ts +++ b/tests/pg/production-error-regressions.pg.test.ts @@ -1,7 +1,11 @@ -import { randomUUID } from 'node:crypto' import { describe, expect, it } from 'vitest' import { getPool } from './setup' -import { insertAuthUser, insertCompany, insertFiscalPeriod } from './fixtures' +import { + insertAuthUser, + insertCompany, + insertFiscalPeriod, + insertPostedJournalEntry, +} from './fixtures' async function insertEntry(params: { userId: string @@ -11,31 +15,20 @@ async function insertEntry(params: { entryDate: string lines: Array<{ account: string; debit: number; credit: number }> }): Promise { - const entryId = randomUUID() - 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, 'Production regression test', 'manual', 'posted')`, - [ - entryId, - params.userId, - params.companyId, - params.fiscalPeriodId, - params.voucherNumber, - params.entryDate, - ], - ) - - 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)`, - [entryId, line.account, line.debit, line.credit], - ) - } - return entryId + return insertPostedJournalEntry({ + userId: params.userId, + companyId: params.companyId, + fiscalPeriodId: params.fiscalPeriodId, + voucherNumber: params.voucherNumber, + entryDate: params.entryDate, + description: 'Production regression test', + sourceType: 'manual', + lines: params.lines.map((line) => ({ + accountNumber: line.account, + debitAmount: line.debit, + creditAmount: line.credit, + })), + }) } async function seedCompany() { diff --git a/tests/pg/vat-declaration-totals-rpc.pg.test.ts b/tests/pg/vat-declaration-totals-rpc.pg.test.ts index c918b02f..59a8a830 100644 --- a/tests/pg/vat-declaration-totals-rpc.pg.test.ts +++ b/tests/pg/vat-declaration-totals-rpc.pg.test.ts @@ -20,7 +20,12 @@ import { describe, it, expect } from 'vitest' import { randomUUID } from 'node:crypto' import { getPool } from './setup' -import { insertAuthUser, insertCompany, insertFiscalPeriod } from './fixtures' +import { + insertAuthUser, + insertCompany, + insertFiscalPeriod, + insertPostedJournalEntry, +} 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 @@ -28,6 +33,9 @@ import { insertAuthUser, insertCompany, insertFiscalPeriod } from './fixtures' const RUTA_ACCOUNTS = ['2611', '2621', '2641', '2645', '3001'] const NET_ACCOUNTS = ['2650', '1650'] const ALL_ACCOUNTS = [...RUTA_ACCOUNTS, ...NET_ACCOUNTS] +// This account keeps intentionally narrow VAT fixtures balanced without +// entering the account set asserted by this read-side RPC suite. +const VAT_FIXTURE_BALANCING_ACCOUNT = '2999' interface RpcPayload { totals: Array<{ account_number: string; debit: number; credit: number }> @@ -68,6 +76,23 @@ async function insertJournalEntry(params: { entryDate?: string lines: Array<{ account: string; debit: number; credit: number }> }): Promise { + if ((params.status ?? 'posted') === 'posted') { + return insertPostedJournalEntry({ + userId: params.userId, + companyId: params.companyId, + fiscalPeriodId: params.fiscalPeriodId, + voucherNumber: params.voucherNumber, + entryDate: params.entryDate ?? '2026-03-15', + description: 'VAT RPC test', + sourceType: params.sourceType ?? 'manual', + lines: params.lines.map((line) => ({ + accountNumber: line.account, + debitAmount: line.debit, + creditAmount: line.credit, + })), + }) + } + const id = randomUUID() // Insert directly, bypassing commit_journal_entry's voucher sequencing — // fine for a read-side RPC that only aggregates line/account references. @@ -151,11 +176,17 @@ describe('get_vat_declaration_totals RPC', () => { }) await insertJournalEntry({ ...ctx, voucherNumber: 2, entryDate: '2025-12-31', - lines: [{ account: '2611', debit: 0, credit: 777 }], + lines: [ + { account: '2611', debit: 0, credit: 777 }, + { account: VAT_FIXTURE_BALANCING_ACCOUNT, debit: 777, credit: 0 }, + ], }) await insertJournalEntry({ ...ctx, voucherNumber: 3, entryDate: '2026-06-30', - lines: [{ account: '2611', debit: 0, credit: 100 }], + lines: [ + { account: '2611', debit: 0, credit: 100 }, + { account: VAT_FIXTURE_BALANCING_ACCOUNT, debit: 100, credit: 0 }, + ], }) const payload = await callRpc(ctx.companyId, '2026-01-01', '2026-12-31') @@ -168,7 +199,10 @@ describe('get_vat_declaration_totals RPC', () => { await insertJournalEntry({ ...ctx, voucherNumber: 1, sourceType: 'invoice_created', - lines: [{ account: '2611', debit: 0, credit: 2500 }], + lines: [ + { account: '2611', debit: 0, credit: 2500 }, + { account: VAT_FIXTURE_BALANCING_ACCOUNT, debit: 2500, credit: 0 }, + ], }) // The app's own settlement flow: tagged, filtered by source_type alone. await insertJournalEntry({ @@ -198,6 +232,7 @@ describe('get_vat_declaration_totals RPC', () => { { account: '2611', debit: 0, credit: 2500 }, { account: '2641', debit: 1000, credit: 0 }, { account: '3001', debit: 0, credit: 10000 }, + { account: VAT_FIXTURE_BALANCING_ACCOUNT, debit: 11500, credit: 0 }, ], }) // Manual settlement clearing the period to 2650, booked without the @@ -236,7 +271,10 @@ describe('get_vat_declaration_totals RPC', () => { await insertJournalEntry({ ...ctx, voucherNumber: 1, sourceType: 'invoice_created', - lines: [{ account: '2611', debit: 0, credit: 100 }], + lines: [ + { account: '2611', debit: 0, credit: 100 }, + { account: VAT_FIXTURE_BALANCING_ACCOUNT, debit: 100, credit: 0 }, + ], }) // The tagged settlement itself is filtered by source_type; its storno // reversal is untagged and would otherwise re-credit 2611. @@ -281,7 +319,10 @@ describe('get_vat_declaration_totals RPC', () => { await insertJournalEntry({ ...ctx, voucherNumber: 1, sourceType: 'invoice_created', - lines: [{ account: '2611', debit: 0, credit: 100 }], + lines: [ + { account: '2611', debit: 0, credit: 100 }, + { account: VAT_FIXTURE_BALANCING_ACCOUNT, debit: 100, credit: 0 }, + ], }) // Paying last period's VAT debt: 2650 against the bank account. await insertJournalEntry({ @@ -305,11 +346,17 @@ describe('get_vat_declaration_totals RPC', () => { await insertJournalEntry({ ...a, voucherNumber: 1, - lines: [{ account: '2611', debit: 0, credit: 100 }], + lines: [ + { account: '2611', debit: 0, credit: 100 }, + { account: VAT_FIXTURE_BALANCING_ACCOUNT, debit: 100, credit: 0 }, + ], }) await insertJournalEntry({ ...b, voucherNumber: 1, - lines: [{ account: '2611', debit: 0, credit: 999 }], + lines: [ + { account: '2611', debit: 0, credit: 999 }, + { account: VAT_FIXTURE_BALANCING_ACCOUNT, debit: 999, credit: 0 }, + ], }) const payload = await callRpc(a.companyId) diff --git a/tests/pg/vat-totals-closing-entry.pg.test.ts b/tests/pg/vat-totals-closing-entry.pg.test.ts index 725c2795..f66163a4 100644 --- a/tests/pg/vat-totals-closing-entry.pg.test.ts +++ b/tests/pg/vat-totals-closing-entry.pg.test.ts @@ -76,34 +76,48 @@ async function insertEntry(params: { lines: Array<{ account: string; debit: number; credit: number }> }): Promise { const id = randomUUID() + const status = params.status ?? 'posted' + const client = await getPool().connect() // Inserted directly, bypassing commit_journal_entry's voucher sequencing: // this is a read-side aggregate that only reads lines and account numbers. - 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, reverses_id) - VALUES ($1, $2, $3, $4, $5, 'A', $6, 'VAT closing-entry test', $7, $8, $9)`, - [ - id, - params.userId, - params.companyId, - params.fiscalPeriodId, - params.voucherNumber, - params.entryDate, - params.sourceType ?? 'manual', - params.status ?? 'posted', - params.reversesId ?? null, - ], - ) - 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], + try { + await client.query('BEGIN') + await client.query( + `INSERT INTO public.journal_entries + (id, user_id, company_id, fiscal_period_id, voucher_number, voucher_series, + entry_date, description, source_type, status, reverses_id) + VALUES ($1, $2, $3, $4, $5, 'A', $6, 'VAT closing-entry test', $7, $8, $9)`, + [ + id, + params.userId, + params.companyId, + params.fiscalPeriodId, + params.voucherNumber, + params.entryDate, + params.sourceType ?? 'manual', + status, + params.reversesId ?? null, + ], ) + for (const line of params.lines) { + await client.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], + ) + } + if (status === 'posted') { + await client.query('SET CONSTRAINTS check_balance_on_posted_insert IMMEDIATE') + } + await client.query('COMMIT') + return id + } catch (error) { + await client.query('ROLLBACK').catch(() => {}) + throw error + } finally { + client.release() } - return id } /** diff --git a/tests/pg/verifikat-without-documents-rpc.pg.test.ts b/tests/pg/verifikat-without-documents-rpc.pg.test.ts index 9984fbf6..9a8aba61 100644 --- a/tests/pg/verifikat-without-documents-rpc.pg.test.ts +++ b/tests/pg/verifikat-without-documents-rpc.pg.test.ts @@ -5,6 +5,7 @@ import { seedCompany, insertAuthUser, insertDraftJournalEntry, + insertPostedJournalEntry, insertBalancedLines, } from './fixtures' @@ -84,30 +85,34 @@ describe('verifikat_without_documents RPC', () => { // 6 posted entries without documents: amounts 100..600, dates ascending for (let i = 1; i <= 6; i++) { - const id = await insertDraftJournalEntry({ + const id = await insertPostedJournalEntry({ userId, companyId, fiscalPeriodId, - status: 'posted', voucherNumber: i, entryDate: `2026-06-0${i}`, description: `no-doc ${i}`, + lines: [ + { accountNumber: '1930', debitAmount: i * 100, creditAmount: 0 }, + { accountNumber: '3001', debitAmount: 0, creditAmount: i * 100 }, + ], }) - await insertBalancedLines(id, i * 100) seeded.set(i, { id, amount: i * 100 }) } // Posted entry WITH a document: must never appear - const withDoc = await insertDraftJournalEntry({ + const withDoc = await insertPostedJournalEntry({ userId, companyId, fiscalPeriodId, - status: 'posted', voucherNumber: 7, entryDate: '2026-06-07', description: 'has doc', + lines: [ + { accountNumber: '1930', debitAmount: 700, creditAmount: 0 }, + { accountNumber: '3001', debitAmount: 0, creditAmount: 700 }, + ], }) - await insertBalancedLines(withDoc, 700) await attachDocument({ userId, companyId, journalEntryId: withDoc }) // Draft entry: must never appear