fix(reports): exclude opening-balance entry from SIE #UB movement (#809)
* fix(reports): exclude opening-balance entry from SIE #UB movement getOpeningBalances() returns obEntryId so callers can exclude the OB entry from period queries to prevent double-counting. sie-export.ts was discarding obEntryId, so the OB entry's lines were counted in both openingBalancesByAccount and calculateBalances. The double-count cancelled the real net movement, leaving #UB = #IB for any account that was zeroed out during the year (e.g. a closed bank account). Fix: filter the OB entry out into periodEntries before the #VER loop and calculateBalances, matching the pattern already used by trial-balance.ts and general-ledger.ts. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Signed-off-by: Jonas Flodén <jonas@floden.nu> * test(reports): add regression test for SIE #UB double-count via OB entry Adds a test that exercises the exact scenario that was broken: the OB entry returned by the journal_entries query being included in both getOpeningBalances (#IB) and calculateBalances (movement), leaving #UB = #IB for an account zeroed out during the year. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Signed-off-by: Jonas Flodén <jonas@floden.nu> --------- Signed-off-by: Jonas Flodén <jonas@floden.nu>
This commit is contained in:
@@ -483,4 +483,73 @@ describe('generateSIEExport', () => {
|
||||
expect(output).toContain('#UB 0 1930 12000.00')
|
||||
expect(output).toContain('#UB 0 2440 -12000.00')
|
||||
})
|
||||
|
||||
it('excludes the OB entry from #VER output and #UB movement so a zeroed-out account gets #UB 0', async () => {
|
||||
// Regression: the OB entry was included in both getOpeningBalances (#IB)
|
||||
// and calculateBalances (movement), so its debit cancelled the real net
|
||||
// movement and left #UB = #IB for any account zeroed out during the year.
|
||||
//
|
||||
// Setup: 1933 opens at 96 466,59 (IB), is swept to 0 via a transfer to
|
||||
// 1930 during the year. The OB entry (id: 'ob-entry-1') is returned by
|
||||
// the journal_entries query because it lives in the same fiscal period.
|
||||
results = [
|
||||
{ data: { id: 'period-1', period_start: '2024-01-01', period_end: '2024-12-31', opening_balance_entry_id: 'ob-entry-1' }, error: null },
|
||||
{ data: null, error: null }, // prevPeriod
|
||||
{ data: [], error: null }, // accounts
|
||||
{
|
||||
data: [
|
||||
// The OB entry itself — must be excluded from movement/VER output
|
||||
{
|
||||
id: 'ob-entry-1',
|
||||
entry_date: '2024-01-01',
|
||||
voucher_number: 1,
|
||||
voucher_series: 'A',
|
||||
description: 'IB 2024',
|
||||
status: 'posted',
|
||||
lines: [
|
||||
{ account_number: '1933', debit_amount: 96466.59, credit_amount: 0, line_description: 'IB 1933', cost_center: null, project: null },
|
||||
{ account_number: '2019', debit_amount: 0, credit_amount: 96466.59, line_description: null, cost_center: null, project: null },
|
||||
],
|
||||
},
|
||||
// A real transaction: account 1933 swept to 1930
|
||||
{
|
||||
id: 'e2',
|
||||
entry_date: '2024-08-07',
|
||||
voucher_number: 2,
|
||||
voucher_series: 'A',
|
||||
description: 'Stängning Bokio',
|
||||
status: 'posted',
|
||||
lines: [
|
||||
{ account_number: '1930', debit_amount: 96466.59, credit_amount: 0, line_description: null, cost_center: null, project: null },
|
||||
{ account_number: '1933', debit_amount: 0, credit_amount: 96466.59, line_description: null, cost_center: null, project: null },
|
||||
],
|
||||
},
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
{ data: [], error: null }, // cost_centers
|
||||
{ data: [], error: null }, // projects
|
||||
// fetchAllRows for OB entry lines (opening_balance_entry_id is set)
|
||||
{
|
||||
data: [
|
||||
{ account_number: '1933', debit_amount: 96466.59, credit_amount: 0 },
|
||||
{ account_number: '2019', debit_amount: 0, credit_amount: 96466.59 },
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
]
|
||||
|
||||
const output = await generateSIEExport(supabase, 'company-1', baseOptions)
|
||||
|
||||
// IB recorded correctly
|
||||
expect(output).toContain('#IB 0 1933 96466.59')
|
||||
// 1933 was zeroed out: UB must be 0, not a repeat of IB
|
||||
expect(output).toContain('#UB 0 1933 0.00')
|
||||
// 1930 received the sweep: UB = 0 + 96466.59
|
||||
expect(output).toContain('#UB 0 1930 96466.59')
|
||||
// OB entry must NOT appear as a #VER block
|
||||
expect(output).not.toContain('"IB 2024"')
|
||||
// The real transfer entry must appear
|
||||
expect(output).toContain('"Stängning Bokio"')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -198,7 +198,7 @@ export async function generateSIEExport(
|
||||
// balances RPC derives IB from earlier journal lines instead of silently
|
||||
// emitting zero #IB records and producing wrong #UB values.
|
||||
const openingBalancesByAccount = new Map<string, number>()
|
||||
const { balances: obBalances } = await getOpeningBalances(supabase, companyId, {
|
||||
const { balances: obBalances, obEntryId } = await getOpeningBalances(supabase, companyId, {
|
||||
period_start: period.period_start,
|
||||
opening_balance_entry_id: period.opening_balance_entry_id ?? null,
|
||||
})
|
||||
@@ -210,8 +210,12 @@ export async function generateSIEExport(
|
||||
openingBalancesByAccount.set(accountNumber, amount)
|
||||
}
|
||||
|
||||
// Exclude the OB entry from VER/TRANS and from movement calculations to
|
||||
// prevent double-counting: it is already represented by the #IB records above.
|
||||
const periodEntries = (entries as JournalEntry[])?.filter(e => e.id !== obEntryId) ?? []
|
||||
|
||||
// === Journal entries (VER + TRANS) ===
|
||||
for (const entry of (entries as JournalEntry[]) || []) {
|
||||
for (const entry of periodEntries) {
|
||||
const entryLines = (entry.lines as JournalEntryLine[]) || []
|
||||
const entryDate = dateStringToSIE(entry.entry_date)
|
||||
const series = entry.voucher_series || 'A'
|
||||
@@ -248,7 +252,7 @@ export async function generateSIEExport(
|
||||
|
||||
// === Closing balances (UB for balance sheet, RES for income statement) ===
|
||||
// Movement balances from journal entries
|
||||
const movementBalances = calculateBalances(entries as JournalEntry[])
|
||||
const movementBalances = calculateBalances(periodEntries)
|
||||
|
||||
// Merge all accounts that have either IB or movements
|
||||
const allAccountNumbers = new Set([
|
||||
|
||||
Reference in New Issue
Block a user