Files
accounted/lib/reports/trial-balance.ts
T
Mattsson 466e55a015 Fix/invoice delivery and payment accounts (#1116)
* fix: reconcile annual reports with final closing entries

* test: cover annual report depreciation and VAT balances

* Merge remote-tracking branch 'origin/main' into fix/usr-fdbck-ch

* fix: show exact invoice delivery details

* fix: use currency account in invoice emails

* fix: address invoice delivery review feedback

* fix: harden invoice delivery and payment accounts

* test: assert RLS-denied zero-row updates

* fix: close remaining invoice compliance gaps

* fix: harden invoice archive authorization

* fix: close invoice delivery review findings

* fix: verify delivery finalization results

* fix: cap combined invoice email recipients

* fix: close final invoice compliance findings

* fix: prevent stale payment account saves

* test: prove invoice delivery isolation

* fix: close invoice privacy review findings

* test: normalize delivery retention dates
2026-07-23 09:54:02 +02:00

310 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { SupabaseClient } from '@supabase/supabase-js'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { fetchEntryLines, type EntryLinesQuery } from '@/lib/bookkeeping/entry-lines'
import { getOpeningBalances } from './opening-balances'
import type { TrialBalanceRow } from '@/types'
/**
* Generate trial balance (Saldobalans) for a fiscal period or a date range
* inside one.
*
* 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.
*
* When `fromDate`/`toDate` are passed, they must lie inside the fiscal
* period. The function rolls the IB forward from `period_start` to
* `fromDate − 1` (so "opening" reflects the state at `fromDate`) and limits
* period activity to `[fromDate, toDate]`. Defaults equal `period_start` and
* `period_end`: identical to the no-options behaviour.
*
* When `dimensions` is passed (map of SIE dim number → object code, e.g.
* `{"6":"P001"}`, AND across keys), both line queries filter with jsonb
* containment (`dimensions @> …`, served by idx_jel_dimensions_gin). The
* result is then a PARTIAL view: opening balances from year-end closing are
* company-wide, so callers must only use the filter for P&L-style reports
* (classes 3-8) where IB is immaterial: never for balance/statutory reports.
* The catalog whitelist + statutory-guard test pin this.
*
* Uses the shared two-step entry-lines fetch (lib/bookkeeping/entry-lines.ts):
* entries first, then lines chunked by entry id, both paginated, so any
* number of entries is handled without the pathological journal_entries!inner
* embed plan (see entry-lines.ts for the full story).
*/
export async function generateTrialBalance(
supabase: SupabaseClient,
companyId: string,
fiscalPeriodId: string,
options?: {
excludeYearEndClosing?: boolean
excludeFinalClosingEntry?: boolean
fromDate?: string
toDate?: string
dimensions?: Record<string, 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, period_end, opening_balance_entry_id, closing_entry_id, is_closed')
.eq('id', fiscalPeriodId)
.eq('company_id', companyId)
.single()
const dimensionFilter =
options?.dimensions && Object.keys(options.dimensions).length > 0
? options.dimensions
: undefined
// Existing operational reports intentionally exclude every year_end entry.
// Statutory annual reports must exclude only the linked final closing entry:
// tax, depreciation, and appropriations also use source_type year_end. A
// closed period without the link is ambiguous, so fail instead of silently
// understating the statutory report.
if (
options?.excludeFinalClosingEntry
&& period?.is_closed === true
&& !period.closing_entry_id
) {
throw new Error(
'Closed fiscal period is missing closing_entry_id; statutory pre-closing balances cannot be generated safely',
)
}
const excludeAllYearEndEntries = options?.excludeYearEndClosing
let yearEndEntryIds: string[] = []
if (excludeAllYearEndEntries) {
yearEndEntryIds = (
await fetchAllRows<{ id: string }>(({ from, to }) =>
supabase
.from('journal_entries')
.select('id')
.eq('company_id', companyId)
.eq('source_type', 'year_end')
.eq('status', 'reversed')
.order('id', { ascending: true })
.range(from, to)
)
).map((r) => r.id)
}
const excludeYearEndChain = (query: EntryLinesQuery): EntryLinesQuery => {
let q = query.neq('source_type', 'year_end')
if (yearEndEntryIds.length > 0) {
const idList = `(${yearEndEntryIds.join(',')})`
q = q.or(`reverses_id.is.null,reverses_id.not.in.${idList}`)
q = q.or(`correction_of_id.is.null,correction_of_id.not.in.${idList}`)
}
return q
}
const closingEntryId = options?.excludeFinalClosingEntry
? period?.closing_entry_id ?? null
: null
// The base query already admits only posted and reversed entries. Exclude a
// posted final closing entry, but retain a reversed one together with its
// storno so the two continue to net to zero. Draft entries never enter the
// base query.
const excludeClosingEntry = (query: EntryLinesQuery): EntryLinesQuery =>
closingEntryId
? query.or(`id.neq.${closingEntryId},status.neq.posted`)
: query
// ── Opening balances (IB) at period_start ──────────────────────
const { balances: obBalances, obEntryId } = await getOpeningBalances(
supabase, companyId, period
)
// A dimension-filtered view cannot use company-wide opening balances (the
// OB entry and the prior-period RPC are not dimension-aware). Drop them so
// every reported amount is dimension-scoped activity: correct for the P&L
// reports the filter is whitelisted for, and never fabricates balances if
// misapplied. obEntryId is still needed to exclude the OB entry from lines.
const openingBalances = dimensionFilter
? new Map<string, { debit: number; credit: number }>()
: obBalances
// ── Roll IB forward from period_start up to fromDate ───────────
// When the caller requests a sub-range starting after period_start, the
// "opening" of that window must include all activity since the period
// started. We additively fold those lines into openingBalances so the
// downstream IB/period split stays correct without changing call sites.
if (
options?.fromDate &&
period?.period_start &&
options.fromDate > period.period_start
) {
const priorLines = await fetchEntryLines<{
id: string
account_number: string
debit_amount: number
credit_amount: number
}>({
supabase,
lineColumns: 'id, account_number, debit_amount, credit_amount',
filterEntries: (q: EntryLinesQuery) => {
let query = q
.eq('company_id', companyId)
.eq('fiscal_period_id', fiscalPeriodId)
.in('status', ['posted', 'reversed'])
.gte('entry_date', period.period_start)
.lt('entry_date', options.fromDate)
if (obEntryId) {
query = query.neq('id', obEntryId)
}
if (excludeAllYearEndEntries) {
query = excludeYearEndChain(query)
}
if (options?.excludeFinalClosingEntry) {
query = excludeClosingEntry(query)
}
return query
},
filterLines: dimensionFilter
? // jsonb containment (@>): served by idx_jel_dimensions_gin.
(q: EntryLinesQuery) => q.contains('dimensions', dimensionFilter)
: undefined,
})
for (const line of priorLines) {
const existing = openingBalances.get(line.account_number) || { debit: 0, credit: 0 }
existing.debit += Number(line.debit_amount) || 0
existing.credit += Number(line.credit_amount) || 0
openingBalances.set(line.account_number, existing)
}
}
// ── 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 fetchEntryLines<{
id: string
account_number: string
debit_amount: number
credit_amount: number
}>({
supabase,
lineColumns: 'id, account_number, debit_amount, credit_amount',
filterEntries: (q: EntryLinesQuery) => {
let query = q
.eq('company_id', companyId)
.eq('fiscal_period_id', fiscalPeriodId)
.in('status', ['posted', 'reversed'])
// Date filters are only applied when the caller explicitly asks. The
// period itself is already enforced via fiscal_period_id, so adding
// redundant entry_date bounds for the default case would just
// increase query complexity (and break older mocks that don't stub gte
// /lte). The fiscal_period_id constraint plus a CHECK on entry_date in
// the engine keep activity inside the period.
if (options?.fromDate) {
query = query.gte('entry_date', options.fromDate)
}
if (options?.toDate) {
query = query.lte('entry_date', options.toDate)
}
if (obEntryId) {
query = query.neq('id', obEntryId)
}
if (excludeAllYearEndEntries) {
query = excludeYearEndChain(query)
}
if (options?.excludeFinalClosingEntry) {
query = excludeClosingEntry(query)
}
return query
},
filterLines: dimensionFilter
? // jsonb containment (@>): served by idx_jel_dimensions_gin.
(q: EntryLinesQuery) => q.contains('dimensions', dimensionFilter)
: undefined,
})
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('company_id', companyId)
.order('account_number', { ascending: true })
.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,
}
}