* 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>
236 lines
7.8 KiB
TypeScript
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),
|
|
}
|
|
}
|