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:
Mattsson
2026-08-02 20:39:29 +02:00
committed by GitHub
parent 8a2498a987
commit 18cdba3574
11 changed files with 2869 additions and 55 deletions
+3
View File
@@ -735,3 +735,6 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. 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.
@@ -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
View File
@@ -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.
+1
View File
@@ -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'
@@ -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<string, QueuedResult[]> {
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<ReturnType<typeof createJournalEntry>>)
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<ReturnType<typeof createJournalEntry>>)
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<ReturnType<typeof createJournalEntry>>)
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) ---
@@ -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<string> {
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<T>(
userId: string,
operation: (client: PoolClient) => Promise<T>,
): Promise<T> {
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<string> {
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,
})
})
})
+53 -6
View File
@@ -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)
})
+50 -43
View File
@@ -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<boolean> {
// 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,
@@ -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';
@@ -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';