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
+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.