Files
accounted/lib/bookkeeping/__tests__/opening-balance-replacement.test.ts
T
Mattsson 18cdba3574 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
2026-08-02 20:39:29 +02:00

186 lines
5.4 KiB
TypeScript

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)
})
})