* fix: resolve BFL compliance violations in general ledger and trial balance Fix two compliance violations and a pre-existing double-counting bug: 1. .in(entryIds) truncation (BFL 5:2 completeness) — general-ledger.ts and journal-register.ts used .in() with dynamic ID arrays that silently truncate at ~1000 rows. Migrated to joined queries with fetchAllRows pagination, matching the pattern already used by trial-balance.ts. 2. Trial balance missing IB columns (BFNAR 2013:2) — opening_debit and opening_credit were hardcoded to 0. Now computed from the opening_balance_entry (set by year-end closing) or by summing prior- period entries as a fallback. 3. Double-counting after year-end closing — the opening_balance_entry's lines were counted as both IB and period activity. Now excluded from period queries via .neq() when the OB entry exists. Extracted shared getOpeningBalances() helper used by both trial balance and general ledger. Refactored test mocks from positional arrays to table-keyed queues for readability. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: add pagination and user_id filter to OB entry query Address review feedback: the obEntryId fast path in getOpeningBalances used a bare single-shot query without fetchAllRows (inconsistent with the PR's truncation fix) and lacked the user_id defense-in-depth filter required by CLAUDE.md guidelines. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
139 lines
5.1 KiB
TypeScript
139 lines
5.1 KiB
TypeScript
import type { SupabaseClient } from '@supabase/supabase-js'
|
|
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
|
import { getOpeningBalances } from './opening-balances'
|
|
import type { TrialBalanceRow } from '@/types'
|
|
|
|
/**
|
|
* Generate trial balance (Saldobalans) for a fiscal period.
|
|
*
|
|
* Computes IB (ingående balans), period movements, and UB (utgående balans)
|
|
* per BFNAR 2013:2 requirements. Uses the opening_balance_entry set by
|
|
* year-end closing when available; falls back to summing prior-period entries.
|
|
*
|
|
* Uses joined queries with pagination to handle any number of entries.
|
|
* Avoids the broken .in(entryIds) pattern that silently truncated at 1000 rows.
|
|
*/
|
|
export async function generateTrialBalance(
|
|
supabase: SupabaseClient,
|
|
userId: string,
|
|
fiscalPeriodId: string
|
|
): Promise<{
|
|
rows: TrialBalanceRow[]
|
|
totalDebit: number
|
|
totalCredit: number
|
|
isBalanced: boolean
|
|
}> {
|
|
|
|
// Fetch period for opening balance computation
|
|
const { data: period } = await supabase
|
|
.from('fiscal_periods')
|
|
.select('period_start, opening_balance_entry_id')
|
|
.eq('id', fiscalPeriodId)
|
|
.eq('user_id', userId)
|
|
.single()
|
|
|
|
// ── Opening balances (IB) ──────────────────────────────────────
|
|
const { balances: openingBalances, obEntryId } = await getOpeningBalances(
|
|
supabase, userId, period
|
|
)
|
|
|
|
// ── Period lines (excluding opening balance entry) ─────────────
|
|
// If year-end closing set an OB entry, exclude it from period lines so
|
|
// its values aren't double-counted (they're already captured as IB).
|
|
// Race condition note: if year-end closing runs concurrently and sets
|
|
// obEntryId between the period query and this query, the OB entry could
|
|
// be missed from both IB and period. The window is sub-second and the
|
|
// consequence is a single stale report — acceptable.
|
|
const lines = await fetchAllRows<{
|
|
account_number: string
|
|
debit_amount: number
|
|
credit_amount: number
|
|
}>(({ from, to }) => {
|
|
let query = supabase
|
|
.from('journal_entry_lines')
|
|
.select('account_number, debit_amount, credit_amount, journal_entries!inner(user_id, fiscal_period_id, status)')
|
|
.eq('journal_entries.user_id', userId)
|
|
.eq('journal_entries.fiscal_period_id', fiscalPeriodId)
|
|
.in('journal_entries.status', ['posted', 'reversed'])
|
|
|
|
if (obEntryId) {
|
|
query = query.neq('journal_entry_id', obEntryId)
|
|
}
|
|
|
|
return query.range(from, to)
|
|
})
|
|
|
|
if (lines.length === 0 && openingBalances.size === 0) {
|
|
return { rows: [], totalDebit: 0, totalCredit: 0, isBalanced: true }
|
|
}
|
|
|
|
// Get account names
|
|
const accounts = await fetchAllRows<{
|
|
account_number: string
|
|
account_name: string
|
|
account_class: number
|
|
}>(({ from, to }) =>
|
|
supabase
|
|
.from('chart_of_accounts')
|
|
.select('account_number, account_name, account_class')
|
|
.eq('user_id', userId)
|
|
.range(from, to)
|
|
)
|
|
|
|
const accountMap = new Map<string, { name: string; class: number }>()
|
|
for (const acc of accounts) {
|
|
accountMap.set(acc.account_number, {
|
|
name: acc.account_name,
|
|
class: acc.account_class,
|
|
})
|
|
}
|
|
|
|
// Aggregate period activity by account
|
|
const periodBalances = new Map<string, { debit: number; credit: number }>()
|
|
|
|
for (const line of lines) {
|
|
const existing = periodBalances.get(line.account_number) || { debit: 0, credit: 0 }
|
|
existing.debit += Number(line.debit_amount) || 0
|
|
existing.credit += Number(line.credit_amount) || 0
|
|
periodBalances.set(line.account_number, existing)
|
|
}
|
|
|
|
// Merge account numbers from both opening and period
|
|
const allAccountNumbers = new Set([...openingBalances.keys(), ...periodBalances.keys()])
|
|
|
|
// Build rows: IB + period = UB
|
|
const rows: TrialBalanceRow[] = []
|
|
for (const accountNumber of allAccountNumbers) {
|
|
const opening = openingBalances.get(accountNumber) || { debit: 0, credit: 0 }
|
|
const periodActivity = periodBalances.get(accountNumber) || { debit: 0, credit: 0 }
|
|
const accountInfo = accountMap.get(accountNumber) || {
|
|
name: `Konto ${accountNumber}`,
|
|
class: parseInt(accountNumber[0]) || 0,
|
|
}
|
|
|
|
rows.push({
|
|
account_number: accountNumber,
|
|
account_name: accountInfo.name,
|
|
account_class: accountInfo.class,
|
|
opening_debit: Math.round(opening.debit * 100) / 100,
|
|
opening_credit: Math.round(opening.credit * 100) / 100,
|
|
period_debit: Math.round(periodActivity.debit * 100) / 100,
|
|
period_credit: Math.round(periodActivity.credit * 100) / 100,
|
|
closing_debit: Math.round((opening.debit + periodActivity.debit) * 100) / 100,
|
|
closing_credit: Math.round((opening.credit + periodActivity.credit) * 100) / 100,
|
|
})
|
|
}
|
|
|
|
rows.sort((a, b) => a.account_number.localeCompare(b.account_number))
|
|
|
|
const totalDebit = Math.round(rows.reduce((sum, r) => sum + r.closing_debit, 0) * 100) / 100
|
|
const totalCredit = Math.round(rows.reduce((sum, r) => sum + r.closing_credit, 0) * 100) / 100
|
|
|
|
return {
|
|
rows,
|
|
totalDebit,
|
|
totalCredit,
|
|
isBalanced: Math.abs(totalDebit - totalCredit) < 0.01,
|
|
}
|
|
}
|