From 18cdba35746fef901c67bbd5c29867602c9ad161 Mon Sep 17 00:00:00 2001 From: Mattsson <111893710+mattssonn@users.noreply.github.com> Date: Sun, 2 Aug 2026 20:39:29 +0200 Subject: [PATCH] fix: make out-of-order SIE opening balances atomic (#1334) * fix: preserve SIE IB on out-of-order imports * fix: make SIE opening balance replacement atomic * test: seed accounts for atomic IB pg coverage * test: complete atomic IB pg fixtures * fix(import): avoid IB resync across fiscal-year gaps * test(import): mirror PostgREST date values in pg adapter * fix(import): address opening balance review feedback --- DECISIONS.md | 3 + .../opening-balance-replacement.pg.test.ts | 652 ++++++++++++++++ .../opening-balance-replacement.test.ts | 185 +++++ lib/bookkeeping/engine.ts | 187 ++++- lib/bookkeeping/errors.ts | 1 + .../__tests__/sie-import-derived-ib.test.ts | 228 +++++- .../sie-import-out-of-order.pg.test.ts | 710 ++++++++++++++++++ lib/import/__tests__/sie-import.test.ts | 59 +- lib/import/sie-import.ts | 93 +-- ...047_atomic_opening_balance_replacement.sql | 399 ++++++++++ ...den_atomic_opening_balance_replacement.sql | 407 ++++++++++ 11 files changed, 2869 insertions(+), 55 deletions(-) create mode 100644 lib/bookkeeping/__tests__/opening-balance-replacement.pg.test.ts create mode 100644 lib/bookkeeping/__tests__/opening-balance-replacement.test.ts create mode 100644 lib/import/__tests__/sie-import-out-of-order.pg.test.ts create mode 100644 supabase/migrations/20260801221047_atomic_opening_balance_replacement.sql create mode 100644 supabase/migrations/20260801222035_harden_atomic_opening_balance_replacement.sql diff --git a/DECISIONS.md b/DECISIONS.md index d8fa7e34..a2789c23 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -735,3 +735,6 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-01] MCP page offsets are declared as non-negative integers and defensively floored before PostgREST range calls: fractional offsets cannot name a stable row boundary and can produce invalid range bounds when execution bypasses schema validation. [2026-08-01] Paginated MCP invoice tools fetch one lookahead row and use it when Supabase omits the exact count: returning a conservative next_offset avoids falsely declaring the current page terminal and silently truncating callers, while exact-count responses and page sizes remain unchanged. +[2026-08-01] Out-of-order SIE IB activity is bounded by the target fiscal-period end, not its start: this excludes later-first imports while preserving same-period continuation suppression; successor IB resync checks the current error state plus a real target-period entry because result.success is finalized later, keeping replacement on a new engine voucher plus storno without letting a no-op import succeed through resync alone. +[2026-08-01] Successor SIE IB replacement uses a specialized engine RPC instead of loosening the owner-only generic relink RPC: non-viewer members and scoped service-role imports are supported, while one period-row lock and expected-pointer CAS make the replacement voucher, storno, reversal status, pointer swap, and voucher sequence increments commit or roll back together. +[2026-08-02] Out-of-order SIE IB resync requires exact date adjacency: the nearest later fiscal period can sit beyond a missing middle year, and replacing its authoritative IB with a non-adjacent UB would make that later period temporarily wrong until the gap was imported. diff --git a/lib/bookkeeping/__tests__/opening-balance-replacement.pg.test.ts b/lib/bookkeeping/__tests__/opening-balance-replacement.pg.test.ts new file mode 100644 index 00000000..39708b82 --- /dev/null +++ b/lib/bookkeeping/__tests__/opening-balance-replacement.pg.test.ts @@ -0,0 +1,652 @@ +import { randomUUID } from 'node:crypto' +import { describe, expect, it } from 'vitest' +import type { PoolClient } from 'pg' +import { getClient, getPool } from '@/tests/pg/setup' +import { + insertAuthUser, + insertCompanyMember, + seedCompany, +} from '@/tests/pg/fixtures' + +type ReplacementResult = { + new_entry_id: string + storno_entry_id: string + new_voucher_number: number + storno_voucher_number: number +} + +type SeededOpeningBalance = { + oldEntryId: string + lines: Array> +} + +async function seedOpeningBalance(params: { + userId: string + companyId: string + fiscalPeriodId: string + debit?: number + link?: boolean +}): Promise { + const amount = params.debit ?? 100 + await getPool().query( + `INSERT INTO public.chart_of_accounts + (user_id, company_id, account_number, account_name, account_class, + account_type, normal_balance, is_active) + VALUES + ($1, $2, '1930', 'Bankkonto', 1, 'asset', 'debit', true), + ($1, $2, '2010', 'Eget kapital', 2, 'equity', 'credit', true) + ON CONFLICT (company_id, account_number) DO NOTHING`, + [params.userId, params.companyId], + ) + const accounts = await getPool().query<{ id: string; account_number: string }>( + `SELECT id, account_number + FROM public.chart_of_accounts + WHERE company_id = $1 + AND account_number = ANY($2::text[]) + ORDER BY account_number`, + [params.companyId, ['1930', '2010']], + ) + const accountIds = new Map(accounts.rows.map((account) => [account.account_number, account.id])) + + expect(accountIds.get('1930')).toBeTruthy() + 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, + ], + ) + if (params.link !== false) { + await getPool().query( + `UPDATE public.fiscal_periods + SET opening_balance_entry_id = $1, + opening_balances_set = true + WHERE id = $2`, + [oldEntryId, params.fiscalPeriodId], + ) + } + + return { + oldEntryId, + lines: [ + { + account_number: '1930', + account_id: accountIds.get('1930'), + debit_amount: 150, + credit_amount: 0, + currency: 'SEK', + amount_in_currency: null, + exchange_rate: null, + line_description: 'IB 1930', + tax_code: null, + dimensions: {}, + sort_order: 0, + }, + { + account_number: '2010', + account_id: accountIds.get('2010'), + debit_amount: 0, + credit_amount: 150, + currency: 'SEK', + amount_in_currency: null, + exchange_rate: null, + line_description: 'IB 2010', + tax_code: null, + dimensions: {}, + sort_order: 1, + }, + ], + } +} + +async function setRole( + client: PoolClient, + role: 'authenticated' | 'service_role', + userId?: string, +): Promise { + const claims = userId + ? { sub: userId, role } + : { role } + await client.query(`SELECT set_config('request.jwt.claims', $1, true)`, [JSON.stringify(claims)]) + await client.query(`SELECT set_config('request.jwt.claim.role', $1, true)`, [role]) + await client.query(`SELECT set_config('request.jwt.claim.sub', $1, true)`, [userId ?? '']) + const roleStatements = { + authenticated: 'SET LOCAL ROLE authenticated', + service_role: 'SET LOCAL ROLE service_role', + } as const + await client.query(roleStatements[role]) +} + +async function runAs( + role: 'authenticated' | 'service_role', + userId: string | undefined, + operation: (client: PoolClient) => Promise, +): Promise { + const client = await getClient() + try { + await client.query('BEGIN') + await setRole(client, role, userId) + const result = await operation(client) + await client.query('COMMIT') + return result + } catch (error) { + await client.query('ROLLBACK').catch(() => {}) + throw error + } finally { + client.release() + } +} + +async function callReplacement( + client: PoolClient, + params: { + companyId: string + fiscalPeriodId: string + expectedOldEntryId: string + userId: string + lines: Array> + }, +): Promise { + const result = await client.query( + `SELECT * FROM public.commit_opening_balance_replacement( + $1::uuid, $2::uuid, $3::uuid, $4::uuid, '2026-01-01'::date, + 'Replacement opening balance', 'A', $5::jsonb, NULL, NULL + )`, + [ + params.companyId, + params.fiscalPeriodId, + params.expectedOldEntryId, + params.userId, + JSON.stringify(params.lines), + ], + ) + return result.rows[0]! +} + +async function expectUnchanged(params: { + companyId: string + fiscalPeriodId: string + oldEntryId: string + entryCount: number + lastNumber: number +}): Promise { + const state = await getPool().query<{ + opening_balance_entry_id: string | null + opening_balances_set: boolean + old_status: string + entry_count: number + last_number: number + }>( + `SELECT fp.opening_balance_entry_id, + fp.opening_balances_set, + old.status AS old_status, + (SELECT count(*)::int + FROM public.journal_entries je + WHERE je.company_id = $1) AS entry_count, + sequence.last_number + FROM public.fiscal_periods fp + JOIN public.journal_entries old ON old.id = $2 + JOIN public.voucher_sequences sequence + ON sequence.company_id = $1 + AND sequence.fiscal_period_id = fp.id + AND sequence.voucher_series = 'A' + WHERE fp.id = $3`, + [params.companyId, params.oldEntryId, params.fiscalPeriodId], + ) + expect(state.rows[0]).toEqual({ + opening_balance_entry_id: params.oldEntryId, + opening_balances_set: true, + old_status: 'posted', + entry_count: params.entryCount, + last_number: params.lastNumber, + }) +} + +describe('commit_opening_balance_replacement', () => { + it('atomically replaces the IB for a member without a duplicate balance', async () => { + const { userId: ownerId, companyId, fiscalPeriodId } = await seedCompany() + const memberId = await insertAuthUser() + await insertCompanyMember({ companyId, userId: memberId, role: 'member' }) + const seeded = await seedOpeningBalance({ + userId: ownerId, + companyId, + fiscalPeriodId, + }) + + const outcome = await runAs('authenticated', memberId, (client) => callReplacement(client, { + companyId, + fiscalPeriodId, + expectedOldEntryId: seeded.oldEntryId, + userId: memberId, + lines: seeded.lines, + })) + + const state = await getPool().query<{ + id: string + status: string + source_type: string + reverses_id: string | null + reversed_by_id: string | null + user_id: string + }>( + `SELECT id, status, source_type, reverses_id, reversed_by_id, user_id + FROM public.journal_entries + WHERE id = ANY($1::uuid[])`, + [[seeded.oldEntryId, outcome.new_entry_id, outcome.storno_entry_id]], + ) + expect(state.rows).toEqual(expect.arrayContaining([ + expect.objectContaining({ + id: seeded.oldEntryId, + status: 'reversed', + reversed_by_id: outcome.storno_entry_id, + }), + expect.objectContaining({ + id: outcome.new_entry_id, + status: 'posted', + source_type: 'opening_balance', + user_id: memberId, + }), + expect.objectContaining({ + id: outcome.storno_entry_id, + status: 'posted', + source_type: 'storno', + reverses_id: seeded.oldEntryId, + user_id: memberId, + }), + ])) + + const period = await getPool().query<{ opening_balance_entry_id: string }>( + `SELECT opening_balance_entry_id + FROM public.fiscal_periods + WHERE id = $1`, + [fiscalPeriodId], + ) + expect(period.rows[0]!.opening_balance_entry_id).toBe(outcome.new_entry_id) + + const net = await getPool().query<{ account_number: string; amount: number }>( + `SELECT line.account_number, + sum(line.debit_amount - line.credit_amount)::float8 AS amount + FROM public.journal_entry_lines line + JOIN public.journal_entries entry ON entry.id = line.journal_entry_id + WHERE entry.id = ANY($1::uuid[]) + AND entry.status IN ('posted', 'reversed') + GROUP BY line.account_number + ORDER BY line.account_number`, + [[seeded.oldEntryId, outcome.new_entry_id, outcome.storno_entry_id]], + ) + expect(net.rows).toEqual([ + { account_number: '1930', amount: 150 }, + { account_number: '2010', amount: -150 }, + ]) + }) + + it('supports the service-role SIE path with a scoped member actor', async () => { + const { userId, companyId, fiscalPeriodId } = await seedCompany() + const seeded = await seedOpeningBalance({ userId, companyId, fiscalPeriodId }) + + const outcome = await runAs('service_role', undefined, (client) => callReplacement(client, { + companyId, + fiscalPeriodId, + expectedOldEntryId: seeded.oldEntryId, + userId, + lines: seeded.lines, + })) + + expect(outcome.new_entry_id).toBeTruthy() + expect(outcome.storno_entry_id).toBeTruthy() + expect(outcome.new_voucher_number).toBe(2) + expect(outcome.storno_voucher_number).toBe(3) + }) + + it('rejects a viewer before writing any replacement entries', async () => { + const { userId: ownerId, companyId, fiscalPeriodId } = await seedCompany() + const viewerId = await insertAuthUser() + await insertCompanyMember({ companyId, userId: viewerId, role: 'viewer' }) + const seeded = await seedOpeningBalance({ + userId: ownerId, + companyId, + fiscalPeriodId, + }) + const before = await getPool().query<{ count: number }>( + `SELECT count(*)::int AS count FROM public.journal_entries WHERE company_id = $1`, + [companyId], + ) + + await expect(runAs('authenticated', viewerId, (client) => callReplacement(client, { + companyId, + fiscalPeriodId, + expectedOldEntryId: seeded.oldEntryId, + userId: viewerId, + lines: seeded.lines, + }))).rejects.toMatchObject({ code: '42501' }) + + await expectUnchanged({ + companyId, + fiscalPeriodId, + oldEntryId: seeded.oldEntryId, + entryCount: before.rows[0]!.count, + lastNumber: 1, + }) + }) + + it('rejects authenticated and service-role writes to an archived company', async () => { + const { userId: ownerId, companyId, fiscalPeriodId } = await seedCompany() + const memberId = await insertAuthUser() + await insertCompanyMember({ companyId, userId: memberId, role: 'member' }) + const seeded = await seedOpeningBalance({ + userId: ownerId, + companyId, + fiscalPeriodId, + }) + await getPool().query( + `UPDATE public.companies SET archived_at = now() WHERE id = $1`, + [companyId], + ) + const before = await getPool().query<{ count: number }>( + `SELECT count(*)::int AS count FROM public.journal_entries WHERE company_id = $1`, + [companyId], + ) + + const callers: Array<{ + role: 'authenticated' | 'service_role' + jwtUserId: string | undefined + }> = [ + { role: 'authenticated', jwtUserId: memberId }, + { role: 'service_role', jwtUserId: undefined }, + ] + + for (const caller of callers) { + await expect(runAs(caller.role, caller.jwtUserId, (client) => callReplacement(client, { + companyId, + fiscalPeriodId, + expectedOldEntryId: seeded.oldEntryId, + userId: memberId, + lines: seeded.lines, + }))).rejects.toMatchObject({ code: '42501' }) + + await expectUnchanged({ + companyId, + fiscalPeriodId, + oldEntryId: seeded.oldEntryId, + entryCount: before.rows[0]!.count, + lastNumber: 1, + }) + } + }) + + it('rejects a locked period before writing any replacement entries', async () => { + const { userId, companyId, fiscalPeriodId } = await seedCompany() + const seeded = await seedOpeningBalance({ userId, companyId, fiscalPeriodId }) + await getPool().query( + `UPDATE public.fiscal_periods SET locked_at = now() WHERE id = $1`, + [fiscalPeriodId], + ) + const before = await getPool().query<{ count: number }>( + `SELECT count(*)::int AS count FROM public.journal_entries WHERE company_id = $1`, + [companyId], + ) + + await expect(runAs('authenticated', userId, (client) => callReplacement(client, { + companyId, + fiscalPeriodId, + expectedOldEntryId: seeded.oldEntryId, + userId, + lines: seeded.lines, + }))).rejects.toThrow(/locked\/closed fiscal period/) + + await getPool().query( + `UPDATE public.fiscal_periods SET locked_at = NULL WHERE id = $1`, + [fiscalPeriodId], + ) + await expectUnchanged({ + companyId, + fiscalPeriodId, + oldEntryId: seeded.oldEntryId, + entryCount: before.rows[0]!.count, + lastNumber: 1, + }) + }) + + it('rejects a company lock date before writing any replacement entries', async () => { + const { userId, companyId, fiscalPeriodId } = await seedCompany() + const seeded = await seedOpeningBalance({ userId, companyId, fiscalPeriodId }) + await getPool().query( + `INSERT INTO public.company_settings + (user_id, company_id, bookkeeping_locked_through) + VALUES ($1, $2, '2026-01-01') + ON CONFLICT (company_id) + DO UPDATE SET bookkeeping_locked_through = EXCLUDED.bookkeeping_locked_through`, + [userId, companyId], + ) + const before = await getPool().query<{ count: number }>( + `SELECT count(*)::int AS count FROM public.journal_entries WHERE company_id = $1`, + [companyId], + ) + + await expect(runAs('authenticated', userId, (client) => callReplacement(client, { + companyId, + fiscalPeriodId, + expectedOldEntryId: seeded.oldEntryId, + userId, + lines: seeded.lines, + }))).rejects.toThrow(/Bookkeeping is locked through/) + + await getPool().query( + `UPDATE public.company_settings + SET bookkeeping_locked_through = NULL + WHERE company_id = $1`, + [companyId], + ) + await expectUnchanged({ + companyId, + fiscalPeriodId, + oldEntryId: seeded.oldEntryId, + entryCount: before.rows[0]!.count, + lastNumber: 1, + }) + }) + + it('uses compare-and-swap protection when the period pointer changed', async () => { + const { userId, companyId, fiscalPeriodId } = await seedCompany() + const seeded = await seedOpeningBalance({ userId, companyId, fiscalPeriodId }) + const competing = await seedOpeningBalance({ + userId, + companyId, + fiscalPeriodId, + debit: 125, + link: false, + }) + await getPool().query( + `UPDATE public.fiscal_periods + SET opening_balances_set = false + WHERE id = $1`, + [fiscalPeriodId], + ) + await getPool().query( + `UPDATE public.fiscal_periods + SET opening_balance_entry_id = $1, + opening_balances_set = true + WHERE id = $2`, + [competing.oldEntryId, fiscalPeriodId], + ) + const before = await getPool().query<{ count: number }>( + `SELECT count(*)::int AS count FROM public.journal_entries WHERE company_id = $1`, + [companyId], + ) + + await expect(runAs('authenticated', userId, (client) => callReplacement(client, { + companyId, + fiscalPeriodId, + expectedOldEntryId: seeded.oldEntryId, + userId, + lines: seeded.lines, + }))).rejects.toMatchObject({ code: '40001' }) + + const state = await getPool().query<{ + opening_balance_entry_id: string + original_status: string + entry_count: number + }>( + `SELECT fp.opening_balance_entry_id, + original.status AS original_status, + (SELECT count(*)::int FROM public.journal_entries WHERE company_id = $1) AS entry_count + FROM public.fiscal_periods fp + JOIN public.journal_entries original ON original.id = $2 + WHERE fp.id = $3`, + [companyId, seeded.oldEntryId, fiscalPeriodId], + ) + expect(state.rows[0]).toEqual({ + opening_balance_entry_id: competing.oldEntryId, + original_status: 'posted', + entry_count: before.rows[0]!.count, + }) + }) + + it('rolls back vouchers, storno, pointer, status, and sequence on a late failure', async () => { + const { userId, companyId, fiscalPeriodId } = await seedCompany() + const seeded = await seedOpeningBalance({ userId, companyId, fiscalPeriodId }) + const before = await getPool().query<{ count: number }>( + `SELECT count(*)::int AS count FROM public.journal_entries WHERE company_id = $1`, + [companyId], + ) + const client = await getClient() + + try { + await client.query('BEGIN') + await client.query(` + CREATE FUNCTION public.test_fail_atomic_ib_pointer_swap() + RETURNS trigger + LANGUAGE plpgsql + AS $trigger$ + BEGIN + IF NEW.opening_balance_entry_id IS DISTINCT FROM OLD.opening_balance_entry_id THEN + RAISE EXCEPTION 'forced late pointer failure'; + END IF; + RETURN NEW; + END; + $trigger$ + `) + await client.query(` + CREATE TRIGGER test_fail_atomic_ib_pointer_swap + BEFORE UPDATE ON public.fiscal_periods + FOR EACH ROW + EXECUTE FUNCTION public.test_fail_atomic_ib_pointer_swap() + `) + await setRole(client, 'authenticated', userId) + await client.query('SAVEPOINT before_replacement') + + await expect(callReplacement(client, { + companyId, + fiscalPeriodId, + expectedOldEntryId: seeded.oldEntryId, + userId, + lines: seeded.lines, + })).rejects.toThrow(/forced late pointer failure/) + + await client.query('ROLLBACK TO SAVEPOINT before_replacement') + await client.query('RESET ROLE') + + const state = await client.query<{ + opening_balance_entry_id: string + opening_balances_set: boolean + old_status: string + entry_count: number + last_number: number + }>( + `SELECT fp.opening_balance_entry_id, + fp.opening_balances_set, + old.status AS old_status, + (SELECT count(*)::int FROM public.journal_entries WHERE company_id = $1) AS entry_count, + sequence.last_number + FROM public.fiscal_periods fp + JOIN public.journal_entries old ON old.id = $2 + JOIN public.voucher_sequences sequence + ON sequence.company_id = $1 + AND sequence.fiscal_period_id = fp.id + AND sequence.voucher_series = 'A' + WHERE fp.id = $3`, + [companyId, seeded.oldEntryId, fiscalPeriodId], + ) + expect(state.rows[0]).toEqual({ + opening_balance_entry_id: seeded.oldEntryId, + opening_balances_set: true, + old_status: 'posted', + entry_count: before.rows[0]!.count, + last_number: 1, + }) + } finally { + await client.query('ROLLBACK').catch(() => {}) + client.release() + } + }) + + it('grants execution only to authenticated and service-role callers', async () => { + const privileges = await getPool().query<{ + authenticated_can: boolean + service_role_can: boolean + anon_can: boolean + }>( + `SELECT + has_function_privilege( + 'authenticated', + 'public.commit_opening_balance_replacement(uuid,uuid,uuid,uuid,date,text,text,jsonb,text,text)', + 'EXECUTE' + ) AS authenticated_can, + has_function_privilege( + 'service_role', + 'public.commit_opening_balance_replacement(uuid,uuid,uuid,uuid,date,text,text,jsonb,text,text)', + 'EXECUTE' + ) AS service_role_can, + has_function_privilege( + 'anon', + 'public.commit_opening_balance_replacement(uuid,uuid,uuid,uuid,date,text,text,jsonb,text,text)', + 'EXECUTE' + ) AS anon_can`, + ) + expect(privileges.rows[0]).toEqual({ + authenticated_can: true, + service_role_can: true, + anon_can: false, + }) + }) +}) diff --git a/lib/bookkeeping/__tests__/opening-balance-replacement.test.ts b/lib/bookkeeping/__tests__/opening-balance-replacement.test.ts new file mode 100644 index 00000000..8ef8b223 --- /dev/null +++ b/lib/bookkeeping/__tests__/opening-balance-replacement.test.ts @@ -0,0 +1,185 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { eventBus } from '@/lib/events' +import { replaceOpeningBalanceEntry } from '../engine' +import { + AccountsNotInChartError, + BookkeepingDatabaseError, + JournalEntryNotBalancedError, +} from '../errors' + +vi.mock('@/lib/events', () => ({ + eventBus: { emit: vi.fn().mockResolvedValue([]) }, +})) + +vi.mock('@/lib/bookkeeping/account-backfill', () => ({ + backfillStandardBASAccounts: vi.fn().mockResolvedValue([]), +})) + +function thenableChain(result: unknown) { + const chain: Record = {} + for (const method of ['select', 'eq', 'in']) { + chain[method] = vi.fn().mockReturnValue(chain) + } + chain.then = (resolve: (value: unknown) => void) => resolve(result) + return chain +} + +const input = { + fiscal_period_id: 'period-2026', + entry_date: '2026-01-01', + description: 'Replacement opening balance', + source_type: 'opening_balance' as const, + voucher_series: 'A', + lines: [ + { account_number: '1930', debit_amount: 150, credit_amount: 0 }, + { account_number: '2010', debit_amount: 0, credit_amount: 150 }, + ], +} + +describe('replaceOpeningBalanceEntry', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('passes resolved lines to the atomic RPC and emits committed/reversed events', async () => { + const entries = [ + { id: 'old-entry', status: 'reversed', lines: [] }, + { id: 'new-entry', status: 'posted', source_type: 'opening_balance', lines: [] }, + { id: 'storno-entry', status: 'posted', source_type: 'storno', lines: [] }, + ] + const rpc = vi.fn().mockResolvedValue({ + data: [{ + new_entry_id: 'new-entry', + storno_entry_id: 'storno-entry', + new_voucher_number: 2, + storno_voucher_number: 3, + }], + error: null, + }) + const supabase = { + rpc, + from: vi.fn().mockImplementation((table: string) => { + if (table === 'chart_of_accounts') { + return thenableChain({ + data: [ + { id: 'account-1930', account_number: '1930' }, + { id: 'account-2010', account_number: '2010' }, + ], + error: null, + }) + } + if (table === 'journal_entries') { + return thenableChain({ data: entries, error: null }) + } + throw new Error(`Unexpected table: ${table}`) + }), + } + + const result = await replaceOpeningBalanceEntry( + supabase as never, + 'company-1', + 'user-1', + 'old-entry', + input, + ) + + expect(result).toEqual({ + newEntryId: 'new-entry', + stornoEntryId: 'storno-entry', + newVoucherNumber: 2, + stornoVoucherNumber: 3, + }) + expect(rpc).toHaveBeenCalledWith('commit_opening_balance_replacement', expect.objectContaining({ + p_company_id: 'company-1', + p_period_id: 'period-2026', + p_expected_old_entry_id: 'old-entry', + p_user_id: 'user-1', + p_entry_date: '2026-01-01', + p_voucher_series: 'A', + p_lines: [ + expect.objectContaining({ + account_number: '1930', + account_id: 'account-1930', + debit_amount: 150, + credit_amount: 0, + }), + expect.objectContaining({ + account_number: '2010', + account_id: 'account-2010', + debit_amount: 0, + credit_amount: 150, + }), + ], + })) + expect(eventBus.emit).toHaveBeenCalledTimes(3) + expect(eventBus.emit).toHaveBeenCalledWith(expect.objectContaining({ + type: 'journal_entry.reversed', + })) + }) + + it('rejects an unbalanced replacement before calling the RPC', async () => { + const rpc = vi.fn() + + await expect(replaceOpeningBalanceEntry( + { rpc } as never, + 'company-1', + 'user-1', + 'old-entry', + { + ...input, + lines: [ + { account_number: '1930', debit_amount: 150, credit_amount: 0 }, + { account_number: '2010', debit_amount: 0, credit_amount: 149 }, + ], + }, + )).rejects.toBeInstanceOf(JournalEntryNotBalancedError) + + expect(rpc).not.toHaveBeenCalled() + }) + + it('rejects lines whose accounts are absent from the chart', async () => { + const rpc = vi.fn() + const from = vi.fn().mockImplementation((table: string) => { + if (table !== 'chart_of_accounts') throw new Error(`Unexpected table: ${table}`) + return thenableChain({ data: [], error: null }) + }) + + await expect(replaceOpeningBalanceEntry( + { rpc, from } as never, + 'company-1', + 'user-1', + 'old-entry', + input, + )).rejects.toBeInstanceOf(AccountsNotInChartError) + + expect(rpc).not.toHaveBeenCalled() + }) + + it('surfaces an atomic RPC failure without attempting a partial journal write', async () => { + const rpc = vi.fn().mockResolvedValue({ + data: null, + error: { message: 'Opening balance changed concurrently', code: '40001' }, + }) + const from = vi.fn().mockImplementation((table: string) => { + if (table !== 'chart_of_accounts') throw new Error(`Unexpected table: ${table}`) + return thenableChain({ + data: [ + { id: 'account-1930', account_number: '1930' }, + { id: 'account-2010', account_number: '2010' }, + ], + error: null, + }) + }) + + await expect(replaceOpeningBalanceEntry( + { rpc, from } as never, + 'company-1', + 'user-1', + 'old-entry', + input, + )).rejects.toBeInstanceOf(BookkeepingDatabaseError) + + expect(rpc).toHaveBeenCalledTimes(1) + expect(from).toHaveBeenCalledTimes(1) + }) +}) diff --git a/lib/bookkeeping/engine.ts b/lib/bookkeeping/engine.ts index 1c9bc935..99a3aa3e 100644 --- a/lib/bookkeeping/engine.ts +++ b/lib/bookkeeping/engine.ts @@ -191,8 +191,7 @@ export async function findFiscalPeriod( * Build line insert objects from input lines, resolving account IDs and * including tax_code and the dimensions bag */ -function buildLineInserts( - entryId: string, +function buildLineValues( lines: CreateJournalEntryLineInput[], accountIdMap: Map ) { @@ -202,7 +201,6 @@ function buildLineInserts( // (20260702230000): writing them explicitly would error. const dimensions = normalizeLineDimensions(line) return { - journal_entry_id: entryId, account_number: line.account_number, account_id: accountIdMap.get(line.account_number) || null, debit_amount: Math.round((line.debit_amount || 0) * 100) / 100, @@ -218,6 +216,17 @@ function buildLineInserts( }) } +function buildLineInserts( + entryId: string, + lines: CreateJournalEntryLineInput[], + accountIdMap: Map +) { + return buildLineValues(lines, accountIdMap).map((line) => ({ + journal_entry_id: entryId, + ...line, + })) +} + /** * Create a draft journal entry with lines (no voucher number assigned yet) * The entry stays in 'draft' status until commitEntry() is called. @@ -713,6 +722,178 @@ export async function createJournalEntry( } } +export interface OpeningBalanceReplacementResult { + newEntryId: string + stornoEntryId: string + newVoucherNumber: number + stornoVoucherNumber: number +} + +/** + * Atomically replace a period's posted opening balance with a new engine + * voucher and a storno of the old voucher. The database function owns the + * period row lock, authorization, compare-and-swap check, voucher commits, + * status transition, and pointer swap in one transaction. + */ +export async function replaceOpeningBalanceEntry( + supabase: SupabaseClient, + companyId: string, + userId: string, + expectedOldEntryId: string, + input: CreateJournalEntryInput, +): Promise { + if (input.source_type !== 'opening_balance') { + throw new BookkeepingDatabaseError( + 'replace_opening_balance', + 'Replacement entry must use source_type opening_balance', + ) + } + + const balance = validateBalance(input.lines) + if (!balance.valid) { + throw new JournalEntryNotBalancedError( + balance.totalDebit, + balance.totalCredit, + 'draft', + ) + } + + await validateEntryDimensions(supabase, companyId, input.lines) + + const accountIdMap = await resolveAccountIds(supabase, companyId, input.lines) + const accountNumbers = [...new Set(input.lines.map((line) => line.account_number))] + let missingAccounts = accountNumbers.filter((number) => !accountIdMap.has(number)) + + if (missingAccounts.length > 0) { + const seeded = await backfillStandardBASAccounts( + supabase, + companyId, + userId, + missingAccounts, + ) + if (seeded.length > 0) { + const refreshed = await resolveAccountIds(supabase, companyId, input.lines) + for (const [number, id] of refreshed) accountIdMap.set(number, id) + missingAccounts = accountNumbers.filter((number) => !accountIdMap.has(number)) + } + if (missingAccounts.length > 0) { + throw new AccountsNotInChartError(missingAccounts) + } + } + + const voucherSeries = input.voucher_series + ?? await resolveSeriesFromSettings(supabase, companyId, 'opening_balance') + const preparedLines = buildLineValues(input.lines, accountIdMap) + const actor = getActor() + + const { data, error } = await supabase.rpc('commit_opening_balance_replacement', { + p_company_id: companyId, + p_period_id: input.fiscal_period_id, + p_expected_old_entry_id: expectedOldEntryId, + p_user_id: userId, + p_entry_date: input.entry_date, + p_description: input.description, + p_voucher_series: voucherSeries, + p_lines: preparedLines, + p_actor_type: actor?.type ?? null, + p_actor_label: actor?.label ?? null, + }) + + if (error) { + log.error('commit_opening_balance_replacement RPC failed', error, { + operation: 'replace_opening_balance', + companyId, + userId, + entityType: 'journal_entry', + entityId: expectedOldEntryId, + fiscalPeriodId: input.fiscal_period_id, + pgCode: (error as { code?: string }).code, + pgDetails: (error as { details?: string }).details, + pgHint: (error as { hint?: string }).hint, + }) + throw new BookkeepingDatabaseError('replace_opening_balance', error.message) + } + + type RpcRow = { + new_entry_id: string + storno_entry_id: string + new_voucher_number: number + storno_voucher_number: number + } + const row = (Array.isArray(data) ? data[0] : data) as RpcRow | null + if (!row?.new_entry_id || !row.storno_entry_id) { + throw new BookkeepingDatabaseError( + 'replace_opening_balance', + 'Atomic replacement returned no journal entry ids', + ) + } + + const result: OpeningBalanceReplacementResult = { + newEntryId: row.new_entry_id, + stornoEntryId: row.storno_entry_id, + newVoucherNumber: row.new_voucher_number, + stornoVoucherNumber: row.storno_voucher_number, + } + + const { data: entries, error: entriesError } = await supabase + .from('journal_entries') + .select('*, lines:journal_entry_lines(*)') + .eq('company_id', companyId) + .in('id', [expectedOldEntryId, result.newEntryId, result.stornoEntryId]) + + if (entriesError) { + log.error('atomic opening balance replacement committed but entry refresh failed', entriesError, { + companyId, + entityId: result.newEntryId, + }) + return result + } + + const byId = new Map( + ((entries ?? []) as JournalEntry[]).map((entry) => [entry.id, entry]), + ) + const originalEntry = byId.get(expectedOldEntryId) + const newEntry = byId.get(result.newEntryId) + const stornoEntry = byId.get(result.stornoEntryId) + + if (!originalEntry || !newEntry || !stornoEntry) { + log.error( + 'atomic opening balance replacement committed but event entries are missing', + new Error('journal entry refresh returned incomplete replacement data'), + { + companyId, + expectedOldEntryId, + newEntryId: result.newEntryId, + stornoEntryId: result.stornoEntryId, + missingOriginalEntry: !originalEntry, + missingNewEntry: !newEntry, + missingStornoEntry: !stornoEntry, + }, + ) + } + + if (newEntry) { + await eventBus.emit({ + type: 'journal_entry.committed', + payload: { entry: newEntry, userId, companyId }, + }) + } + if (stornoEntry) { + await eventBus.emit({ + type: 'journal_entry.committed', + payload: { entry: stornoEntry, userId, companyId }, + }) + } + if (originalEntry && stornoEntry) { + await eventBus.emit({ + type: 'journal_entry.reversed', + payload: { originalEntry, reversalEntry: stornoEntry, userId, companyId }, + }) + } + + return result +} + /** * Get the current date in Swedish timezone (Europe/Stockholm). * Avoids UTC date shift when server runs in a different timezone. diff --git a/lib/bookkeeping/errors.ts b/lib/bookkeeping/errors.ts index f9928fac..4c934ee6 100644 --- a/lib/bookkeeping/errors.ts +++ b/lib/bookkeeping/errors.ts @@ -292,6 +292,7 @@ export type BookkeepingOperation = | 'create_reversal_entry' | 'create_reversal_lines' | 'post_reversal_entry' + | 'replace_opening_balance' | 'create_corrected_entry' | 'create_corrected_lines' | 'post_corrected_entry' diff --git a/lib/import/__tests__/sie-import-derived-ib.test.ts b/lib/import/__tests__/sie-import-derived-ib.test.ts index c51c37f7..1686b011 100644 --- a/lib/import/__tests__/sie-import-derived-ib.test.ts +++ b/lib/import/__tests__/sie-import-derived-ib.test.ts @@ -13,14 +13,14 @@ */ import { describe, it, expect, vi, beforeEach } from 'vitest' import { executeSIEImport } from '../sie-import' -import { createJournalEntry } from '@/lib/bookkeeping/engine' +import { createJournalEntry, replaceOpeningBalanceEntry } from '@/lib/bookkeeping/engine' import { findUntransferredResults } from '@/lib/reports/imbalance-diagnosis' import type { ParsedSIEFile, AccountMapping } from '../types' import type { SupabaseClient } from '@supabase/supabase-js' vi.mock('@/lib/bookkeeping/engine', () => ({ createJournalEntry: vi.fn(async () => ({ id: 'ob-entry-1' })), - reverseEntry: vi.fn(), + replaceOpeningBalanceEntry: vi.fn(), })) vi.mock('@/lib/reports/imbalance-diagnosis', () => ({ @@ -131,7 +131,7 @@ function makeMapping(source: string, target: string): AccountMapping { } } -function standardQueues() { +function standardQueues(): Record { return { sie_imports: [ { data: null }, // checkDuplicateImport: no duplicate @@ -280,6 +280,228 @@ describe('executeSIEImport: derived IB from #UB -1 (issue #675)', () => { expect(createJournalEntry).not.toHaveBeenCalled() expect(result.openingBalanceEntryId).toBeNull() }) + + it('keeps an earlier-year IB and resyncs the already-imported successor', async () => { + const queues = standardQueues() + queues.fiscal_periods = [ + { data: { id: 'fp-2025' } }, + { data: { opening_balances_set: false, opening_balance_entry_id: null } }, + {}, // Link the new 2025 IB. + { + data: { + id: 'fp-2026', + name: 'Räkenskapsår 2026', + period_start: '2026-01-01', + period_end: '2026-12-31', + is_closed: false, + locked_at: null, + opening_balance_entry_id: 'ib-2026-old', + opening_balances_set: true, + }, + }, + ] + queues.journal_entries = [ + // The chronological activity check excludes the already-imported 2026 + // entries when deciding whether the 2025 #IB is legitimate. + { count: 0 }, + ] + + vi.mocked(createJournalEntry).mockResolvedValueOnce({ + id: 'ib-2025', + } as Awaited>) + vi.mocked(replaceOpeningBalanceEntry).mockResolvedValueOnce({ + newEntryId: 'ib-2026-new', + stornoEntryId: 'storno-2026-old', + newVoucherNumber: 2, + stornoVoucherNumber: 3, + }) + + const parsed = makeParsedFile({ + header: { + ...makeParsedFile().header, + fiscalYears: [{ yearIndex: 0, start: '2025-01-01', end: '2025-12-31' }], + }, + openingBalances: [ + { yearIndex: 0, account: '1930', amount: 100 }, + { yearIndex: 0, account: '2010', amount: -100 }, + ], + closingBalances: [ + { yearIndex: 0, account: '1930', amount: 150 }, + { yearIndex: 0, account: '2010', amount: -150 }, + ], + stats: { + totalAccounts: 2, + totalVouchers: 0, + totalTransactionLines: 0, + fiscalYearStart: '2025-01-01', + fiscalYearEnd: '2025-12-31', + }, + }) + + const result = await executeSIEImport( + buildRoutingSupabase(queues), + 'company-1', + 'user-1', + parsed, + standardMappings, + standardOptions, + ) + + expect(result.success).toBe(true) + expect(result.openingBalanceEntryId).toBe('ib-2025') + expect(result.nextPeriodIBResync).toEqual({ + nextPeriodId: 'fp-2026', + nextPeriodName: 'Räkenskapsår 2026', + stornoEntryId: 'storno-2026-old', + newOpeningBalanceEntryId: 'ib-2026-new', + }) + expect(result.warnings.join(' ')).toMatch(/Räkenskapsår 2026.*synkades om/) + + expect(vi.mocked(createJournalEntry).mock.calls[0][3]).toMatchObject({ + fiscal_period_id: 'fp-2025', + source_type: 'opening_balance', + entry_date: '2025-01-01', + }) + expect(replaceOpeningBalanceEntry).toHaveBeenCalledWith( + expect.anything(), + 'company-1', + 'user-1', + 'ib-2026-old', + expect.objectContaining({ + fiscal_period_id: 'fp-2026', + source_type: 'opening_balance', + entry_date: '2026-01-01', + }), + ) + expect(vi.mocked(replaceOpeningBalanceEntry).mock.calls[0]?.[4]).not.toHaveProperty( + 'voucher_series', + ) + }) + + it('warns without changing a locked successor opening balance', async () => { + const queues = standardQueues() + queues.fiscal_periods = [ + { data: { id: 'fp-2025' } }, + { data: { opening_balances_set: false, opening_balance_entry_id: null } }, + {}, // Link the new 2025 IB. + { + data: { + id: 'fp-2026', + name: 'Räkenskapsår 2026', + period_start: '2026-01-01', + period_end: '2026-12-31', + is_closed: false, + locked_at: '2026-07-01T00:00:00Z', + opening_balance_entry_id: 'ib-2026-old', + opening_balances_set: true, + }, + }, + ] + queues.journal_entries = [{ count: 0 }] + vi.mocked(createJournalEntry).mockResolvedValueOnce({ + id: 'ib-2025', + } as Awaited>) + + const parsed = makeParsedFile({ + header: { + ...makeParsedFile().header, + fiscalYears: [{ yearIndex: 0, start: '2025-01-01', end: '2025-12-31' }], + }, + openingBalances: [ + { yearIndex: 0, account: '1930', amount: 100 }, + { yearIndex: 0, account: '2010', amount: -100 }, + ], + closingBalances: [ + { yearIndex: 0, account: '1930', amount: 150 }, + { yearIndex: 0, account: '2010', amount: -150 }, + ], + stats: { + totalAccounts: 2, + totalVouchers: 0, + totalTransactionLines: 0, + fiscalYearStart: '2025-01-01', + fiscalYearEnd: '2025-12-31', + }, + }) + + const result = await executeSIEImport( + buildRoutingSupabase(queues), + 'company-1', + 'user-1', + parsed, + standardMappings, + standardOptions, + ) + + expect(result.success).toBe(true) + expect(result.openingBalanceEntryId).toBe('ib-2025') + expect(result.nextPeriodIBResync).toBeUndefined() + expect(result.nextPeriodIBResyncSkipped).toEqual({ + reason: 'locked', + nextPeriodName: 'Räkenskapsår 2026', + }) + expect(result.warnings.join(' ')).toMatch(/Räkenskapsår 2026.*är låst/) + expect(createJournalEntry).toHaveBeenCalledTimes(1) + expect(replaceOpeningBalanceEntry).not.toHaveBeenCalled() + }) + + it('still rejects a file whose vouchers cross the target fiscal-period boundary', async () => { + const queues = standardQueues() + queues.fiscal_periods = [ + { data: { id: 'fp-2025' } }, + { data: { opening_balances_set: false, opening_balance_entry_id: null } }, + {}, // Link the 2025 IB before transaction-range validation. + { data: { period_start: '2025-01-01', period_end: '2025-12-31' } }, + ] + queues.journal_entries = [{ count: 0 }] + vi.mocked(createJournalEntry).mockResolvedValueOnce({ + id: 'ib-2025', + } as Awaited>) + + const parsed = makeParsedFile({ + header: { + ...makeParsedFile().header, + fiscalYears: [{ yearIndex: 0, start: '2025-01-01', end: '2025-12-31' }], + }, + openingBalances: [ + { yearIndex: 0, account: '1930', amount: 100 }, + { yearIndex: 0, account: '2010', amount: -100 }, + ], + closingBalances: [], + vouchers: [ + { + series: 'A', + number: 1, + date: new Date(2026, 0, 2), + description: 'Voucher from another fiscal year', + lines: [ + { account: '1930', amount: 10 }, + { account: '2010', amount: -10 }, + ], + }, + ], + stats: { + totalAccounts: 2, + totalVouchers: 1, + totalTransactionLines: 2, + fiscalYearStart: '2025-01-01', + fiscalYearEnd: '2025-12-31', + }, + }) + + const result = await executeSIEImport( + buildRoutingSupabase(queues), + 'company-1', + 'user-1', + parsed, + standardMappings, + standardOptions, + ) + + expect(result.success).toBe(false) + expect(result.errors.join(' ')).toMatch(/datum utanför räkenskapsåret/) + expect(result.errors.join(' ')).toMatch(/flera år i samma fil stöds inte/) + }) }) // --- Untransferred prior-year results (post-import walk) --- 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 new file mode 100644 index 00000000..bcc9990e --- /dev/null +++ b/lib/import/__tests__/sie-import-out-of-order.pg.test.ts @@ -0,0 +1,710 @@ +import { randomUUID } from 'node:crypto' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { PoolClient } from 'pg' +import type { SupabaseClient } from '@supabase/supabase-js' +import { getClient, getPool } from '@/tests/pg/setup' +import { seedCompany } from '@/tests/pg/fixtures' +import type { CreateJournalEntryInput } from '@/types' +import type { ParsedSIEFile } from '../types' + +vi.mock('@/lib/bookkeeping/engine', () => ({ + createJournalEntry: vi.fn(), + replaceOpeningBalanceEntry: vi.fn(), +})) + +import { replaceOpeningBalanceEntry } from '@/lib/bookkeeping/engine' +import { + companyHasPriorActivity, + resyncNextPeriodOpeningBalance, +} from '../sie-import' + +type EntryLine = { + account_number: string + debit_amount: number + credit_amount: number + line_description?: string | null +} + +async function insertPostedEntry(params: { + userId: string + companyId: string + fiscalPeriodId: string + entryDate: string + sourceType: 'opening_balance' | 'import' | 'storno' + description: string + lines: EntryLine[] + 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], + ) + + 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)`, + [ + id, + line.account_number, + line.debit_amount, + line.credit_amount, + line.line_description ?? null, + ], + ) + } + + return id +} + +async function runAsAuthenticated( + userId: string, + operation: (client: PoolClient) => Promise, +): Promise { + const client = await getClient() + try { + await client.query('BEGIN') + await client.query(`SELECT set_config('request.jwt.claims', $1, true)`, [ + JSON.stringify({ sub: userId, role: 'authenticated' }), + ]) + await client.query(`SELECT set_config('request.jwt.claim.sub', $1, true)`, [userId]) + await client.query('SET LOCAL ROLE authenticated') + const result = await operation(client) + await client.query('COMMIT') + return result + } catch (error) { + await client.query('ROLLBACK').catch(() => {}) + throw error + } finally { + client.release() + } +} + +function makePgSupabase(_userId: string): SupabaseClient { + const from = (table: string) => { + if (table === 'journal_entries') { + const filters: { + companyId?: string + status?: string + excludedSourceTypes: string[] + throughDate?: string + } = { excludedSourceTypes: [] } + + const chain = { + select: () => chain, + eq: (column: string, value: unknown) => { + if (column === 'company_id') filters.companyId = String(value) + else if (column === 'status') filters.status = String(value) + else throw new Error(`Unhandled eq filter in journal entry pg adapter: ${column}`) + return chain + }, + neq: (column: string, value: unknown) => { + if (column === 'source_type') filters.excludedSourceTypes.push(String(value)) + else throw new Error(`Unhandled neq filter in journal entry pg adapter: ${column}`) + return chain + }, + lte: (column: string, value: unknown) => { + if (column === 'entry_date') filters.throughDate = String(value) + else throw new Error(`Unhandled lte filter in journal entry pg adapter: ${column}`) + return chain + }, + then: ( + resolve: (value: { data: null; error: null; count: number }) => void, + reject: (reason: unknown) => void, + ) => { + getPool() + .query<{ count: string }>( + `SELECT count(*)::text AS count + FROM public.journal_entries + WHERE company_id = $1 + AND status = $2 + AND source_type <> ALL($3::text[]) + AND entry_date <= $4::date`, + [ + filters.companyId, + filters.status, + filters.excludedSourceTypes, + filters.throughDate, + ], + ) + .then((result) => resolve({ + data: null, + error: null, + count: Number(result.rows[0]!.count), + })) + .catch(reject) + }, + } + return chain + } + + if (table === 'fiscal_periods') { + const filters: { companyId?: string; afterDate?: string } = {} + const chain = { + select: () => chain, + eq: (column: string, value: unknown) => { + if (column === 'company_id') filters.companyId = String(value) + else throw new Error(`Unhandled eq filter in fiscal period pg adapter: ${column}`) + return chain + }, + gt: (column: string, value: unknown) => { + if (column === 'period_start') filters.afterDate = String(value) + else throw new Error(`Unhandled gt filter in fiscal period pg adapter: ${column}`) + return chain + }, + order: () => chain, + limit: () => chain, + maybeSingle: async () => { + const result = await getPool().query( + `SELECT id, name, + period_start::text AS period_start, + period_end::text AS period_end, + is_closed, locked_at, + opening_balance_entry_id, opening_balances_set + FROM public.fiscal_periods + WHERE company_id = $1 + AND period_start > $2::date + ORDER BY period_start ASC + LIMIT 1`, + [filters.companyId, filters.afterDate], + ) + return { data: result.rows[0] ?? null, error: null } + }, + } + return chain + } + + throw new Error(`Unexpected table in pg adapter: ${table}`) + } + + return { from } as unknown as SupabaseClient +} + +function closingBalances(amount = 150): ParsedSIEFile { + return { + closingBalances: [ + { yearIndex: 0, account: '1930', amount }, + { yearIndex: 0, account: '2010', amount: -amount }, + ], + } as ParsedSIEFile +} + +async function insertPeriod( + companyId: string, + name: string, + periodStart: string, + periodEnd: string, +): Promise { + const result = await getPool().query<{ id: string }>( + `INSERT INTO public.fiscal_periods + (company_id, name, period_start, period_end, is_closed, opening_balances_set) + VALUES ($1, $2, $3, $4, false, false) + RETURNING id`, + [companyId, name, periodStart, periodEnd], + ) + return result.rows[0]!.id +} + +function installAtomicReplacementMock(): void { + vi.mocked(replaceOpeningBalanceEntry).mockImplementation(async ( + _client: SupabaseClient, + targetCompanyId: string, + targetUserId: string, + expectedOldEntryId: string, + input: CreateJournalEntryInput, + ) => { + const accounts = await getPool().query<{ id: string; account_number: string }>( + `SELECT id, account_number + FROM public.chart_of_accounts + WHERE company_id = $1 + AND account_number = ANY($2::text[])`, + [targetCompanyId, input.lines.map((line) => line.account_number)], + ) + const accountIds = new Map(accounts.rows.map((account) => [account.account_number, account.id])) + const lines = input.lines.map((line, sortOrder) => ({ + account_number: line.account_number, + account_id: accountIds.get(line.account_number), + debit_amount: line.debit_amount, + credit_amount: line.credit_amount, + currency: line.currency ?? 'SEK', + amount_in_currency: line.amount_in_currency ?? null, + exchange_rate: line.exchange_rate ?? null, + line_description: line.line_description ?? null, + tax_code: line.tax_code ?? null, + dimensions: line.dimensions ?? {}, + sort_order: sortOrder, + })) + + const outcome = await runAsAuthenticated(targetUserId, async (client) => { + const result = await client.query<{ + new_entry_id: string + storno_entry_id: string + new_voucher_number: number + storno_voucher_number: number + }>( + `SELECT * FROM public.commit_opening_balance_replacement( + $1::uuid, $2::uuid, $3::uuid, $4::uuid, $5::date, + $6::text, $7::text, $8::jsonb, NULL, NULL + )`, + [ + targetCompanyId, + input.fiscal_period_id, + expectedOldEntryId, + targetUserId, + input.entry_date, + input.description, + input.voucher_series ?? 'A', + JSON.stringify(lines), + ], + ) + return result.rows[0]! + }) + + return { + newEntryId: outcome.new_entry_id, + stornoEntryId: outcome.storno_entry_id, + newVoucherNumber: outcome.new_voucher_number, + stornoVoucherNumber: outcome.storno_voucher_number, + } + }) +} + +const oldIBLines: EntryLine[] = [ + { account_number: '1930', debit_amount: 100, credit_amount: 0 }, + { account_number: '2010', debit_amount: 0, credit_amount: 100 }, +] + +describe('out-of-order SIE opening balances', () => { + beforeEach(() => { + vi.mocked(replaceOpeningBalanceEntry).mockReset() + }) + + it('keeps the 2025 IB and replaces the imported-first 2026 IB without duplication', async () => { + const { userId, companyId, fiscalPeriodId: period2026Id } = await seedCompany() + await getPool().query( + `INSERT INTO public.chart_of_accounts + (user_id, company_id, account_number, account_name, account_class, + account_type, normal_balance, is_active) + VALUES + ($1, $2, '1930', 'Bankkonto', 1, 'asset', 'debit', true), + ($1, $2, '2010', 'Eget kapital', 2, 'equity', 'credit', true), + ($1, $2, '3001', 'Forsaljning', 3, 'revenue', 'credit', true) + ON CONFLICT (company_id, account_number) DO NOTHING`, + [userId, companyId], + ) + const old2026IBId = await insertPostedEntry({ + userId, + companyId, + fiscalPeriodId: period2026Id, + entryDate: '2026-01-01', + sourceType: 'opening_balance', + description: 'Old 2026 IB', + lines: oldIBLines, + }) + await insertPostedEntry({ + userId, + companyId, + fiscalPeriodId: period2026Id, + entryDate: '2026-06-01', + sourceType: 'import', + description: 'Imported 2026 activity', + lines: [ + { account_number: '1930', debit_amount: 25, credit_amount: 0 }, + { account_number: '3001', debit_amount: 0, credit_amount: 25 }, + ], + }) + await getPool().query( + `UPDATE public.fiscal_periods + SET opening_balance_entry_id = $1, opening_balances_set = true + WHERE id = $2`, + [old2026IBId, period2026Id], + ) + + const supabase = makePgSupabase(userId) + expect(await companyHasPriorActivity(supabase, companyId, '2025-12-31')).toBe(false) + // Inclusive end-date filtering preserves the same-period continuation guard. + expect(await companyHasPriorActivity(supabase, companyId, '2026-12-31')).toBe(true) + + const period2025Id = await insertPeriod( + companyId, + 'Räkenskapsår 2025', + '2025-01-01', + '2025-12-31', + ) + await getPool().query( + `UPDATE public.fiscal_periods SET previous_period_id = $1 WHERE id = $2`, + [period2025Id, period2026Id], + ) + const ib2025Id = await insertPostedEntry({ + userId, + companyId, + fiscalPeriodId: period2025Id, + entryDate: '2025-01-01', + sourceType: 'opening_balance', + description: 'Imported 2025 IB', + lines: oldIBLines, + }) + await getPool().query( + `UPDATE public.fiscal_periods + SET opening_balance_entry_id = $1, opening_balances_set = true + WHERE id = $2`, + [ib2025Id, period2025Id], + ) + + installAtomicReplacementMock() + + const resync = await resyncNextPeriodOpeningBalance( + supabase, + companyId, + userId, + '2025-12-31', + closingBalances(), + new Map([['1930', '1930'], ['2010', '2010']]), + ) + + expect(resync.resynced).toBe(true) + if (!resync.resynced) throw new Error(`Unexpected resync failure: ${resync.reason}`) + + const periods = await getPool().query<{ + id: string + previous_period_id: string | null + opening_balance_entry_id: string | null + }>( + `SELECT id, previous_period_id, opening_balance_entry_id + FROM public.fiscal_periods + WHERE id = ANY($1::uuid[]) + ORDER BY period_start`, + [[period2025Id, period2026Id]], + ) + expect(periods.rows[0]).toMatchObject({ + id: period2025Id, + opening_balance_entry_id: ib2025Id, + }) + expect(periods.rows[1]).toMatchObject({ + id: period2026Id, + previous_period_id: period2025Id, + opening_balance_entry_id: resync.newOpeningBalanceEntryId, + }) + + const replaced = await getPool().query<{ + id: string + status: string + source_type: string + reverses_id: string | null + }>( + `SELECT id, status, source_type, reverses_id + FROM public.journal_entries + WHERE id = ANY($1::uuid[])`, + [[old2026IBId, resync.stornoEntryId, resync.newOpeningBalanceEntryId]], + ) + expect(replaced.rows).toEqual(expect.arrayContaining([ + expect.objectContaining({ id: old2026IBId, status: 'reversed', source_type: 'opening_balance' }), + expect.objectContaining({ + id: resync.stornoEntryId, + status: 'posted', + source_type: 'storno', + reverses_id: old2026IBId, + }), + expect.objectContaining({ + id: resync.newOpeningBalanceEntryId, + status: 'posted', + source_type: 'opening_balance', + }), + ])) + + const net = await getPool().query<{ account_number: string; amount: number }>( + `SELECT l.account_number, + SUM(l.debit_amount - l.credit_amount)::float8 AS amount + FROM public.journal_entry_lines l + JOIN public.journal_entries e ON e.id = l.journal_entry_id + WHERE e.id = ANY($1::uuid[]) + AND e.status IN ('posted', 'reversed') + GROUP BY l.account_number + ORDER BY l.account_number`, + [[old2026IBId, resync.stornoEntryId, resync.newOpeningBalanceEntryId]], + ) + expect(net.rows).toEqual([ + { account_number: '1930', amount: 150 }, + { account_number: '2010', amount: -150 }, + ]) + }) + + it('waits for a missing middle year before resyncing a later opening balance', async () => { + const { userId, companyId, fiscalPeriodId: period2026Id } = await seedCompany() + await getPool().query( + `INSERT INTO public.chart_of_accounts + (user_id, company_id, account_number, account_name, account_class, + account_type, normal_balance, is_active) + VALUES + ($1, $2, '1930', 'Bankkonto', 1, 'asset', 'debit', true), + ($1, $2, '2010', 'Eget kapital', 2, 'equity', 'credit', true), + ($1, $2, '3001', 'Forsaljning', 3, 'revenue', 'credit', true) + ON CONFLICT (company_id, account_number) DO NOTHING`, + [userId, companyId], + ) + const old2026IBId = await insertPostedEntry({ + userId, + companyId, + fiscalPeriodId: period2026Id, + entryDate: '2026-01-01', + sourceType: 'opening_balance', + description: 'Imported-first 2026 IB', + lines: oldIBLines, + }) + await insertPostedEntry({ + userId, + companyId, + fiscalPeriodId: period2026Id, + entryDate: '2026-06-01', + sourceType: 'import', + description: 'Imported 2026 activity', + lines: [ + { account_number: '1930', debit_amount: 25, credit_amount: 0 }, + { account_number: '3001', debit_amount: 0, credit_amount: 25 }, + ], + }) + await getPool().query( + `UPDATE public.fiscal_periods + SET opening_balance_entry_id = $1, opening_balances_set = true + WHERE id = $2`, + [old2026IBId, period2026Id], + ) + + const supabase = makePgSupabase(userId) + expect(await companyHasPriorActivity(supabase, companyId, '2024-12-31')).toBe(false) + + const period2024Id = await insertPeriod( + companyId, + 'Räkenskapsår 2024', + '2024-01-01', + '2024-12-31', + ) + const ib2024Id = await insertPostedEntry({ + userId, + companyId, + fiscalPeriodId: period2024Id, + entryDate: '2024-01-01', + sourceType: 'opening_balance', + description: 'Imported 2024 IB', + lines: oldIBLines, + }) + await insertPostedEntry({ + userId, + companyId, + fiscalPeriodId: period2024Id, + entryDate: '2024-06-01', + sourceType: 'import', + description: 'Imported 2024 activity', + lines: [ + { account_number: '1930', debit_amount: 50, credit_amount: 0 }, + { account_number: '3001', debit_amount: 0, credit_amount: 50 }, + ], + }) + await getPool().query( + `UPDATE public.fiscal_periods + SET opening_balance_entry_id = $1, opening_balances_set = true + WHERE id = $2`, + [ib2024Id, period2024Id], + ) + + installAtomicReplacementMock() + const gapResync = await resyncNextPeriodOpeningBalance( + supabase, + companyId, + userId, + '2024-12-31', + closingBalances(150), + new Map([['1930', '1930'], ['2010', '2010']]), + ) + + expect(gapResync).toEqual({ + resynced: false, + reason: 'next_period_not_adjacent', + nextPeriodName: '2026', + }) + expect(replaceOpeningBalanceEntry).not.toHaveBeenCalled() + + const afterGap = await getPool().query<{ opening_balance_entry_id: string | null }>( + `SELECT opening_balance_entry_id FROM public.fiscal_periods WHERE id = $1`, + [period2026Id], + ) + expect(afterGap.rows[0]!.opening_balance_entry_id).toBe(old2026IBId) + + expect(await companyHasPriorActivity(supabase, companyId, '2025-12-31')).toBe(true) + const period2025Id = await insertPeriod( + companyId, + 'Räkenskapsår 2025', + '2025-01-01', + '2025-12-31', + ) + await insertPostedEntry({ + userId, + companyId, + fiscalPeriodId: period2025Id, + entryDate: '2025-06-01', + sourceType: 'import', + description: 'Imported 2025 activity', + lines: [ + { account_number: '1930', debit_amount: 100, credit_amount: 0 }, + { account_number: '3001', debit_amount: 0, credit_amount: 100 }, + ], + }) + + const adjacentResync = await resyncNextPeriodOpeningBalance( + supabase, + companyId, + userId, + '2025-12-31', + closingBalances(250), + new Map([['1930', '1930'], ['2010', '2010']]), + ) + + expect(adjacentResync.resynced).toBe(true) + if (!adjacentResync.resynced) { + throw new Error(`Unexpected resync failure: ${adjacentResync.reason}`) + } + + const periods = await getPool().query<{ + id: string + opening_balance_entry_id: string | null + }>( + `SELECT id, opening_balance_entry_id + FROM public.fiscal_periods + WHERE id = ANY($1::uuid[]) + ORDER BY period_start`, + [[period2024Id, period2025Id, period2026Id]], + ) + expect(periods.rows).toEqual([ + { id: period2024Id, opening_balance_entry_id: ib2024Id }, + { id: period2025Id, opening_balance_entry_id: null }, + { id: period2026Id, opening_balance_entry_id: adjacentResync.newOpeningBalanceEntryId }, + ]) + + const effective2026IB = await getPool().query<{ count: string }>( + `SELECT count(*)::text AS count + FROM public.journal_entries + WHERE fiscal_period_id = $1 + AND source_type = 'opening_balance' + AND status = 'posted'`, + [period2026Id], + ) + expect(effective2026IB.rows[0]!.count).toBe('1') + + const net = await getPool().query<{ account_number: string; amount: number }>( + `SELECT l.account_number, + SUM(l.debit_amount - l.credit_amount)::float8 AS amount + FROM public.journal_entry_lines l + JOIN public.journal_entries e ON e.id = l.journal_entry_id + WHERE e.id = ANY($1::uuid[]) + AND e.status IN ('posted', 'reversed') + GROUP BY l.account_number + ORDER BY l.account_number`, + [[ + old2026IBId, + adjacentResync.stornoEntryId, + adjacentResync.newOpeningBalanceEntryId, + ]], + ) + expect(net.rows).toEqual([ + { account_number: '1930', amount: 250 }, + { account_number: '2010', amount: -250 }, + ]) + }) + + it('leaves a locked successor unchanged', async () => { + const { userId, companyId, fiscalPeriodId: period2026Id } = await seedCompany() + const old2026IBId = await insertPostedEntry({ + userId, + companyId, + fiscalPeriodId: period2026Id, + entryDate: '2026-01-01', + sourceType: 'opening_balance', + description: 'Locked 2026 IB', + lines: oldIBLines, + }) + await getPool().query( + `UPDATE public.fiscal_periods + SET opening_balance_entry_id = $1, + opening_balances_set = true, + locked_at = now() + WHERE id = $2`, + [old2026IBId, period2026Id], + ) + const before = await getPool().query<{ count: string }>( + `SELECT count(*)::text AS count FROM public.journal_entries WHERE company_id = $1`, + [companyId], + ) + + const result = await resyncNextPeriodOpeningBalance( + makePgSupabase(userId), + companyId, + userId, + '2025-12-31', + closingBalances(), + new Map([['1930', '1930'], ['2010', '2010']]), + ) + + expect(result).toEqual({ + resynced: false, + reason: 'next_period_locked', + nextPeriodName: '2026', + }) + expect(replaceOpeningBalanceEntry).not.toHaveBeenCalled() + + const after = await getPool().query<{ + opening_balance_entry_id: string | null + count: string + }>( + `SELECT fp.opening_balance_entry_id, + (SELECT count(*)::text FROM public.journal_entries WHERE company_id = $1) AS count + FROM public.fiscal_periods fp + WHERE fp.id = $2`, + [companyId, period2026Id], + ) + expect(after.rows[0]).toEqual({ + opening_balance_entry_id: old2026IBId, + count: before.rows[0]!.count, + }) + }) +}) diff --git a/lib/import/__tests__/sie-import.test.ts b/lib/import/__tests__/sie-import.test.ts index e44a9506..49a0f7d2 100644 --- a/lib/import/__tests__/sie-import.test.ts +++ b/lib/import/__tests__/sie-import.test.ts @@ -565,6 +565,30 @@ describe('ensureFiscalPeriod validation', () => { ).rejects.toThrow(/överlappar men matchar inte/) }) + it('relinks the immediate successor when an earlier period is imported later', async () => { + const { supabase, enqueueMany, findCalls } = createQueuedMockSupabase() + enqueueMany([ + { data: null, error: null }, + { data: [], error: null }, + { data: [], error: null }, + { data: { id: 'fp-2025' }, error: null }, + { data: [{ id: 'fp-2026' }], error: null }, + { data: null, error: null }, + ]) + + const id = await ensureFiscalPeriod( + supabase as unknown as Supabase, + 'company-id', + '2025-01-01', + '2025-12-31', + ) + + expect(id).toBe('fp-2025') + expect(findCalls('fiscal_periods', 'update')).toContainEqual([ + { previous_period_id: 'fp-2025' }, + ]) + }) + // BFL 3 kap. caps any räkenskapsår at 18 months (12 is the norm; 18 is the // ceiling for a förlängt/omlagt year). #RAR used to be validated for start // and end DAY only, so a 24-month räkenskapsår from a foreign system @@ -841,6 +865,10 @@ describe('companyHasPriorActivity', () => { capturedFilters[`in:${col}`] = val return chain }, + lte: (col: string, val: unknown) => { + capturedFilters[`lte:${col}`] = val + return chain + }, then: (resolve: (v: { count: number; error: null }) => void) => resolve({ count, error: null }), } @@ -853,7 +881,11 @@ describe('companyHasPriorActivity', () => { it('returns false when the company has no prior posted entries', async () => { const { supabase } = buildCountingSupabase(0) - const result = await companyHasPriorActivity(supabase as unknown as Supabase, 'company-1') + const result = await companyHasPriorActivity( + supabase as unknown as Supabase, + 'company-1', + '2025-12-31', + ) expect(result).toBe(false) }) @@ -861,7 +893,11 @@ describe('companyHasPriorActivity', () => { it('returns true when the company has prior posted non-IB entries', async () => { const { supabase } = buildCountingSupabase(42) - const result = await companyHasPriorActivity(supabase as unknown as Supabase, 'company-1') + const result = await companyHasPriorActivity( + supabase as unknown as Supabase, + 'company-1', + '2025-12-31', + ) expect(result).toBe(true) }) @@ -869,11 +905,16 @@ describe('companyHasPriorActivity', () => { it('excludes opening_balance and storno entries, and only counts posted', async () => { const { supabase, capturedFilters } = buildCountingSupabase(0) - await companyHasPriorActivity(supabase as unknown as Supabase, 'company-1') + await companyHasPriorActivity( + supabase as unknown as Supabase, + 'company-1', + '2025-12-31', + ) expect(capturedFilters['neq:source_type']).toEqual(['opening_balance', 'storno']) expect(capturedFilters['eq:status']).toBe('posted') expect(capturedFilters['eq:company_id']).toBe('company-1') + expect(capturedFilters['lte:entry_date']).toBe('2025-12-31') }) it('treats null/undefined count as zero', async () => { @@ -884,8 +925,10 @@ describe('companyHasPriorActivity', () => { neq: () => ({ neq: () => ({ eq: () => ({ - then: (resolve: (v: { count: null; error: null }) => void) => - resolve({ count: null, error: null }), + lte: () => ({ + then: (resolve: (v: { count: null; error: null }) => void) => + resolve({ count: null, error: null }), + }), }), }), }), @@ -894,7 +937,11 @@ describe('companyHasPriorActivity', () => { }), } - const result = await companyHasPriorActivity(supabase as unknown as Supabase, 'company-1') + const result = await companyHasPriorActivity( + supabase as unknown as Supabase, + 'company-1', + '2025-12-31', + ) expect(result).toBe(false) }) diff --git a/lib/import/sie-import.ts b/lib/import/sie-import.ts index b75ead02..a29848df 100644 --- a/lib/import/sie-import.ts +++ b/lib/import/sie-import.ts @@ -9,7 +9,7 @@ import type { SupabaseClient } from '@supabase/supabase-js' import { normalizeLineDimensions } from '@/lib/bookkeeping/dimension-resolver' import { importDimensionRegistry } from './sie-dimensions' -import { createJournalEntry, reverseEntry } from '@/lib/bookkeeping/engine' +import { createJournalEntry, replaceOpeningBalanceEntry } from '@/lib/bookkeeping/engine' import type { ParsedSIEFile, AccountMapping, @@ -735,9 +735,10 @@ async function createOpeningBalanceEntry( } /** - * Returns true when the company already has at least one posted (or reversed) - * non-IB journal entry, i.e. this is a continuation import, not the first - * ever SIE upload for the company. + * Returns true when the company already has at least one posted non-IB + * journal entry dated no later than the target fiscal period, i.e. this is a + * continuation import rather than the first SIE upload for that point in the + * company's chronology. * * Used to gate IB-entry creation: when a company is already live, each year's * #IB equals the prior year's UB, which is the sum of already-imported @@ -746,7 +747,8 @@ async function createOpeningBalanceEntry( */ export async function companyHasPriorActivity( supabase: SupabaseClient, - companyId: string + companyId: string, + targetPeriodEnd: string, ): Promise { // Only count currently-effective real activity. Excluding 'reversed' drops // cancelled originals; excluding source_type 'storno' drops their matching @@ -760,6 +762,9 @@ export async function companyHasPriorActivity( .neq('source_type', 'opening_balance') .neq('source_type', 'storno') .eq('status', 'posted') + // Keep same-period activity in the continuation guard, but ignore a + // later year that happened to be imported first. + .lte('entry_date', targetPeriodEnd) return (count ?? 0) > 0 } @@ -839,6 +844,21 @@ export async function resyncNextPeriodOpeningBalance( return { resynced: false, reason: 'no_next_period' } } + const importedPeriodEnd = new Date(`${justImportedPeriodEnd}T00:00:00Z`) + importedPeriodEnd.setUTCDate(importedPeriodEnd.getUTCDate() + 1) + const expectedNextPeriodStart = importedPeriodEnd.toISOString().slice(0, 10) + + if (nextPeriod.period_start !== expectedNextPeriodStart) { + // A later period separated by a gap has its own authoritative IB. It must + // not be replaced with a non-adjacent year's UB while the middle year is + // still missing. + return { + resynced: false, + reason: 'next_period_not_adjacent', + nextPeriodName: nextPeriod.name, + } + } + if (!nextPeriod.opening_balance_entry_id) { // No existing IB on the next period: caller has nothing to resync; the // user's first IB for the next period will be derived from the import @@ -909,52 +929,26 @@ export async function resyncNextPeriodOpeningBalance( } } - // Ordering note: create the new IB FIRST, then storno the old one. If we - // stornoed first and the createJournalEntry call failed, the next period - // would be left with a reversed IB and nothing to replace it, and - // executeSIEImport swallows our error as a non-fatal warning. By creating - // first we guarantee the worst case is "new IB exists but not yet linked", - // which getOpeningBalances() can still reason about. - - // Build the new IB entry on the next period. - const newEntry = await createJournalEntry(supabase, companyId, userId, { - fiscal_period_id: nextPeriod.id, - entry_date: nextPeriod.period_start as string, - description: 'Ingående balanser (resynk efter prior-year SIE-import)', - source_type: 'opening_balance', - voucher_series: 'A', - lines: newLines, - }) - - // Atomically swap the period FK pointer (two-step around the - // immutability trigger). - const { error: relinkError } = await supabase.rpc('replace_period_opening_balance_link', { - p_company_id: companyId, - p_period_id: nextPeriod.id, - p_new_entry_id: newEntry.id, - }) - - if (relinkError) { - throw new Error(`Failed to relink opening balance on next period: ${relinkError.message}`) - } - - // Now that the period points at the new IB, storno the old one. If this - // throws, the period is already on the correct entry: the orphaned old - // entry shows up as a stray verifikat but the FK stays consistent. - const storno = await reverseEntry( + const replacement = await replaceOpeningBalanceEntry( supabase, companyId, userId, nextPeriod.opening_balance_entry_id, - nextPeriod.period_start as string, + { + fiscal_period_id: nextPeriod.id, + entry_date: nextPeriod.period_start as string, + description: 'Ingående balanser (resynk efter prior-year SIE-import)', + source_type: 'opening_balance', + lines: newLines, + }, ) return { resynced: true, nextPeriodId: nextPeriod.id, nextPeriodName: nextPeriod.name, - stornoEntryId: storno.id, - newOpeningBalanceEntryId: newEntry.id, + stornoEntryId: replacement.stornoEntryId, + newOpeningBalanceEntryId: replacement.newEntryId, } } @@ -2199,7 +2193,11 @@ export async function executeSIEImport( // first-ever import creates the legitimate pre-system IB; subsequent // imports must rely on the prior entries to derive opening balances // on the fly (via getOpeningBalances() fallback). - const isContinuationImport = await companyHasPriorActivity(supabase, companyId) + const isContinuationImport = await companyHasPriorActivity( + supabase, + companyId, + fiscalYearEnd, + ) if (isContinuationImport) { result.warnings.push( @@ -2464,7 +2462,16 @@ export async function executeSIEImport( // exists with its own opening_balance entry, the customer is doing a // prior-year backfill. Sync the next period's IB to match the UB we // just imported so reports stay consistent. - if (result.success && fiscalYearEnd && result.fiscalPeriodId && parsed.closingBalances.length > 0) { + // result.success is finalized below, after diagnostics and documentation. + // Use the same error condition here so this block is reachable, but require + // a target-period entry so a no-op file cannot succeed through resync alone. + if ( + result.errors.length === 0 && + result.journalEntriesCreated > 0 && + fiscalYearEnd && + result.fiscalPeriodId && + parsed.closingBalances.length > 0 + ) { try { const resync = await resyncNextPeriodOpeningBalance( supabase, diff --git a/supabase/migrations/20260801221047_atomic_opening_balance_replacement.sql b/supabase/migrations/20260801221047_atomic_opening_balance_replacement.sql new file mode 100644 index 00000000..0e3455d9 --- /dev/null +++ b/supabase/migrations/20260801221047_atomic_opening_balance_replacement.sql @@ -0,0 +1,399 @@ +-- Replace a period opening balance as one atomic bookkeeping operation. +-- +-- The replacement entry, storno, original-entry status change, and fiscal +-- period pointer swap must either all commit or all roll back. The period row +-- lock closes the lock-date race, while the expected old entry id provides a +-- compare-and-swap guard against concurrent corrections. + +CREATE OR REPLACE FUNCTION public.commit_opening_balance_replacement( + p_company_id uuid, + p_period_id uuid, + p_expected_old_entry_id uuid, + p_user_id uuid, + p_entry_date date, + p_description text, + p_voucher_series text, + p_lines jsonb, + p_actor_type text DEFAULT NULL, + p_actor_label text DEFAULT NULL +) +RETURNS TABLE( + new_entry_id uuid, + storno_entry_id uuid, + new_voucher_number integer, + storno_voucher_number integer +) +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $function$ +DECLARE + v_claims jsonb := COALESCE( + NULLIF(current_setting('request.jwt.claims', true), '')::jsonb, + '{}'::jsonb + ); + v_jwt_role text := COALESCE( + NULLIF(v_claims ->> 'role', ''), + NULLIF(current_setting('request.jwt.claim.role', true), ''), + '' + ); + v_member_role text; + v_period public.fiscal_periods%ROWTYPE; + v_old_entry public.journal_entries%ROWTYPE; + v_lock_date date; + v_new_entry_id uuid := uuid_generate_v4(); + v_storno_entry_id uuid := uuid_generate_v4(); + v_new_voucher_number integer; + v_storno_voucher_number integer; + v_line_count integer; + v_updated_count integer; + v_total_debit numeric; + v_total_credit numeric; +BEGIN + IF v_jwt_role = 'authenticated' THEN + IF auth.uid() IS NULL OR p_user_id IS DISTINCT FROM auth.uid() THEN + RAISE EXCEPTION 'unauthorized: user attribution does not match caller' + USING ERRCODE = '42501'; + END IF; + ELSIF v_jwt_role <> 'service_role' THEN + RAISE EXCEPTION 'unauthorized: authenticated or service role required' + USING ERRCODE = '42501'; + END IF; + + SELECT cm.role + INTO v_member_role + FROM public.company_members cm + WHERE cm.company_id = p_company_id + AND cm.user_id = p_user_id; + + IF v_member_role IS NULL OR v_member_role = 'viewer' THEN + RAISE EXCEPTION 'unauthorized: caller cannot write to company %', p_company_id + USING ERRCODE = '42501'; + END IF; + + SELECT fp.* + INTO v_period + FROM public.fiscal_periods fp + WHERE fp.id = p_period_id + AND fp.company_id = p_company_id + FOR UPDATE; + + IF NOT FOUND THEN + RAISE EXCEPTION 'Fiscal period not found: %', p_period_id; + END IF; + + IF v_period.is_closed OR v_period.locked_at IS NOT NULL THEN + RAISE EXCEPTION 'Cannot replace opening balance in locked/closed fiscal period "%"', + v_period.name; + END IF; + + IF v_period.opening_balances_set IS NOT TRUE + OR v_period.opening_balance_entry_id IS DISTINCT FROM p_expected_old_entry_id THEN + RAISE EXCEPTION 'Opening balance changed concurrently for fiscal period %', p_period_id + USING ERRCODE = '40001'; + END IF; + + IF p_entry_date IS DISTINCT FROM v_period.period_start THEN + RAISE EXCEPTION 'Replacement opening balance date must equal fiscal period start %', + v_period.period_start; + END IF; + + SELECT cs.bookkeeping_locked_through + INTO v_lock_date + FROM public.company_settings cs + WHERE cs.company_id = p_company_id; + + IF v_lock_date IS NOT NULL AND p_entry_date <= v_lock_date THEN + RAISE EXCEPTION 'Bookkeeping is locked through %', v_lock_date; + END IF; + + SELECT je.* + INTO v_old_entry + FROM public.journal_entries je + WHERE je.id = p_expected_old_entry_id + AND je.company_id = p_company_id + AND je.fiscal_period_id = p_period_id + FOR UPDATE; + + IF NOT FOUND + OR v_old_entry.status <> 'posted' + OR v_old_entry.source_type <> 'opening_balance' THEN + RAISE EXCEPTION 'Expected opening balance is not a posted entry in period %', p_period_id; + END IF; + + IF jsonb_typeof(p_lines) <> 'array' OR jsonb_array_length(p_lines) = 0 THEN + RAISE EXCEPTION 'Replacement opening balance requires at least one line'; + END IF; + + SELECT + round(COALESCE(sum(line.debit_amount), 0), 2), + round(COALESCE(sum(line.credit_amount), 0), 2) + INTO v_total_debit, v_total_credit + FROM jsonb_to_recordset(p_lines) AS line( + account_number text, + account_id uuid, + debit_amount numeric, + credit_amount numeric, + currency text, + amount_in_currency numeric, + exchange_rate numeric, + line_description text, + tax_code text, + dimensions jsonb, + sort_order integer + ); + + IF v_total_debit <= 0 OR v_total_debit IS DISTINCT FROM v_total_credit THEN + RAISE EXCEPTION 'Replacement opening balance is not balanced (debit %, credit %)', + v_total_debit, v_total_credit; + END IF; + + IF EXISTS ( + SELECT 1 + FROM jsonb_to_recordset(p_lines) AS line( + account_number text, + account_id uuid, + debit_amount numeric, + credit_amount numeric, + currency text, + amount_in_currency numeric, + exchange_rate numeric, + line_description text, + tax_code text, + dimensions jsonb, + sort_order integer + ) + LEFT JOIN public.chart_of_accounts account + ON account.id = line.account_id + AND account.company_id = p_company_id + AND account.account_number = line.account_number + AND account.is_active = true + WHERE account.id IS NULL + OR line.account_number IS NULL + OR line.debit_amount IS NULL + OR line.credit_amount IS NULL + OR line.debit_amount < 0 + OR line.credit_amount < 0 + OR (line.debit_amount > 0 AND line.credit_amount > 0) + OR (line.debit_amount = 0 AND line.credit_amount = 0) + ) THEN + RAISE EXCEPTION 'Replacement opening balance contains an invalid line'; + END IF; + + INSERT INTO public.journal_entries ( + id, + user_id, + company_id, + fiscal_period_id, + voucher_number, + voucher_series, + entry_date, + description, + source_type, + status + ) VALUES ( + v_new_entry_id, + p_user_id, + p_company_id, + p_period_id, + 0, + COALESCE(NULLIF(p_voucher_series, ''), 'A'), + p_entry_date, + p_description, + 'opening_balance', + 'draft' + ); + + INSERT INTO public.journal_entry_lines ( + journal_entry_id, + account_number, + account_id, + debit_amount, + credit_amount, + currency, + amount_in_currency, + exchange_rate, + line_description, + tax_code, + dimensions, + sort_order + ) + SELECT + v_new_entry_id, + line.account_number, + line.account_id, + round(line.debit_amount, 2), + round(line.credit_amount, 2), + COALESCE(NULLIF(line.currency, ''), 'SEK'), + CASE + WHEN line.amount_in_currency IS NULL THEN NULL + ELSE round(line.amount_in_currency, 2) + END, + line.exchange_rate, + line.line_description, + line.tax_code, + COALESCE(line.dimensions, '{}'::jsonb), + COALESCE(line.sort_order, 0) + FROM jsonb_to_recordset(p_lines) AS line( + account_number text, + account_id uuid, + debit_amount numeric, + credit_amount numeric, + currency text, + amount_in_currency numeric, + exchange_rate numeric, + line_description text, + tax_code text, + dimensions jsonb, + sort_order integer + ); + + GET DIAGNOSTICS v_line_count = ROW_COUNT; + IF v_line_count <> jsonb_array_length(p_lines) THEN + RAISE EXCEPTION 'Replacement opening balance line count changed during insert'; + END IF; + + INSERT INTO public.journal_entries ( + id, + user_id, + company_id, + fiscal_period_id, + voucher_number, + voucher_series, + entry_date, + description, + source_type, + source_id, + reverses_id, + status + ) VALUES ( + v_storno_entry_id, + p_user_id, + p_company_id, + p_period_id, + 0, + COALESCE(v_old_entry.voucher_series, 'A'), + p_entry_date, + 'Makulering: ' || v_old_entry.description, + 'storno', + v_old_entry.source_id, + v_old_entry.id, + 'draft' + ); + + INSERT INTO public.journal_entry_lines ( + journal_entry_id, + account_number, + account_id, + debit_amount, + credit_amount, + currency, + amount_in_currency, + exchange_rate, + line_description, + tax_code, + dimensions, + sort_order + ) + SELECT + v_storno_entry_id, + line.account_number, + line.account_id, + line.credit_amount, + line.debit_amount, + line.currency, + CASE + WHEN line.amount_in_currency IS NULL OR line.amount_in_currency = 0 THEN NULL + ELSE -line.amount_in_currency + END, + line.exchange_rate, + 'Reversal: ' || COALESCE(line.line_description, ''), + line.tax_code, + line.dimensions, + line.sort_order + FROM public.journal_entry_lines line + WHERE line.journal_entry_id = v_old_entry.id; + + IF NOT FOUND THEN + RAISE EXCEPTION 'Expected opening balance has no lines: %', v_old_entry.id; + END IF; + + SELECT committed.voucher_number + INTO v_new_voucher_number + FROM public.commit_journal_entry( + p_company_id, + v_new_entry_id, + 'sie_import', + NULL, + p_actor_type, + p_actor_label + ) committed; + + SELECT committed.voucher_number + INTO v_storno_voucher_number + FROM public.commit_journal_entry( + p_company_id, + v_storno_entry_id, + 'sie_import', + NULL, + p_actor_type, + p_actor_label + ) committed; + + UPDATE public.journal_entries + SET status = 'reversed', + reversed_by_id = v_storno_entry_id + WHERE id = v_old_entry.id + AND company_id = p_company_id + AND status = 'posted'; + + GET DIAGNOSTICS v_updated_count = ROW_COUNT; + IF v_updated_count <> 1 THEN + RAISE EXCEPTION 'Opening balance changed concurrently: %', v_old_entry.id + USING ERRCODE = '40001'; + END IF; + + UPDATE public.fiscal_periods + SET opening_balances_set = false + WHERE id = p_period_id + AND company_id = p_company_id + AND opening_balance_entry_id = p_expected_old_entry_id + AND opening_balances_set = true; + + GET DIAGNOSTICS v_updated_count = ROW_COUNT; + IF v_updated_count <> 1 THEN + RAISE EXCEPTION 'Opening balance pointer changed concurrently for fiscal period %', p_period_id + USING ERRCODE = '40001'; + END IF; + + UPDATE public.fiscal_periods + SET opening_balance_entry_id = v_new_entry_id, + opening_balances_set = true + WHERE id = p_period_id + AND company_id = p_company_id + AND opening_balance_entry_id = p_expected_old_entry_id + AND opening_balances_set = false; + + GET DIAGNOSTICS v_updated_count = ROW_COUNT; + IF v_updated_count <> 1 THEN + RAISE EXCEPTION 'Opening balance pointer could not be replaced for fiscal period %', p_period_id + USING ERRCODE = '40001'; + END IF; + + RETURN QUERY SELECT + v_new_entry_id, + v_storno_entry_id, + v_new_voucher_number, + v_storno_voucher_number; +END; +$function$; + +REVOKE ALL ON FUNCTION public.commit_opening_balance_replacement( + uuid, uuid, uuid, uuid, date, text, text, jsonb, text, text +) FROM PUBLIC, anon; + +GRANT EXECUTE ON FUNCTION public.commit_opening_balance_replacement( + uuid, uuid, uuid, uuid, date, text, text, jsonb, text, text +) TO authenticated, service_role; + +NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/20260801222035_harden_atomic_opening_balance_replacement.sql b/supabase/migrations/20260801222035_harden_atomic_opening_balance_replacement.sql new file mode 100644 index 00000000..bc2b1954 --- /dev/null +++ b/supabase/migrations/20260801222035_harden_atomic_opening_balance_replacement.sql @@ -0,0 +1,407 @@ +-- Harden atomic opening balance replacement authorization and commit metadata. +-- +-- This full definition supersedes 20260801221047 without mutating that applied +-- staging migration. Archived companies must remain read-only, and the inner +-- voucher commits retain the engine's existing NULL commit method. +-- +-- The replacement entry, storno, original-entry status change, and fiscal +-- period pointer swap must either all commit or all roll back. The period row +-- lock closes the lock-date race, while the expected old entry id provides a +-- compare-and-swap guard against concurrent corrections. + +CREATE OR REPLACE FUNCTION public.commit_opening_balance_replacement( + p_company_id uuid, + p_period_id uuid, + p_expected_old_entry_id uuid, + p_user_id uuid, + p_entry_date date, + p_description text, + p_voucher_series text, + p_lines jsonb, + p_actor_type text DEFAULT NULL, + p_actor_label text DEFAULT NULL +) +RETURNS TABLE( + new_entry_id uuid, + storno_entry_id uuid, + new_voucher_number integer, + storno_voucher_number integer +) +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $function$ +DECLARE + v_claims jsonb := COALESCE( + NULLIF(current_setting('request.jwt.claims', true), '')::jsonb, + '{}'::jsonb + ); + v_jwt_role text := COALESCE( + NULLIF(v_claims ->> 'role', ''), + NULLIF(current_setting('request.jwt.claim.role', true), ''), + '' + ); + v_member_role text; + v_period public.fiscal_periods%ROWTYPE; + v_old_entry public.journal_entries%ROWTYPE; + v_lock_date date; + v_new_entry_id uuid := uuid_generate_v4(); + v_storno_entry_id uuid := uuid_generate_v4(); + v_new_voucher_number integer; + v_storno_voucher_number integer; + v_line_count integer; + v_updated_count integer; + v_total_debit numeric; + v_total_credit numeric; +BEGIN + IF v_jwt_role = 'authenticated' THEN + IF auth.uid() IS NULL OR p_user_id IS DISTINCT FROM auth.uid() THEN + RAISE EXCEPTION 'unauthorized: user attribution does not match caller' + USING ERRCODE = '42501'; + END IF; + ELSIF v_jwt_role <> 'service_role' THEN + RAISE EXCEPTION 'unauthorized: authenticated or service role required' + USING ERRCODE = '42501'; + END IF; + + SELECT cm.role + INTO v_member_role + FROM public.company_members cm + JOIN public.companies company + ON company.id = cm.company_id + AND company.archived_at IS NULL + WHERE cm.company_id = p_company_id + AND cm.user_id = p_user_id; + + IF v_member_role IS NULL + OR v_member_role NOT IN ('owner', 'admin', 'member') THEN + RAISE EXCEPTION 'unauthorized: caller cannot write to company %', p_company_id + USING ERRCODE = '42501'; + END IF; + + SELECT fp.* + INTO v_period + FROM public.fiscal_periods fp + WHERE fp.id = p_period_id + AND fp.company_id = p_company_id + FOR UPDATE; + + IF NOT FOUND THEN + RAISE EXCEPTION 'Fiscal period not found: %', p_period_id; + END IF; + + IF v_period.is_closed OR v_period.locked_at IS NOT NULL THEN + RAISE EXCEPTION 'Cannot replace opening balance in locked/closed fiscal period "%"', + v_period.name; + END IF; + + IF v_period.opening_balances_set IS NOT TRUE + OR v_period.opening_balance_entry_id IS DISTINCT FROM p_expected_old_entry_id THEN + RAISE EXCEPTION 'Opening balance changed concurrently for fiscal period %', p_period_id + USING ERRCODE = '40001'; + END IF; + + IF p_entry_date IS DISTINCT FROM v_period.period_start THEN + RAISE EXCEPTION 'Replacement opening balance date must equal fiscal period start %', + v_period.period_start; + END IF; + + SELECT cs.bookkeeping_locked_through + INTO v_lock_date + FROM public.company_settings cs + WHERE cs.company_id = p_company_id; + + IF v_lock_date IS NOT NULL AND p_entry_date <= v_lock_date THEN + RAISE EXCEPTION 'Bookkeeping is locked through %', v_lock_date; + END IF; + + SELECT je.* + INTO v_old_entry + FROM public.journal_entries je + WHERE je.id = p_expected_old_entry_id + AND je.company_id = p_company_id + AND je.fiscal_period_id = p_period_id + FOR UPDATE; + + IF NOT FOUND + OR v_old_entry.status <> 'posted' + OR v_old_entry.source_type <> 'opening_balance' THEN + RAISE EXCEPTION 'Expected opening balance is not a posted entry in period %', p_period_id; + END IF; + + IF jsonb_typeof(p_lines) <> 'array' OR jsonb_array_length(p_lines) = 0 THEN + RAISE EXCEPTION 'Replacement opening balance requires at least one line'; + END IF; + + SELECT + round(COALESCE(sum(line.debit_amount), 0), 2), + round(COALESCE(sum(line.credit_amount), 0), 2) + INTO v_total_debit, v_total_credit + FROM jsonb_to_recordset(p_lines) AS line( + account_number text, + account_id uuid, + debit_amount numeric, + credit_amount numeric, + currency text, + amount_in_currency numeric, + exchange_rate numeric, + line_description text, + tax_code text, + dimensions jsonb, + sort_order integer + ); + + IF v_total_debit <= 0 OR v_total_debit IS DISTINCT FROM v_total_credit THEN + RAISE EXCEPTION 'Replacement opening balance is not balanced (debit %, credit %)', + v_total_debit, v_total_credit; + END IF; + + IF EXISTS ( + SELECT 1 + FROM jsonb_to_recordset(p_lines) AS line( + account_number text, + account_id uuid, + debit_amount numeric, + credit_amount numeric, + currency text, + amount_in_currency numeric, + exchange_rate numeric, + line_description text, + tax_code text, + dimensions jsonb, + sort_order integer + ) + LEFT JOIN public.chart_of_accounts account + ON account.id = line.account_id + AND account.company_id = p_company_id + AND account.account_number = line.account_number + AND account.is_active = true + WHERE account.id IS NULL + OR line.account_number IS NULL + OR line.debit_amount IS NULL + OR line.credit_amount IS NULL + OR line.debit_amount < 0 + OR line.credit_amount < 0 + OR (line.debit_amount > 0 AND line.credit_amount > 0) + OR (line.debit_amount = 0 AND line.credit_amount = 0) + ) THEN + RAISE EXCEPTION 'Replacement opening balance contains an invalid line'; + END IF; + + INSERT INTO public.journal_entries ( + id, + user_id, + company_id, + fiscal_period_id, + voucher_number, + voucher_series, + entry_date, + description, + source_type, + status + ) VALUES ( + v_new_entry_id, + p_user_id, + p_company_id, + p_period_id, + 0, + COALESCE(NULLIF(p_voucher_series, ''), 'A'), + p_entry_date, + p_description, + 'opening_balance', + 'draft' + ); + + INSERT INTO public.journal_entry_lines ( + journal_entry_id, + account_number, + account_id, + debit_amount, + credit_amount, + currency, + amount_in_currency, + exchange_rate, + line_description, + tax_code, + dimensions, + sort_order + ) + SELECT + v_new_entry_id, + line.account_number, + line.account_id, + round(line.debit_amount, 2), + round(line.credit_amount, 2), + COALESCE(NULLIF(line.currency, ''), 'SEK'), + CASE + WHEN line.amount_in_currency IS NULL THEN NULL + ELSE round(line.amount_in_currency, 2) + END, + line.exchange_rate, + line.line_description, + line.tax_code, + COALESCE(line.dimensions, '{}'::jsonb), + COALESCE(line.sort_order, 0) + FROM jsonb_to_recordset(p_lines) AS line( + account_number text, + account_id uuid, + debit_amount numeric, + credit_amount numeric, + currency text, + amount_in_currency numeric, + exchange_rate numeric, + line_description text, + tax_code text, + dimensions jsonb, + sort_order integer + ); + + GET DIAGNOSTICS v_line_count = ROW_COUNT; + IF v_line_count <> jsonb_array_length(p_lines) THEN + RAISE EXCEPTION 'Replacement opening balance line count changed during insert'; + END IF; + + INSERT INTO public.journal_entries ( + id, + user_id, + company_id, + fiscal_period_id, + voucher_number, + voucher_series, + entry_date, + description, + source_type, + source_id, + reverses_id, + status + ) VALUES ( + v_storno_entry_id, + p_user_id, + p_company_id, + p_period_id, + 0, + COALESCE(v_old_entry.voucher_series, 'A'), + p_entry_date, + 'Makulering: ' || v_old_entry.description, + 'storno', + v_old_entry.source_id, + v_old_entry.id, + 'draft' + ); + + INSERT INTO public.journal_entry_lines ( + journal_entry_id, + account_number, + account_id, + debit_amount, + credit_amount, + currency, + amount_in_currency, + exchange_rate, + line_description, + tax_code, + dimensions, + sort_order + ) + SELECT + v_storno_entry_id, + line.account_number, + line.account_id, + line.credit_amount, + line.debit_amount, + line.currency, + CASE + WHEN line.amount_in_currency IS NULL OR line.amount_in_currency = 0 THEN NULL + ELSE -line.amount_in_currency + END, + line.exchange_rate, + 'Reversal: ' || COALESCE(line.line_description, ''), + line.tax_code, + line.dimensions, + line.sort_order + FROM public.journal_entry_lines line + WHERE line.journal_entry_id = v_old_entry.id; + + IF NOT FOUND THEN + RAISE EXCEPTION 'Expected opening balance has no lines: %', v_old_entry.id; + END IF; + + SELECT committed.voucher_number + INTO v_new_voucher_number + FROM public.commit_journal_entry( + p_company_id, + v_new_entry_id, + NULL, + NULL, + p_actor_type, + p_actor_label + ) committed; + + SELECT committed.voucher_number + INTO v_storno_voucher_number + FROM public.commit_journal_entry( + p_company_id, + v_storno_entry_id, + NULL, + NULL, + p_actor_type, + p_actor_label + ) committed; + + UPDATE public.journal_entries + SET status = 'reversed', + reversed_by_id = v_storno_entry_id + WHERE id = v_old_entry.id + AND company_id = p_company_id + AND status = 'posted'; + + GET DIAGNOSTICS v_updated_count = ROW_COUNT; + IF v_updated_count <> 1 THEN + RAISE EXCEPTION 'Opening balance changed concurrently: %', v_old_entry.id + USING ERRCODE = '40001'; + END IF; + + UPDATE public.fiscal_periods + SET opening_balances_set = false + WHERE id = p_period_id + AND company_id = p_company_id + AND opening_balance_entry_id = p_expected_old_entry_id + AND opening_balances_set = true; + + GET DIAGNOSTICS v_updated_count = ROW_COUNT; + IF v_updated_count <> 1 THEN + RAISE EXCEPTION 'Opening balance pointer changed concurrently for fiscal period %', p_period_id + USING ERRCODE = '40001'; + END IF; + + UPDATE public.fiscal_periods + SET opening_balance_entry_id = v_new_entry_id, + opening_balances_set = true + WHERE id = p_period_id + AND company_id = p_company_id + AND opening_balance_entry_id = p_expected_old_entry_id + AND opening_balances_set = false; + + GET DIAGNOSTICS v_updated_count = ROW_COUNT; + IF v_updated_count <> 1 THEN + RAISE EXCEPTION 'Opening balance pointer could not be replaced for fiscal period %', p_period_id + USING ERRCODE = '40001'; + END IF; + + RETURN QUERY SELECT + v_new_entry_id, + v_storno_entry_id, + v_new_voucher_number, + v_storno_voucher_number; +END; +$function$; + +REVOKE ALL ON FUNCTION public.commit_opening_balance_replacement( + uuid, uuid, uuid, uuid, date, text, text, jsonb, text, text +) FROM PUBLIC, anon; + +GRANT EXECUTE ON FUNCTION public.commit_opening_balance_replacement( + uuid, uuid, uuid, uuid, date, text, text, jsonb, text, text +) TO authenticated, service_role; + +NOTIFY pgrst, 'reload schema';