Files
accounted/lib/reconciliation/unattended-sweep.ts
T
MattssonandClaude Fable 5 08440fed94 feat(reconciliation): match migrated bank history against imported SIE verifikat (#1598)
* feat(reconciliation): match migrated bank history against imported SIE verifikat

A first-class Fortnox/SIE migrator path: after SIE import plus bank connect
or bank CSV upload, historical bank rows are auto-matched (>= 0.9) or
suggestion-matched (0.75-0.89, persisted for review) against the imported
verifikat, with a guided review surface, instead of landing as anonymous
"Att bokfora" rows.

Phase 0: per-cash-account unattended sweep (fixes #1298 cross-account
pooling); widen payment_match_log action CHECK with
linked_to_existing_voucher (silently unlogged since March).
Phase 1: potential_journal_entry_id/method/confidence on transactions with
CHECK + invalidation triggers; persistSuggestions in runReconciliation;
sweep after bank CSV import with SIE overlap (suppressing
auto-categorization); sweep summaries stamped on bank_connections and
bank_file_imports; POST /api/reconciliation/bank/confirm-suggestions with
per-pair server-side revalidation (voucher consumption + bank-leg amount
and direction).
Phase 2: "Granska forslag" review tab on Transactions with chunked bulk
confirm, per-row fallbacks, "Kor matchning igen" (all_accounts sweep mode,
mutually exclusive with dry_run), attn line, pre-migration row marker.
Phase 3: ImportResultStep dual CTA (bank connect + CSV), migrator variant
of the account-picker #917 nudge, sweep outcome on the onboarding
checklist bank step.

Non-selection apply runs on /api/reconciliation/bank/run now floor at 0.9
and persist the review band instead of auto-committing fuzzy matches.
Migrations already applied to staging under the same versions.

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

* fix(reconciliation): resolve PR review findings in one pass

Swedish accounting review (both previously-deferred holes closed):
- runReconciliation's >= 0.9 auto-apply now writes 'matched' to
  payment_match_log (behandlingshistorik, BFNAR 2013:2 kap 8); the bus
  event alone lands in the 30-day event_log and is not an audit record.
- The three match-route storno-conflict branches detach reconciliation
  links via unlinkReconciliation instead of storno-reversing the linked
  verifikat: a reconciliation link points at an independent verifikat
  that may evidence other affarshandelser, and a wholesale reversal is
  an over-broad rattelse (BFL 5 kap 5 §).
- Historical gap quantified on prod (read-only, recorded in DECISIONS):
  762 unlogged manual links across 52 companies since 2026-03-23.

CodeRabbit:
- confirm-suggestions route: maxDuration 300 for full 500-item batches.
- AccountPickerDialog: migrator-nudge buttons set lookbackTouched so the
  async gap-fill probe cannot override an explicit choice.
- enable-banking post-backfill sweep: persistSuggestions so the review
  band is not dropped.
- bank-file execute: sie_sweep stamp errors are logged, not swallowed.
- ImportResultStep: sandbox keeps the CSV CTA (file import works there).
- payment_match_log CHECK swap: NOT VALID + VALIDATE, no table scan
  under ACCESS EXCLUSIVE.
- logMatchEvent calls awaited (serverless can freeze unawaited work).
- DECISIONS.md stale version reference annotated.

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

* fix(reconciliation): defer reconciliation-link detach until the match commits

Round-2 review findings:
- CodeRabbit: the eager unlinkReconciliation call could orphan a
  transaction if the match flow failed after it. All three match routes
  now persist NOTHING up front: the final transaction update overwrites
  journal_entry_id and clears reconciliation_method in the same write,
  so any failure in between leaves the existing link intact. The release
  is logged as 'unmatched' after the commit.
- Swedish review: the auto_suggested logMatchEvent in runReconciliation
  is now awaited like every other audit write.
- DECISIONS entry split into compliance/CodeRabbit lines and updated to
  describe the deferred detach.

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

* fix(reconciliation): literal reconciliation_method payloads for the phantom-column scanner

The conditional spreads introduced with the deferred detach pushed the
scanner's unresolvable-expression count past its ceiling (380 > 378).
reconciliation_method: null is correct unconditionally on a confirmed
invoice/supplier match (null is already the value on every row that was
not reconciliation-linked), so the payloads become plain literals the
guard can verify. No behavior change.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 23:12:27 +02:00

236 lines
7.8 KiB
TypeScript

import type { SupabaseClient } from '@supabase/supabase-js'
import {
runReconciliation,
DEFAULT_UNATTENDED_CONFIDENCE_THRESHOLD,
type ReconciliationOptions,
} from './bank-reconciliation'
import { createLogger } from '@/lib/logger'
const log = createLogger('reconciliation.unattended-sweep')
/** Per-account outcome of one unattended sweep. */
export interface SweepAccountResult {
/** null on the legacy fallback run for companies with no cash_accounts rows. */
cashAccountId: string | null
accountNumber: string
currency: string
/** Matches auto-linked at or above the unattended confidence floor. */
applied: number
/** Apply failures (optimistic-lock conflicts, DB errors) plus a whole-account
* run failure, which counts as 1 without aborting the other accounts. */
errors: number
/** Matches proposed below the floor: candidate suggestions for human review. */
skippedBelowThreshold: number
/** Below-floor matches persisted onto potential_journal_entry_id. */
suggested: number
/** Unmatched transactions the run considered on this account. */
candidates: number
/** Total matches the matcher proposed for this account. */
proposed: number
}
export interface UnattendedSweepResult {
accounts: SweepAccountResult[]
applied: number
errors: number
skippedBelowThreshold: number
suggested: number
/** Candidate transactions the sweep left neither linked nor suggested. */
unmatched: number
}
export interface UnattendedSweepOptions {
dateFrom?: string
dateTo?: string
/**
* Confidence floor for auto-apply. Defaults to
* DEFAULT_UNATTENDED_CONFIDENCE_THRESHOLD: these sweeps run with nobody
* reviewing a dry-run first, so fuzzy / date-range matches must never be
* committed automatically.
*/
confidenceThreshold?: number
/**
* Persist the below-floor band as reviewable suggestions (default true:
* every unattended caller feeds the "Granska migrerad historik" surface).
*/
persistSuggestions?: boolean
}
/**
* The JSONB stamped on bank_connections.last_sie_sweep /
* bank_file_imports.sie_sweep so the UI can render the sweep outcome without
* recomputing. snake_case: it lives in the DB and crosses the API boundary.
*/
export interface SieSweepSummary {
auto_linked: number
suggested: number
unmatched: number
/**
* Apply/run failures across the sweep. NOT decoration: a whole-account run
* that threw contributes 0 candidates, so its transactions are absent from
* `unmatched` too. errors > 0 means the other three numbers describe an
* INCOMPLETE sweep, and any UI reading this summary must not present it as
* "all done".
*/
errors: number
date_from: string | null
date_to: string | null
ran_at: string
}
export function toSweepSummary(
result: UnattendedSweepResult,
options: { dateFrom?: string; dateTo?: string } = {},
): SieSweepSummary {
return {
auto_linked: result.applied,
suggested: result.suggested,
unmatched: result.unmatched,
errors: result.errors,
date_from: options.dateFrom ?? null,
date_to: options.dateTo ?? null,
ran_at: new Date().toISOString(),
}
}
/**
* Run the unattended post-sync reconciliation sweep once per cash account
* instead of once per company (issue #1298).
*
* The pooled form (`runReconciliation` with no cashAccountId) filtered the
* transaction side by currency alone while the GL side stayed on '1930', so a
* company with two same-currency accounts (checking 1930 + savings 1931) could
* auto-link a savings transaction to an unlinked 1930 voucher and persist a
* wrong journal_entry_id. Only a log warning guarded it
* (warnIfUnscopedAcrossCashAccounts). Here every enabled cash account gets its
* own scoped run: its BAS code on the GL side, its cash_account_id on the
* transaction side, and NULL-cash_account_id rows claimed only by the primary
* account (same rule as Bankavstamning).
*
* Companies with no cash_accounts rows at all keep the legacy single
* 1930/SEK run: with no rows there is no per-account scope to apply, and
* scopeTransactionsToAccount's currency-only path is the supported mode there.
*
* One account's run failing (thrown) is counted as one error on that account
* and the sweep continues: an unattended sweep must not let one broken account
* block matching on the others. The initial cash_accounts lookup failing throws
* instead: silently degrading to the pooled run would re-create exactly the
* cross-linking this helper exists to remove (same fail-closed contract as
* resolveCashAccountScope).
*/
export async function runUnattendedReconciliationSweep(
supabase: SupabaseClient,
companyId: string,
userId: string,
options: UnattendedSweepOptions = {},
): Promise<UnattendedSweepResult> {
const { dateFrom, dateTo, persistSuggestions = true } = options
const confidenceThreshold =
options.confidenceThreshold ?? DEFAULT_UNATTENDED_CONFIDENCE_THRESHOLD
const { data: cashAccounts, error } = await supabase
.from('cash_accounts')
.select('id, ledger_account, currency, is_primary')
.eq('company_id', companyId)
.eq('enabled', true)
.order('ledger_account')
if (error) {
throw new Error('Kunde inte hämta kassakonton för avstämningssvepet')
}
type Scope = {
cashAccountId: string | null
accountNumber: string
currency: string
includeUnassigned: boolean
}
const rows = (cashAccounts ?? []) as Array<{
id: string
ledger_account: string
currency: string | null
is_primary: boolean | null
}>
const scopes: Scope[] =
rows.length > 0
? rows.map((row) => ({
cashAccountId: row.id,
accountNumber: row.ledger_account,
currency: row.currency ?? 'SEK',
includeUnassigned: Boolean(row.is_primary),
}))
: [
{
cashAccountId: null,
accountNumber: '1930',
currency: 'SEK',
includeUnassigned: true,
},
]
const accounts: SweepAccountResult[] = []
for (const scope of scopes) {
const runOptions: ReconciliationOptions = {
dateFrom,
dateTo,
accountNumber: scope.accountNumber,
currency: scope.currency,
cashAccountId: scope.cashAccountId ?? undefined,
includeUnassigned: scope.includeUnassigned,
confidenceThreshold,
persistSuggestions,
}
try {
const result = await runReconciliation(supabase, companyId, userId, runOptions)
accounts.push({
cashAccountId: scope.cashAccountId,
accountNumber: scope.accountNumber,
currency: scope.currency,
applied: result.applied,
errors: result.errors,
skippedBelowThreshold: result.skippedBelowThreshold,
suggested: result.suggested,
candidates: result.candidates,
proposed: result.matches.length,
})
} catch (err) {
log.warn('per-account sweep run failed; continuing with remaining accounts', {
companyId,
entityType: 'cash_account',
details: {
accountNumber: scope.accountNumber,
cashAccountId: scope.cashAccountId,
message: err instanceof Error ? err.message : String(err),
},
})
accounts.push({
cashAccountId: scope.cashAccountId,
accountNumber: scope.accountNumber,
currency: scope.currency,
applied: 0,
errors: 1,
skippedBelowThreshold: 0,
suggested: 0,
candidates: 0,
proposed: 0,
})
}
}
const applied = accounts.reduce((sum, a) => sum + a.applied, 0)
const suggested = accounts.reduce((sum, a) => sum + a.suggested, 0)
const candidates = accounts.reduce((sum, a) => sum + a.candidates, 0)
return {
accounts,
applied,
errors: accounts.reduce((sum, a) => sum + a.errors, 0),
skippedBelowThreshold: accounts.reduce((sum, a) => sum + a.skippedBelowThreshold, 0),
suggested,
unmatched: Math.max(0, candidates - applied - suggested),
}
}