Files
accounted/lib/reports/supplier-reconciliation.ts
T
Jakob Wennberg a8801430f4 fix(reports): stop driving report queries from the unfiltered journal_entry_lines side (#971)
* fix(reports): drive report line queries from journal_entries, not the unfiltered lines side

Every report generator fetched journal_entry_lines with a
journal_entries!inner(...) embed and put the tenant filter on the
embedded side (.eq('journal_entries.company_id', ...)). PostgREST
compiles that to a correlated INNER JOIN LATERAL with a parameterized
LIMIT inside, which blocks join reordering: Postgres walked the ENTIRE
journal_entry_lines table (603k rows, all tenants) per report query.
Measured in production: 13.6 s vs 2.7 ms for the equivalent plain join,
against Supabase's 8 s statement_timeout; nightly cloud backups failed
for 5 of 11 companies on 2026-07-09 and a GL report 500'd.

Introduce lib/bookkeeping/entry-lines.ts with a shared two-step fetch:

1. fetch matching journal_entries (id + caller-selected columns)
   filtered by company_id / fiscal_period_id / status / entry_date /
   source_type, paginated via fetchAllRows;
2. fetch journal_entry_lines with .in('journal_entry_id', chunk) in
   chunks of 100 ids (URL-length safety), paginated per chunk;
3. reattach the parent entry to each line under the embed's key shape
   (line.journal_entries = {...}, aliasable) and sort lines by id
   ascending to preserve the old .order('id') semantics.

Converted call sites (selected columns and filters preserved):
trial-balance (x2), general-ledger, journal-register, sie-export
(reuses its existing entry list via fetchLinesByEntryIds),
vat-declaration, dimension-pnl, opening-balances, monthly-breakdown,
periodisk-sammanstallning, rc-basis-gaps (sibling-line fetch now also
chunked), ar-reconciliation, supplier-reconciliation,
bank-reconciliation, asset-service (x2), bolagsskatt-calculator,
sarskild-loneskatt-calculator.

Tests: unit tests for the helper (chunk size, reattachment shape,
forced id/journal_entry_id columns, empty result, cross-chunk sort,
error propagation); existing report/reconciliation/bokslut test mocks
updated to the two-step query shape, preserving every assertion about
report output.

From the 2026-07-09 production log triage.

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

* fix(reports): stop echoing raw error messages from the general-ledger route

The catch handler returned err.message to the client in
details.reason; internal error strings (SQL fragments, table names,
timeout messages) must not reach the browser. The error is already
logged server-side with the request id, so the client envelope keeps
only the REPORT_GENERATION_FAILED code.

From the 2026-07-09 production log triage.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 11:03:28 +02:00

123 lines
5.0 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 { resolveSekAmount } from '@/lib/bookkeeping/currency-utils'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { fetchEntryLines, type EntryLinesQuery } from '@/lib/bookkeeping/entry-lines'
export interface ReconciliationResult {
supplier_ledger_total: number
account_2440_balance: number
difference: number
is_reconciled: boolean
/**
* Number of foreign-currency invoices that lacked an exchange_rate, so their
* remaining_amount could not be converted to SEK. When > 0 the difference
* field may be misleading: any reported gap could be missing-data rather
* than a true reconciliation break.
*/
unconverted_fx_count: number
}
/**
* Compare sum of open supplier invoices against account 2440 balance.
*
* Conversion uses each invoice's stored exchange_rate (the invoice-date rate),
* which matches what was originally posted to 2440. This means the report will
* diverge from the GL once partial payments settle at a different rate (the
* delta is correctly booked as valutakursvinst/-förlust to 3960/7960 per
* ML 8 kap 21-23 §). A subledger-derived total would reconcile through that
* difference; deferred to a follow-up.
*/
export async function generateReconciliation(
supabase: SupabaseClient,
companyId: string,
periodId: string
): Promise<ReconciliationResult> {
// remaining_amount is stored in invoice currency; account 2440 is in SEK
// (booked at invoice-date rate), so convert each row before summing.
// Paginated: a company with >1000 open supplier invoices would otherwise be
// silently truncated, manufacturing a phantom reconciliation gap.
const invoices = await fetchAllRows<{
id: string
remaining_amount: number | null
currency: string | null
exchange_rate: number | null
}>(({ from, to }) =>
supabase
.from('supplier_invoices')
.select('id, remaining_amount, currency, exchange_rate')
.eq('company_id', companyId)
.in('status', ['registered', 'approved', 'partially_paid', 'overdue'])
.order('id', { ascending: true })
.range(from, to)
)
let unconvertedFxCount = 0
const supplierLedgerTotal = (invoices || [])
.reduce((sum, inv) => {
const isFx = inv.currency && inv.currency !== 'SEK'
const hasRate = inv.exchange_rate != null && Number(inv.exchange_rate) > 0
// Skip unconvertible FX rows from the sum: adding raw foreign amounts
// to a SEK total is arithmetically unsound. Counted instead.
if (isFx && !hasRate) {
unconvertedFxCount += 1
return sum
}
const sek = resolveSekAmount(
Number(inv.remaining_amount) || 0,
null,
inv.currency,
inv.exchange_rate
)
return Math.round((sum + sek) * 100) / 100
}, 0)
// Get account 2440 balance from the ledger in this period. We count posted
// AND reversed entries together: the SAME inclusion rule the trial balance /
// balance sheet use. A corrected supplier invoice flips its original
// registration to status='reversed' (storno-service.ts); that reversed credit
// on 2440 is cancelled by the posted storno's debit, so BOTH legs must be
// summed or the report double-counts the payment debit and shows a phantom
// debit balance. (This is exactly the false 41 121,25 kr "Ej avstämd" gap a
// fully-paid, fully-corrected company hit: posted-only = 41 121,25, but
// posted+reversed = 0, matching the leverantörsreskontra.)
// Fetched via the two-step entry-lines helper (entries first, then lines
// chunked by entry id, both paginated): see lib/bookkeeping/entry-lines.ts.
const journalLines = await fetchEntryLines<{
id: string
debit_amount: number | null
credit_amount: number | null
}>({
supabase,
lineColumns: 'id, debit_amount, credit_amount',
filterEntries: (q: EntryLinesQuery) =>
q
.eq('company_id', companyId)
.eq('fiscal_period_id', periodId)
.in('status', ['posted', 'reversed']),
filterLines: (q: EntryLinesQuery) => q.eq('account_number', '2440'),
attachEntriesAs: null,
})
// Account 2440 is a liability: credit normal balance
// Balance = credits - debits
let account2440Balance = 0
for (const line of journalLines) {
account2440Balance = Math.round((account2440Balance + (Number(line.credit_amount) || 0) - (Number(line.debit_amount) || 0)) * 100) / 100
}
const difference = Math.round((supplierLedgerTotal - account2440Balance) * 100) / 100
return {
supplier_ledger_total: Math.round(supplierLedgerTotal * 100) / 100,
account_2440_balance: Math.round(account2440Balance * 100) / 100,
difference,
// BFL 5 kap requires the reconciliation to cover all affärshändelser. If
// any row was excluded for a missing exchange rate, the calculation is
// incomplete by construction and we cannot honestly stamp the period
// Avstämd: the user must fix the underlying data first.
is_reconciled: Math.abs(difference) < 0.01 && unconvertedFxCount === 0,
unconverted_fx_count: unconvertedFxCount,
}
}