Files
accounted/lib/reconciliation/bank-reconciliation.ts
T
Jakob WennbergandClaude Opus 4.8 027734ffc7 fix(reconciliation): scope bank avstämning to a fiscal period so the IB stops counting (#751) (#754)
* fix(reconciliation): scope bank reconciliation to a fiscal period so the IB stops counting (#751)

The bank reconciliation widget defaulted its date window to "full history"
(empty dateFrom). With no lower bound the GL side spans the fiscal-year
boundary: a prior period's movements on the account net to exactly the
opening balance, and the new period's IB entry adds another copy. The IB
*summary* was excluded but the prior-period *detail* stayed in the period
movement while the bank feed only covered the current period — a phantom
difference equal to the IB (the "räknar med IB fast den säger borträknad"
report in #751).

- Server: getReconciliationStatus now floors the window at the most recent
  opening-balance date on the account (effectiveFrom = max(dateFrom, ibDate))
  and clamps both the GL movement set and the bank-feed set identically.
  Derived from the already-fetched lines — no extra query. A no-op when the
  caller already passes period_start; a safety net otherwise.
- UI: BankReconciliationView scopes to a fiscal period via FiscalYearSelector
  (defaults to the newest period), seeding dateFrom/dateTo and gating the
  initial fetch so the full-history numbers never flash.
- Tests: two regression cases reproducing the cross-period scenario.

Proven against prod: full-history -> difference -10 172,94 (matched the
screenshot); period-bounded -> 0,00.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(reconciliation): re-fetch on fiscal-period switch; document IB-floor choice

Review follow-up on #754:
- BankReconciliationView: add selectedPeriodId to the gated fetch effect deps
  so switching räkenskapsår re-fetches with the new window, and a late period
  selection (selector signalling ready before the company context hydrates) still
  triggers the real period-scoped fetch instead of leaving the empty-window
  result. Manual date edits still stay on the explicit "Filtrera" action.
- getReconciliationStatus: comment why ibFloor takes the LATEST opening-balance
  date (one IB per period invariant; across a multi-year window the most recent
  IB is the intended floor; same-date duplicates cancel).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(reconciliation): cover mid-period window (dateFrom after the IB date)

Review follow-up on #754: documents that a per-month reconciliation window
starting after the fiscal-year IB correctly excludes the IB and reconciles on
the in-window movements alone (gl_1930_opening_balance = 0 by design).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 13:49:00 +02:00

1046 lines
40 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 type { Transaction, ReconciliationMethod } from '@/types'
import { eventBus } from '@/lib/events/bus'
import { logMatchEvent } from '@/lib/invoices/match-log'
// ============================================================
// Types
// ============================================================
/** A posted journal entry line on account 1930 not yet linked to any transaction */
export interface UnlinkedGLLine {
line_id: string
journal_entry_id: string
debit_amount: number
credit_amount: number
line_description: string | null
entry_date: string
voucher_number: number
voucher_series: string
entry_description: string
source_type: string
/** How many bank transactions already point at this entry. Present only on
* rows from get_account_gl_lines_for_matching (the N:1 candidate fetch);
* undefined on the unmatched-only path, where it is always implicitly 0. */
linked_transaction_count?: number
}
export interface ReconciliationMatch {
transaction: Transaction
glLine: UnlinkedGLLine
method: ReconciliationMethod
confidence: number
}
export interface ReconciliationRunResult {
matches: ReconciliationMatch[]
applied: number
errors: number
}
export interface ReconciliationStatus {
bank_transaction_total: number
/**
* The real ledger balance on the bank account, incl. IB — computed from the
* SAME `['posted','reversed']` lines the trial balance and balance sheet sum,
* so this value is identical to what the balansräkning reports for this
* account. (Use `gl_1930_period_movement` for the reconciliation diff, since
* this figure still includes the opening balance.)
*/
gl_1930_balance: number
/** Ledger movement on the bank account excluding only opening_balance — i.e.
* the ledger balance minus IB. Storno/correction lines ARE included here
* (they're part of the balance), so a corrected bank line reconciles against
* its re-pointed feed transaction. This is what `difference` compares against. */
gl_1930_period_movement: number
/** IB on the bank account within the date range — surfaced separately so
* reconciliation doesn't treat it as an unmatched bank transaction. */
gl_1930_opening_balance: number
/** Net of posted storno/correction lines on the bank account within the date
* range. INFORMATIONAL ONLY — it is part of the ledger balance and is included
* in gl_1930_period_movement, not subtracted from it. Surfaced so the UI can
* show how much of the period's movement came from corrections. */
gl_1930_correction_adjustment: number
/** bankTotal − gl_1930_period_movement. Zero when every period transaction is matched. */
difference: number
is_reconciled: boolean
matched_count: number
unmatched_transaction_count: number
unmatched_gl_line_count: number
}
export interface ReconciliationOptions {
dateFrom?: string
dateTo?: string
dryRun?: boolean
/**
* Settlement account number to reconcile against (e.g. '1930' for SEK,
* '1932' for EUR). Defaults to '1930' so existing callers stay correct.
* The cash_accounts table is the source of truth for which BAS codes are
* routable for a given company.
*/
accountNumber?: string
/**
* Currency to filter transactions on. Defaults to 'SEK' for back-compat;
* future multi-currency reconciliation passes the currency of the selected
* cash account so EUR transactions reconcile against 1932 etc.
*/
currency?: string
/**
* cash_accounts.id of the selected account. When set, transactions are
* scoped to this exact account (with a currency fallback for legacy rows
* whose cash_account_id hasn't been backfilled yet) instead of being matched
* by currency alone — this is what stops two same-currency accounts (e.g.
* checking 1930 + savings 1931) from pooling together. Omit for the legacy
* currency-only behaviour.
*/
cashAccountId?: string
/**
* Whether this account claims rows with a NULL cash_account_id (legacy /
* unassigned). Only the company's primary cash account should — see
* scopeTransactionsToAccount. Defaults to true for back-compat with the
* currency-only callers (where cashAccountId is omitted and this is moot).
*/
includeUnassigned?: boolean
}
/**
* Scope a transactions query builder to a single cash account, tolerating
* legacy rows that predate the cash_account_id backfill.
*
* The applied filter is one of:
* includeUnassigned=true: currency = cur AND (cash_account_id = X OR cash_account_id IS NULL)
* includeUnassigned=false: currency = cur AND cash_account_id = X
* no cashAccountId: currency = cur (legacy currency-only path)
*
* Why `includeUnassigned` exists: a NULL cash_account_id row belongs to exactly
* ONE account, but the query can't tell which — these are unbooked rows in
* companies with ≥2 same-currency accounts (the backfill refuses to guess
* between checking + savings) and booked own-account transfers the backfill
* deliberately skips (>1 bank-class line). Attributing them to EVERY
* same-currency account double-counts them: a 1931 savings account would pull in
* 1930's unassigned rows, so Bankavstämning reported a large bogus difference
* for 1931 while 1930 itself still reconciled. The fix: only the company's
* PRIMARY cash account (cash_accounts.is_primary — exactly one per company)
* claims NULL rows; every other account scopes strictly to its own id. Callers
* pass `includeUnassigned = <this account is_primary>`. When cashAccountId is
* omitted (single-account companies with no row, the '1930' fallback) the pure
* currency filter is used and includeUnassigned is moot.
*
* The earlier nested `or(cash_account_id.eq.X,and(cash_account_id.is.null,currency.eq.cur))`
* form is intentionally avoided — it silently returned ZERO rows mid-backfill.
* A cash account has exactly one currency, so the flat two-term `or` is reliable.
*/
export function scopeTransactionsToAccount<Q extends {
or(filters: string): Q
eq(column: string, value: string): Q
}>(query: Q, cashAccountId: string | undefined, currency: string, includeUnassigned = true): Q {
// Both values are interpolated into a raw PostgREST filter string below. They
// are DB-derived in every caller (cash_accounts.id / .currency, or the 'SEK'
// default), never raw user input — but assert their shape anyway so a future
// caller cannot thread an unsanitized value through into the filter.
if (!/^[A-Z]{3}$/.test(currency)) {
throw new Error(`scopeTransactionsToAccount: invalid currency ${JSON.stringify(currency)}`)
}
if (cashAccountId) {
if (!/^[0-9a-fA-F-]{36}$/.test(cashAccountId)) {
throw new Error('scopeTransactionsToAccount: invalid cashAccountId (expected UUID)')
}
if (includeUnassigned) {
return query
.eq('currency', currency)
.or(`cash_account_id.eq.${cashAccountId},cash_account_id.is.null`)
}
// Non-primary account: strict — never claim the company's unassigned NULL rows.
return query.eq('currency', currency).eq('cash_account_id', cashAccountId)
}
return query.eq('currency', currency)
}
// ============================================================
// In-memory matching: single transaction against GL line pool
// ============================================================
/**
* Try to reconcile a single transaction against a pool of unlinked GL lines.
* Returns the best match or null. Purely in-memory, no DB calls.
*
* `expectedCurrency` filters which transactions can match — defaults to 'SEK'
* so existing callers behave identically.
*/
export function tryReconcileTransaction(
transaction: Transaction,
glLines: UnlinkedGLLine[],
expectedCurrency: string = 'SEK',
): ReconciliationMatch | null {
if (transaction.currency !== expectedCurrency) return null
if (glLines.length === 0) return null
const txAmount = transaction.amount
const txDate = transaction.date
const txReference = (transaction.reference || '').toLowerCase()
let bestMatch: ReconciliationMatch | null = null
for (const line of glLines) {
const lineAmount = getDirectionalAmount(line)
if (!isDirectionCompatible(txAmount, line)) continue
const amountMatches = Math.abs(Math.abs(txAmount) - Math.abs(lineAmount)) < 0.005
const fuzzyAmountMatches = Math.abs(Math.abs(txAmount) - Math.abs(lineAmount)) <= 0.01
const exactDateMatch = txDate === line.entry_date
const dateWithinRange = isDateWithinRange(txDate, line.entry_date, 3)
// Reference matches require BOTH a real OCR/reference token AND a bounded
// date window. Never description-only — that collides on recurring monthly
// charges (same description, same amount, different year). Never cross-year.
const referenceMatch =
hasOcrReferenceMatch(txReference, line) &&
isDateWithinRange(txDate, line.entry_date, 90)
let method: ReconciliationMethod | null = null
let confidence = 0
// Pass 1: Exact amount + exact date
if (amountMatches && exactDateMatch) {
method = 'auto_exact'
confidence = 0.95
}
// Pass 2: Exact amount + OCR/reference match within ±90 days
else if (amountMatches && referenceMatch) {
method = 'auto_reference'
confidence = 0.90
}
// Pass 3: Exact amount + date within ±3 days
else if (amountMatches && dateWithinRange) {
method = 'auto_date_range'
confidence = 0.85
}
// Pass 4: Fuzzy amount (±0.01) + exact date
else if (fuzzyAmountMatches && exactDateMatch) {
method = 'auto_fuzzy'
confidence = 0.75
}
if (method && confidence > (bestMatch?.confidence ?? 0)) {
bestMatch = { transaction, glLine: line, method, confidence }
}
}
return bestMatch
}
// ============================================================
// Batch reconciliation
// ============================================================
/**
* Run auto-reconciliation for all unmatched transactions.
* Fetches data, runs 4-pass matching, optionally applies matches.
*/
export async function runReconciliation(
supabase: SupabaseClient,
companyId: string,
userId: string,
options: ReconciliationOptions = {}
): Promise<ReconciliationRunResult> {
const {
dateFrom,
dateTo,
dryRun = false,
accountNumber = '1930',
currency = 'SEK',
cashAccountId,
includeUnassigned = true,
} = options
// Fetch unlinked GL lines via RPC
const glLines = await fetchUnlinkedGLLines(supabase, companyId, accountNumber, dateFrom, dateTo)
// Fetch unmatched transactions, scoped to the selected cash account.
let query = supabase
.from('transactions')
.select('*')
.eq('company_id', companyId)
.is('journal_entry_id', null)
.eq('is_ignored', false)
query = scopeTransactionsToAccount(query, cashAccountId, currency, includeUnassigned)
if (dateFrom) query = query.gte('date', dateFrom)
if (dateTo) query = query.lte('date', dateTo)
const { data: transactions } = await query
if (!transactions || transactions.length === 0 || glLines.length === 0) {
return { matches: [], applied: 0, errors: 0 }
}
// Run greedy matching, highest confidence first
const matches = greedyMatch(transactions as Transaction[], glLines, currency)
if (dryRun) {
return { matches, applied: 0, errors: 0 }
}
// Apply matches
let applied = 0
let errors = 0
for (const match of matches) {
try {
const { error } = await supabase
.from('transactions')
.update({
journal_entry_id: match.glLine.journal_entry_id,
reconciliation_method: match.method,
is_business: true,
})
.eq('id', match.transaction.id)
.eq('company_id', companyId)
if (error) {
errors++
} else {
applied++
try {
eventBus.emit({
type: 'transaction.reconciled',
payload: {
transaction: match.transaction,
journalEntryId: match.glLine.journal_entry_id,
method: match.method,
userId,
companyId,
},
})
} catch {
// Event emission is non-critical
}
}
} catch {
errors++
}
}
return { matches, applied, errors }
}
// ============================================================
// Reconciliation status
// ============================================================
/**
* Compare bank transaction totals vs GL bank account balance.
*
* `bankAccount` and `currency` must agree (e.g. 1932 + EUR). When the caller
* omits currency it defaults to SEK for back-compat with the single-account
* call sites that only ever reconciled 1930. Multi-currency callers must pass
* both — comparing EUR GL movements against SEK transaction totals would
* silently produce nonsense.
*/
export async function getReconciliationStatus(
supabase: SupabaseClient,
companyId: string,
dateFrom?: string,
dateTo?: string,
bankAccount = '1930',
currency: string = 'SEK',
cashAccountId?: string,
includeUnassigned: boolean = true,
): Promise<ReconciliationStatus> {
// Get all transactions in range, scoped to the selected cash account. Ignored
// rows are pulled too so the totals card still reflects what the bank
// actually moved, but they're excluded from the "unmatched" count below — the
// user has explicitly said they don't want them surfacing as something to
// reconcile. Scoping by cash account (not just currency) is what stops a
// second same-currency account from inflating bankTotal here.
let txQuery = supabase
.from('transactions')
.select('date, amount, journal_entry_id, reconciliation_method, is_ignored')
.eq('company_id', companyId)
txQuery = scopeTransactionsToAccount(txQuery, cashAccountId, currency, includeUnassigned)
if (dateFrom) txQuery = txQuery.gte('date', dateFrom)
if (dateTo) txQuery = txQuery.lte('date', dateTo)
const { data: transactions } = await txQuery
// Get GL bank-account lines. We fetch posted AND reversed entries and count
// them TOGETHER — the exact inclusion rule the trial balance and balance sheet
// use (see lib/reports/trial-balance.ts, which sums `['posted','reversed']`).
// A reversed original stays in the ledger and is cancelled by its storno, so
// both legs must be summed; counting only the storno would leave a dangling
// half-correction. Using the identical rule here is what guarantees
// gl_1930_balance can never disagree with the balansräkning for this account —
// the headline bug this widget had (a corrected bank receipt showed one figure
// here and a different one on the balance sheet). source_type is still pulled
// so we can split out the opening balance and surface correction activity.
let glQuery = supabase
.from('journal_entry_lines')
.select('debit_amount, credit_amount, journal_entries!inner(id, company_id, entry_date, status, source_type)')
.eq('account_number', bankAccount)
.eq('journal_entries.company_id', companyId)
.in('journal_entries.status', ['posted', 'reversed'])
if (dateFrom) glQuery = glQuery.gte('journal_entries.entry_date', dateFrom)
if (dateTo) glQuery = glQuery.lte('journal_entries.entry_date', dateTo)
const { data: glLines } = await glQuery
type GlEntry = {
id?: string | null
status?: string | null
source_type?: string | null
entry_date?: string | null
}
type GlLineRow = {
debit_amount: number | string | null
credit_amount: number | string | null
journal_entries: GlEntry | GlEntry[] | null
}
// Supabase typings sometimes widen embedded relations to arrays even when the
// join is one-to-one. Handle both shapes defensively.
function entryOf(line: GlLineRow): GlEntry | null {
const je = line.journal_entries
if (!je) return null
return Array.isArray(je) ? je[0] ?? null : je
}
function lineAmount(line: GlLineRow): number {
return (Number(line.debit_amount) || 0) - (Number(line.credit_amount) || 0)
}
// posted + reversed = the ledger balance, exactly as the trial balance counts
// it. The .in() filter on the query already excludes draft/cancelled.
const fetchedLines = (glLines || []) as GlLineRow[]
// Floor the window at the most recent opening-balance date on this account
// (issue #751). Everything dated before that IB is prior history the IB entry
// already summarises; if the window has no lower bound (the "full history"
// default) it spans the fiscal-year boundary and pulls the prior period's real
// movements — which net to exactly the IB — into the period movement, while the
// bank feed only covers the current period. The IB *summary* is excluded below,
// but the prior-period *detail* would otherwise remain, manufacturing a phantom
// difference equal to the IB. effectiveFrom is the later of the caller's
// dateFrom and that IB date; it only ever RAISES the lower bound, so the
// dateFrom SQL pre-filter on both queries above stays valid. In normal use the
// UI passes dateFrom = period_start = the IB date, so this is a no-op there.
const ibDates = fetchedLines
.filter((l) => entryOf(l)?.source_type === 'opening_balance')
.map((l) => entryOf(l)?.entry_date)
.filter((d): d is string => typeof d === 'string' && d.length > 0)
// Take the LATEST IB date. The invariant is one opening_balance entry per
// fiscal period (set_opening_balances / SIE import / year-end rollover all
// create exactly one, dated period_start), so within a single-period window
// there is only one. Across a multi-year window the most recent IB is the
// correct floor — an earlier year's IB and the movements it summarises are
// prior history we deliberately drop. Same-date duplicates are harmless: they
// land in both countedLines and glOpeningBalance and cancel.
const ibFloor = ibDates.length ? ibDates.reduce((a, b) => (a > b ? a : b)) : null
const effectiveFrom =
dateFrom && ibFloor ? (dateFrom > ibFloor ? dateFrom : ibFloor) : dateFrom || ibFloor || null
// ISO yyyy-mm-dd compares lexically; undated rows (e.g. test fixtures) pass.
const onOrAfterFloor = (d: string | null | undefined): boolean =>
!effectiveFrom || typeof d !== 'string' ? true : d >= effectiveFrom
// Clamp BOTH sides to the floor identically so they stay comparable. Lines and
// transactions before the IB belong to a prior period's reconciliation.
const countedLines = fetchedLines.filter((l) => onOrAfterFloor(entryOf(l)?.entry_date))
const countedTx = (transactions || []).filter((tx) =>
onOrAfterFloor((tx as { date?: string | null }).date),
)
// Bank side: every feed transaction in the (floored) window, full stop. We
// deliberately do NOT special-case rows linked to a reversed entry any more.
// Because the GL side now counts the reversed original, its storno AND the
// correction together (just like the balance sheet), a corrected bank line nets
// to its true amount on both sides and reconciles on its own — whether
// correctEntry re-pointed the transaction to the live corrected entry or a
// legacy row still points at the reversed original, the result is identical.
const bankTotal = countedTx.reduce((sum, tx) => sum + (Number(tx.amount) || 0), 0)
// gl_1930_balance: the real ledger balance on this account incl. IB —
// byte-for-byte the figure the balansräkning / saldobalans report.
const glBalance = countedLines.reduce((sum, line) => sum + lineAmount(line), 0)
// IB is last year's closing position, not a movement with a bank-feed
// counterpart — surfaced separately and excluded from the period movement.
const glOpeningBalance = countedLines
.filter((l) => entryOf(l)?.source_type === 'opening_balance')
.reduce((sum, line) => sum + lineAmount(line), 0)
// Net storno/correction activity on the account this period. Surfaced for
// transparency ONLY — it is part of the ledger balance and is INCLUDED in the
// movement, never subtracted. (Subtracting it while still counting the
// re-pointed bank transaction is exactly what produced the old phantom diff.)
const glCorrectionAdjustment = countedLines
.filter((l) => {
const st = entryOf(l)?.source_type
return st === 'storno' || st === 'correction'
})
.reduce((sum, line) => sum + lineAmount(line), 0)
// Period movement = the ledger balance minus the opening balance. Everything
// else (real bookings, stornos and corrections alike) has — or should have —
// a bank-feed counterpart, so it stays in.
const glPeriodMovement = glBalance - glOpeningBalance
const matchedCount = countedTx.filter((tx) => tx.journal_entry_id !== null).length
const unmatchedTransactionCount = countedTx.filter(
(tx) => tx.journal_entry_id === null && tx.is_ignored !== true
).length
// Unlinked GL lines count (RPC excludes opening_balance, storno and correction
// since 20260601120000_unlinked_gl_lines_exclude_storno_correction.sql)
const unlinkedLines = await fetchUnlinkedGLLines(supabase, companyId, bankAccount, dateFrom, dateTo)
const difference = Math.round((bankTotal - glPeriodMovement) * 100) / 100
return {
bank_transaction_total: Math.round(bankTotal * 100) / 100,
gl_1930_balance: Math.round(glBalance * 100) / 100,
gl_1930_period_movement: Math.round(glPeriodMovement * 100) / 100,
gl_1930_opening_balance: Math.round(glOpeningBalance * 100) / 100,
gl_1930_correction_adjustment: Math.round(glCorrectionAdjustment * 100) / 100,
difference,
is_reconciled: Math.abs(difference) < 0.01,
matched_count: matchedCount,
unmatched_transaction_count: unmatchedTransactionCount,
unmatched_gl_line_count: unlinkedLines.length,
}
}
// ============================================================
// Manual link/unlink
// ============================================================
/**
* Manually link a transaction to an existing journal entry.
* Validates that the journal entry has a bank account line and amounts are directionally compatible.
*/
export async function manualLink(
supabase: SupabaseClient,
companyId: string,
transactionId: string,
journalEntryId: string,
userId: string,
accountNumber: string = '1930',
): Promise<{ success: boolean; error?: string }> {
// Fetch transaction
const { data: tx, error: txError } = await supabase
.from('transactions')
.select('*')
.eq('id', transactionId)
.eq('company_id', companyId)
.single()
if (txError || !tx) {
return { success: false, error: 'Transaktionen kunde inte hittas.' }
}
if (tx.journal_entry_id) {
return { success: false, error: 'Transaktionen är redan kopplad till en verifikation.' }
}
// Fetch journal entry + verify it has a 1930 line
const { data: entry, error: entryError } = await supabase
.from('journal_entries')
.select('id, company_id, status')
.eq('id', journalEntryId)
.eq('company_id', companyId)
.single()
if (entryError || !entry) {
return { success: false, error: 'Verifikationen kunde inte hittas.' }
}
if (entry.status !== 'posted') {
return { success: false, error: 'Verifikationen är inte bokförd ännu.' }
}
// Defense-in-depth: the transaction must belong to the account being
// reconciled. A transaction bound to 1930 must not be linked against a 1931
// voucher even if the caller passes accountNumber=1931. Legacy rows with no
// cash_account_id fall through (the UI list already gates them by currency).
if (tx.cash_account_id) {
const { data: txCa } = await supabase
.from('cash_accounts')
.select('ledger_account')
.eq('id', tx.cash_account_id)
.eq('company_id', companyId)
.maybeSingle()
if (txCa?.ledger_account && txCa.ledger_account !== accountNumber) {
return {
success: false,
error: `Transaktionen hör till ${txCa.ledger_account}, inte ${accountNumber}`,
}
}
}
// Check for a bank account line on the SELECTED settlement account. The old
// "any 19xx line" check let a 1930 transaction link to a voucher that only
// touched 1931 — a cross-account link that silently hides a real imbalance.
const { data: lines } = await supabase
.from('journal_entry_lines')
.select('debit_amount, credit_amount, account_number')
.eq('journal_entry_id', journalEntryId)
.eq('account_number', accountNumber)
if (!lines || lines.length === 0) {
return { success: false, error: `Verifikationen saknar rad på ${accountNumber}` }
}
// N:1 is intentionally allowed: several bank transactions may settle ONE
// verifikat (a salary run paid out in multiple transfers, a supplier invoice
// paid in instalments). The voucher's bank line is counted once in the period
// movement while each transaction sums on the bank side, so correctly-summing
// links net to zero and any mis-link surfaces as a non-zero difference on the
// status card — there's no need to forbid a second link here. (A given
// transaction still can't be double-linked: the tx.journal_entry_id guard
// above already blocks that.) The candidate list only surfaces an
// already-matched voucher when the user opts in via "Visa även matchade
// verifikationer", so this can't happen by accident.
// Apply link
const { error: updateError } = await supabase
.from('transactions')
.update({
journal_entry_id: journalEntryId,
reconciliation_method: 'manual' as ReconciliationMethod,
is_business: true,
})
.eq('id', transactionId)
.eq('company_id', companyId)
if (updateError) {
return { success: false, error: 'Kunde inte koppla transaktionen. Försök igen.' }
}
try {
eventBus.emit({
type: 'transaction.reconciled',
payload: {
transaction: tx as Transaction,
journalEntryId,
method: 'manual' as ReconciliationMethod,
userId,
companyId,
},
})
} catch {
// Non-critical
}
return { success: true }
}
/**
* Remove a reconciliation link.
* Only allowed when reconciliation_method IS NOT NULL (prevents unlinking categorization-created entries).
*/
export async function unlinkReconciliation(
supabase: SupabaseClient,
companyId: string,
transactionId: string
): Promise<{ success: boolean; error?: string }> {
// Fetch transaction
const { data: tx, error: txError } = await supabase
.from('transactions')
.select('id, journal_entry_id, reconciliation_method')
.eq('id', transactionId)
.eq('company_id', companyId)
.single()
if (txError || !tx) {
return { success: false, error: 'Transaction not found' }
}
if (!tx.journal_entry_id) {
return { success: false, error: 'Transaction is not linked to any journal entry' }
}
if (!tx.reconciliation_method) {
return { success: false, error: 'Cannot unlink a categorization-created entry. Use storno to reverse it instead.' }
}
const { error: updateError } = await supabase
.from('transactions')
.update({
journal_entry_id: null,
reconciliation_method: null,
is_business: null,
})
.eq('id', transactionId)
.eq('company_id', companyId)
if (updateError) {
return { success: false, error: 'Failed to unlink transaction' }
}
logMatchEvent(supabase, companyId, transactionId, 'unmatched', {
previousState: {
journal_entry_id: tx.journal_entry_id,
reconciliation_method: tx.reconciliation_method,
},
})
return { success: true }
}
/** Float tolerance for matching a bank line to a verifikat (0.5 öre). */
const VOUCHER_LINK_AMOUNT_TOLERANCE = 0.005
/** A bank line settling a verifikat sits within a few days of the voucher's
* entry_date. Kept tight so the single-candidate rule below stays meaningful. */
const VOUCHER_LINK_DATE_WINDOW_DAYS = 7
/** Shift an ISO 'YYYY-MM-DD' date by ±days, returning the same string shape. */
function shiftIsoDate(date: string, days: number): string {
const d = new Date(date)
if (Number.isNaN(d.getTime())) return date
d.setUTCDate(d.getUTCDate() + days)
return d.toISOString().slice(0, 10)
}
interface CashAccountInfo {
id: string | null
currency: string
isPrimary: boolean
}
/**
* Reconcile the single unbooked bank transaction that corresponds to a verifikat
* the user just linked to an invoice from the invoice page — the symmetric move
* to the transactions-side match, closing the gap where linkInvoiceToVoucher /
* linkSupplierInvoiceToVoucher advanced the invoice but left the bank line
* sitting in the Transactions inbox (still journal_entry_id = null).
*
* Deliberately conservative — it only acts when the link is unambiguous:
* • the voucher has NO bank transaction reconciled to it yet (never adds a
* second one automatically — that N:1 case must be an explicit choice in
* Bankavstämning),
* • the voucher touches exactly ONE cash-account line (a transfer hitting two
* bank accounts, or an AR/AP reclass with none, is left alone), and
* • exactly ONE unbooked, non-ignored transaction on that account matches the
* bank movement (same amount within tolerance, same direction) inside a
* tight date window.
* Anything else is left untouched: the user can still match it by hand from the
* Transactions list. The link itself uses manualLink — no new journal entry, no
* JE mutation — so this is a reconciliation link, never a second booking.
*
* Best-effort by contract: returns the linked transaction id or null, and the
* caller treats a throw as "nothing linked" because the invoice link has already
* committed.
*/
export async function autoReconcileTransactionForLinkedVoucher(
supabase: SupabaseClient,
companyId: string,
userId: string,
journalEntryId: string,
options: {
invoiceId?: string
supplierInvoiceId?: string
dateWindowDays?: number
} = {},
): Promise<{ linkedTransactionId: string } | null> {
const windowDays = options.dateWindowDays ?? VOUCHER_LINK_DATE_WINDOW_DAYS
// 1. If a bank transaction already points at this voucher, the bank side is
// settled — don't attach another one behind the user's back.
const { data: alreadyLinked } = await supabase
.from('transactions')
.select('id')
.eq('company_id', companyId)
.eq('journal_entry_id', journalEntryId)
.limit(1)
if (alreadyLinked && alreadyLinked.length > 0) return null
// 2. Load the voucher (must be posted) and its lines.
const { data: entry } = await supabase
.from('journal_entries')
.select('id, entry_date, status')
.eq('id', journalEntryId)
.eq('company_id', companyId)
.maybeSingle()
if (!entry || entry.status !== 'posted' || !entry.entry_date) return null
const { data: lines } = await supabase
.from('journal_entry_lines')
.select('account_number, debit_amount, credit_amount')
.eq('journal_entry_id', journalEntryId)
if (!lines || lines.length === 0) return null
// 3. Which BAS codes carry a bank feed? cash_accounts is the source of truth.
const { data: cashAccounts } = await supabase
.from('cash_accounts')
.select('id, ledger_account, currency, is_primary')
.eq('company_id', companyId)
const cashByAccount = new Map<string, CashAccountInfo>()
for (const raw of (cashAccounts ?? []) as Array<{
id: string
ledger_account: string | null
currency: string | null
is_primary: boolean | null
}>) {
if (raw.ledger_account) {
cashByAccount.set(raw.ledger_account, {
id: raw.id,
currency: raw.currency ?? 'SEK',
isPrimary: raw.is_primary ?? false,
})
}
}
// Companies created before cash_accounts seeding reconcile against 1930/SEK.
if (cashByAccount.size === 0) {
cashByAccount.set('1930', { id: null, currency: 'SEK', isPrimary: true })
}
const cashLines = (lines as Array<{
account_number: string
debit_amount: number | null
credit_amount: number | null
}>).filter((l) => cashByAccount.has(l.account_number))
// Exactly one bank movement → exactly one bank transaction to attach.
if (cashLines.length !== 1) return null
const cashLine = cashLines[0]
const accountNumber = cashLine.account_number
const cashAccount = cashByAccount.get(accountNumber)!
const debit = Number(cashLine.debit_amount ?? 0)
const credit = Number(cashLine.credit_amount ?? 0)
const movement = debit > 0 ? debit : -credit // + money in, − money out
if (Math.abs(movement) <= VOUCHER_LINK_AMOUNT_TOLERANCE) return null
// 4. Unbooked, non-ignored candidate transactions on that account, scoped the
// same way Bankavstämning scopes (handles legacy NULL cash_account_id rows).
const fromDate = shiftIsoDate(entry.entry_date, -windowDays)
const toDate = shiftIsoDate(entry.entry_date, windowDays)
let candQuery = supabase
.from('transactions')
.select('id, amount')
.eq('company_id', companyId)
.is('journal_entry_id', null)
.eq('is_ignored', false)
.gte('date', fromDate)
.lte('date', toDate)
candQuery = scopeTransactionsToAccount(
candQuery,
cashAccount.id ?? undefined,
cashAccount.currency,
cashAccount.isPrimary,
)
const { data: candidates } = await candQuery
const matches = ((candidates ?? []) as Array<{ id: string; amount: number }>).filter((tx) => {
const amt = Number(tx.amount)
if (Math.abs(Math.abs(amt) - Math.abs(movement)) > VOUCHER_LINK_AMOUNT_TOLERANCE) return false
return Math.sign(amt) === Math.sign(movement)
})
// Two same-amount unbooked lines near the same date → don't guess.
if (matches.length !== 1) return null
const transactionId = matches[0].id
// 5. Reconcile via the exact path Bankavstämning uses (manualLink re-validates
// posted status, the cash-account line, and the not-already-linked guard,
// then sets journal_entry_id + reconciliation_method + is_business). No new
// journal entry is created.
const linkResult = await manualLink(
supabase,
companyId,
transactionId,
journalEntryId,
userId,
accountNumber,
)
if (!linkResult.success) return null
// Tag the transaction with the (supplier) invoice for traceability + parity
// with the transactions-side match. is_business is already set by manualLink,
// so the row has already dropped out of the inbox regardless of this update.
const tag: Record<string, unknown> = { potential_invoice_id: null }
if (options.invoiceId) tag.invoice_id = options.invoiceId
if (options.supplierInvoiceId) {
tag.supplier_invoice_id = options.supplierInvoiceId
tag.potential_supplier_invoice_id = null
}
if (Object.keys(tag).length > 1) {
await supabase
.from('transactions')
.update(tag)
.eq('id', transactionId)
.eq('company_id', companyId)
}
logMatchEvent(supabase, userId, transactionId, 'linked_to_existing_voucher', {
invoiceId: options.invoiceId,
supplierInvoiceId: options.supplierInvoiceId,
matchMethod: 'invoice_voucher_link',
newState: {
journal_entry_id: journalEntryId,
reconciliation_method: 'manual',
},
})
return { linkedTransactionId: transactionId }
}
// ============================================================
// Helpers
// ============================================================
/**
* Fetch unlinked GL lines for a settlement account. `accountNumber` defaults to
* '1930' for back-compat; multi-account customers (Plusgiro 1920, kreditkort
* 1940, EUR-konto 1932, etc.) pass the BAS code of the account they're
* reconciling. The CashAccountSelector populates this from cash_accounts.
*/
export async function fetchUnlinkedGLLines(
supabase: SupabaseClient,
companyId: string,
accountNumber: string = '1930',
dateFrom?: string,
dateTo?: string,
): Promise<UnlinkedGLLine[]> {
const { data, error } = await supabase.rpc('get_unlinked_gl_lines', {
p_company_id: companyId,
p_account_number: accountNumber,
p_date_from: dateFrom || null,
p_date_to: dateTo || null,
})
if (error || !data) return []
return data as UnlinkedGLLine[]
}
/** A match candidate that carries how many transactions already point at it. */
export interface GLLineForMatching extends UnlinkedGLLine {
linked_transaction_count: number
}
/**
* Fetch GL lines on a settlement account as match candidates. With
* `includeMatched=false` this is parity with fetchUnlinkedGLLines (unmatched
* only); with `includeMatched=true` it also returns already-matched vouchers,
* each carrying `linked_transaction_count`, so a second/third bank transaction
* can be attached to the same verifikat (N:1 — a salary run paid in several
* transfers, a supplier invoice paid in instalments). Server-only: like the rest
* of this module it must never reach the client bundle.
*/
export async function fetchGLLinesForMatching(
supabase: SupabaseClient,
companyId: string,
accountNumber: string = '1930',
dateFrom?: string,
dateTo?: string,
includeMatched: boolean = false,
): Promise<GLLineForMatching[]> {
const { data, error } = await supabase.rpc('get_account_gl_lines_for_matching', {
p_company_id: companyId,
p_account_number: accountNumber,
p_date_from: dateFrom || null,
p_date_to: dateTo || null,
p_include_matched: includeMatched,
})
if (error || !data) return []
// count(*) can arrive as a bigint string over the wire — coerce defensively.
return (data as GLLineForMatching[]).map((line) => ({
...line,
linked_transaction_count: Number(line.linked_transaction_count) || 0,
}))
}
/** Get the net amount from a GL line (positive for debit, negative for credit) */
function getDirectionalAmount(line: UnlinkedGLLine): number {
if (line.debit_amount > 0) return line.debit_amount
if (line.credit_amount > 0) return -line.credit_amount
return 0
}
/**
* Check direction compatibility:
* - Income (tx.amount > 0) matches debit on 1930 (money coming in to bank)
* - Expense (tx.amount < 0) matches credit on 1930 (money going out of bank)
*/
function isDirectionCompatible(txAmount: number, line: UnlinkedGLLine): boolean {
if (txAmount > 0 && line.debit_amount > 0) return true
if (txAmount < 0 && line.credit_amount > 0) return true
return false
}
/**
* OCR/reference-number match. Requires a non-trivial reference token (≥4 chars)
* on the transaction that appears in the GL line/entry description. Description
* substring matching is intentionally NOT done here — that collided on recurring
* monthly charges across years (same description, same amount, different year).
*/
function hasOcrReferenceMatch(txReference: string, line: UnlinkedGLLine): boolean {
if (!txReference || txReference.length < 4) return false
const lineDesc = (line.line_description || '').toLowerCase()
const entryDesc = (line.entry_description || '').toLowerCase()
return lineDesc.includes(txReference) || entryDesc.includes(txReference)
}
/** Check if two dates are within ±dayRange of each other */
function isDateWithinRange(date1: string, date2: string, dayRange: number): boolean {
const d1 = new Date(date1)
const d2 = new Date(date2)
const diffMs = Math.abs(d1.getTime() - d2.getTime())
const diffDays = diffMs / (1000 * 60 * 60 * 24)
return diffDays <= dayRange
}
/**
* Greedy matching: run 4-pass matching, each pass at a specific confidence level.
* Track used GL lines and transactions to prevent double-matching.
*/
function greedyMatch(
transactions: Transaction[],
glLines: UnlinkedGLLine[],
expectedCurrency: string = 'SEK',
): ReconciliationMatch[] {
const usedTransactions = new Set<string>()
const usedGLLines = new Set<string>()
const allMatches: ReconciliationMatch[] = []
// Collect all candidate matches with confidence
const candidates: ReconciliationMatch[] = []
for (const tx of transactions) {
if (tx.currency !== expectedCurrency) continue
for (const line of glLines) {
const match = tryReconcileTransaction(tx, [line], expectedCurrency)
if (match) {
candidates.push(match)
}
}
}
// Sort by confidence descending, then by date proximity
candidates.sort((a, b) => {
if (b.confidence !== a.confidence) return b.confidence - a.confidence
// Prefer closer dates
const dateDistA = Math.abs(
new Date(a.transaction.date).getTime() - new Date(a.glLine.entry_date).getTime()
)
const dateDistB = Math.abs(
new Date(b.transaction.date).getTime() - new Date(b.glLine.entry_date).getTime()
)
return dateDistA - dateDistB
})
// Greedily assign matches
for (const candidate of candidates) {
const txId = candidate.transaction.id
const lineId = candidate.glLine.line_id
if (usedTransactions.has(txId) || usedGLLines.has(lineId)) continue
usedTransactions.add(txId)
usedGLLines.add(lineId)
allMatches.push(candidate)
}
return allMatches
}