Files
accounted/lib/reports/trial-balance.ts
T
Mattsson 288915c152 Fix/fdb fr usrs (#1125)
* fix(invoices): return attachment filename in delivery history summaries

The 20260723003000 hardening dropped attachment_filename from
list_invoice_delivery_summaries, so the delivery history UI always fell
back to the generic "faktura.pdf" label. Recreate the RPC with the
filename included: it is derived from company name, customer name,
invoice number, and date, all already visible to every company member,
so the minimization boundary is unchanged. Addresses stay masked and
message content, BCC, and checksums stay server-side.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(reconciliation): surface own-account transfer legs in match-to-voucher by default

The second (incoming) leg of a transfer between two of the company's own
bank accounts was hidden in the 'Matcha mot befintlig verifikation' dialog
because the voucher counted as 'already matched' once its outgoing leg was
linked, even though the incoming account's line had no settling transaction.
Users read the empty default list as 'the app won't let me link this'.

get_account_gl_lines_for_matching now counts links per settlement account:
a transaction provably on another cash account no longer marks the voucher
as matched for the requested account, so the unsettled transfer leg surfaces
by default (and auto-selects on an exact match). Same-account N:1 stays
behind the 'Visa aven matchade verifikationer' opt-in, and transactions
without a resolvable cash account conservatively keep counting everywhere.
get_unlinked_gl_lines is deliberately untouched (feeds auto-reconcile).

Companion guard: mark_entry_as_opening_balance now refuses entries with
linked bank transactions, since half-settled transfer vouchers became
reachable in the reconciliation view's unmatched table where 'Mark som IB'
renders; re-tagging one would strand its transaction against a movement-
excluded entry. getReconciliationStatus counts unmatched GL lines with the
account-scoped RPC so the status card agrees with the table.

Fixes #1026

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* perf(api): cut prod p95 latency via local JWT auth, single-RT company resolution, and report aggregate RPCs

Baseline 2026-07-23 (487 prod samples): p50 160ms, p95 480ms, 13% of
requests over 300ms. Target: p95 under 300ms.

- requireAuth: verify JWTs locally via getClaims (ES256/JWKS) instead of
  a second network getUser per request; getUser fallback keeps HS256
  self-hosted and existing test mocks working; middleware still
  revocation-checks every /api request
- resolve_active_company RPC (20260723161000): one round trip replaces
  2-3 queries in getActiveCompanyId and middleware; PGRST202/42501 fall
  back to the legacy query path
- arsredovisning build-data: ~33 sequential round trips down to ~7,
  output byte-identical (snapshot-proven)
- currency rate route: stop bypassing the exchange_rates cache (missing
  supabase arg caused an external Riksbanken call on every request)
- document.get: parallelize row fetch, signed URL and audit event
- list_company_accounts RPC (20260723170000): accounts list in one round
  trip instead of paging past PostgREST's 1000-row cap
- vat-declaration route: drop a dead sequential company_settings query
- get_kpi_report_aggregates RPC (20260723180000): KPI report's three
  full-period line scans collapsed into one aggregate call; dimension-
  filtered path unchanged
- lint: fix 9 baseline errors, downgrade 4 react-hooks compiler rules to
  warn, zero the eslint baseline ratchet

All four gates green: lint 0 errors, 9163 tests, check:guards, build.
Migrations applied idempotently to staging only; prod receives them via
Supabase branching on merge.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(review): resolve PR review findings across auth, VAT declaration, and IB retag

- requireAuth getClaims fast path: pin iss (project URL) and aud
  ('authenticated'), log every fallback to getUser (ASVS V9.1 finding)
- remove the ignored accountingMethod parameter from calculateVatDeclaration
  and the dead company_settings.accounting_method reads in xlsx/pdf/eskd
  routes; v1 API keeps accepting the query param but documents it as a no-op
- close the mark_entry_as_opening_balance TOCTOU race with a transactions
  trigger (20260723190000, FOR KEY SHARE on journal_entries) + pg tests;
  applied to staging and smoke-verified both directions
- re-add the 42501 tenant guard to branch-local migration 20260723160000
  (function body had silently reverted to the pre-20260619130100 definition)
- document the buildK3Noter tbFullRows full-TB contract (uppskjuten skatt
  opening balance per BFNAR 2012:1 ch.29)
- add KPI VAT-liability test covering reduced-rate output accounts 2621/2631

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(db): use NULL-safe caller_is_company_member in opening-balance retag guard

The re-added tenant guard carried the pre-20260703180000 raw
NOT IN (SELECT user_company_ids()) pattern, which the
null-safe-tenant-guards ratchet blocks. Staging re-synced.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 16:16:55 +02:00

334 lines
14 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
}> {
const dimensionFilter =
options?.dimensions && Object.keys(options.dimensions).length > 0
? options.dimensions
: undefined
const excludeAllYearEndEntries = options?.excludeYearEndClosing
// Wave 1: the period row (for opening balance computation), the reversed
// year-end entry ids (only needed for excludeYearEndClosing), and the
// chart of accounts are mutually independent, so they share one parallel
// round trip instead of three sequential ones. The accounts list is now
// also fetched for reports that turn out empty or fail the closed-period
// guard below; that occasional extra read-only query is the price of a
// short critical path, and the returned data is unchanged.
const [periodResult, yearEndIdRows, accounts] = await Promise.all([
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(),
excludeAllYearEndEntries
? 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)
)
: Promise.resolve([] as Array<{ id: string }>),
// Account names for row labelling.
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 { data: period } = periodResult
// 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 yearEndEntryIds: string[] = yearEndIdRows.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
// getOpeningBalances always reports the period's opening_balance_entry_id
// back as obEntryId (see lib/reports/opening-balances.ts), so the id is
// known before that fetch resolves and the line queries below can run in
// the same round trip as the opening-balance read.
const obEntryId = period?.opening_balance_entry_id ?? null
// When the caller requests a sub-range starting after period_start, the
// "opening" of that window must include all activity since the period
// started (rolled forward below).
const rollForwardWindow =
options?.fromDate && period?.period_start && options.fromDate > period.period_start
? { periodStart: period.period_start, fromDate: options.fromDate }
: null
// Wave 2: opening balances (IB) at period_start, the IB roll-forward
// slice, and the period lines are independent reads. The array order
// [OB, roll-forward, lines] keeps each table's queries in the same order
// the sequential version issued them.
const [obResult, priorLines, lines] = await Promise.all([
// ── Opening balances (IB) at period_start ──────────────────────
getOpeningBalances(supabase, companyId, period),
// ── Roll IB forward from period_start up to fromDate ───────────
rollForwardWindow
? 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', rollForwardWindow.periodStart)
.lt('entry_date', rollForwardWindow.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,
})
: Promise.resolve(
[] as Array<{
id: string
account_number: string
debit_amount: number
credit_amount: number
}>
),
// ── 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.
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,
}),
])
// 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 }>()
: obResult.balances
// Additively fold the roll-forward lines into openingBalances so the
// downstream IB/period split stays correct without changing call sites.
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)
}
if (lines.length === 0 && openingBalances.size === 0) {
return { rows: [], totalDebit: 0, totalCredit: 0, isBalanced: true }
}
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,
}
}