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
This commit is contained in:
@@ -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<Record<string, unknown>>
|
||||
}
|
||||
|
||||
async function seedOpeningBalance(params: {
|
||||
userId: string
|
||||
companyId: string
|
||||
fiscalPeriodId: string
|
||||
debit?: number
|
||||
link?: boolean
|
||||
}): Promise<SeededOpeningBalance> {
|
||||
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<void> {
|
||||
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<T>(
|
||||
role: 'authenticated' | 'service_role',
|
||||
userId: string | undefined,
|
||||
operation: (client: PoolClient) => Promise<T>,
|
||||
): Promise<T> {
|
||||
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<Record<string, unknown>>
|
||||
},
|
||||
): Promise<ReplacementResult> {
|
||||
const result = await client.query<ReplacementResult>(
|
||||
`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<void> {
|
||||
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,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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<string, unknown> = {}
|
||||
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)
|
||||
})
|
||||
})
|
||||
+184
-3
@@ -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<string, string>
|
||||
) {
|
||||
@@ -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<string, string>
|
||||
) {
|
||||
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<OpeningBalanceReplacementResult> {
|
||||
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.
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user