Files
accounted/lib/reconciliation/suggestions.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

271 lines
9.7 KiB
TypeScript

import type { SupabaseClient } from '@supabase/supabase-js'
import { manualLink, ledgerLineAmountIn } from './bank-reconciliation'
import { logMatchEvent } from '@/lib/invoices/match-log'
/**
* Confirm / reject persisted journal-entry match suggestions
* (transactions.potential_journal_entry_id, written by the reconciliation
* sweep's 0.75-0.89 band).
*
* Confirming is a reconciliation LINK, never a booking. Revalidated per pair
* at click time, in this order: row still exists and is unlinked, suggestion
* still present, suggested verifikat not already settled by another
* transaction, the voucher's net movement on the settlement account still
* agrees with the transaction's amount and direction (a suggestion computed
* before an inline rattelse re-priced the bank leg must die here, not link),
* and finally manualLink's own checks (entry posted, line on the settlement
* account, optimistic lock on the row). Stale pairs are skipped and reported,
* never failing the batch.
*
* The consumption check is read-then-act per request; two CONCURRENT requests
* confirming different rows against the same verifikat can both pass it. The
* sibling-clear trigger closes the window after the first commit, and a
* double-settled voucher still surfaces as a non-zero difference on the
* Bankavstamning status card, but within a single request the sequential
* re-fetch is the real guarantee.
*/
export type SuggestionSkipReason =
| 'not_found'
| 'no_suggestion'
| 'already_linked'
| 'voucher_consumed'
| 'amount_mismatch'
| 'link_failed'
export interface SuggestionActionResult {
confirmed: string[]
rejected: string[]
skipped: Array<{ transactionId: string; reason: SuggestionSkipReason; message?: string }>
}
interface SuggestionRow {
id: string
amount: number | string | null
currency: string | null
journal_entry_id: string | null
potential_journal_entry_id: string | null
potential_match_method: string | null
potential_match_confidence: number | string | null
cash_account_id: string | null
}
async function fetchSuggestionRow(
supabase: SupabaseClient,
companyId: string,
transactionId: string,
): Promise<SuggestionRow | null> {
const { data } = await supabase
.from('transactions')
.select(
'id, amount, currency, journal_entry_id, potential_journal_entry_id, potential_match_method, potential_match_confidence, cash_account_id',
)
.eq('id', transactionId)
.eq('company_id', companyId)
.maybeSingle()
return (data as SuggestionRow | null) ?? null
}
/**
* Resolve the settlement account manualLink must validate the voucher line
* against. Rows with a cash_account_id use that account's ledger_account. Rows
* WITHOUT one were swept under the PRIMARY cash account's scope
* (includeUnassigned), so confirmation must resolve the same way: hard-coding
* '1930' made every unassigned-row suggestion unconfirmable in a company whose
* primary account is e.g. 1920 Plusgiro. '1930' remains only the final
* fallback for companies with no cash_accounts rows at all.
*/
async function resolveSettlementAccount(
supabase: SupabaseClient,
companyId: string,
cashAccountId: string | null,
): Promise<string> {
const query = supabase.from('cash_accounts').select('ledger_account').eq('company_id', companyId)
const { data } = cashAccountId
? await query.eq('id', cashAccountId).maybeSingle()
: await query.eq('is_primary', true).maybeSingle()
return (data?.ledger_account as string | undefined) ?? '1930'
}
/** Fuzzy band tolerance: the widest amount slack any persisted suggestion was
* created under (auto_fuzzy, +-0.01), plus float headroom. */
const CONFIRM_AMOUNT_TOLERANCE = 0.011
/**
* The suggested voucher's net movement on the settlement account must still
* agree with the transaction, in the transaction's own currency. Returns null
* when it does; a skip reason message when it does not or cannot be verified
* (no comparable amount = no honest link, per the determinism rule).
*/
async function verifySuggestedAmount(
supabase: SupabaseClient,
tx: SuggestionRow,
accountNumber: string,
): Promise<string | null> {
const { data: lines } = await supabase
.from('journal_entry_lines')
.select('debit_amount, credit_amount, currency, amount_in_currency')
.eq('journal_entry_id', tx.potential_journal_entry_id)
.eq('account_number', accountNumber)
if (!lines || lines.length === 0) {
return `Verifikationen saknar rad på ${accountNumber}`
}
const txCurrency = tx.currency ?? 'SEK'
let movement = 0
for (const line of lines) {
const amount = ledgerLineAmountIn(line, txCurrency)
if (amount === null) {
return 'Verifikationens belopp kan inte jämföras i transaktionens valuta'
}
movement += amount
}
const txAmount = Number(tx.amount)
if (!Number.isFinite(txAmount)) return 'Transaktionens belopp kunde inte läsas'
if (Math.abs(Math.abs(txAmount) - Math.abs(movement)) > CONFIRM_AMOUNT_TOLERANCE) {
return 'Beloppet stämmer inte längre med verifikationen'
}
if (Math.sign(txAmount) !== Math.sign(movement)) {
return 'Riktningen stämmer inte längre med verifikationen'
}
return null
}
export async function confirmJournalEntrySuggestions(
supabase: SupabaseClient,
companyId: string,
userId: string,
transactionIds: string[],
): Promise<SuggestionActionResult> {
const result: SuggestionActionResult = { confirmed: [], rejected: [], skipped: [] }
// Sequential on purpose: each pair is re-fetched at its turn, so when two
// batch rows suggest the SAME verifikat the first confirm consumes it and the
// second reads its (trigger-cleared) suggestion as gone instead of racing.
for (const transactionId of transactionIds) {
const tx = await fetchSuggestionRow(supabase, companyId, transactionId)
if (!tx) {
result.skipped.push({ transactionId, reason: 'not_found' })
continue
}
if (tx.journal_entry_id) {
result.skipped.push({ transactionId, reason: 'already_linked' })
continue
}
if (!tx.potential_journal_entry_id) {
result.skipped.push({ transactionId, reason: 'no_suggestion' })
continue
}
// Explicit consumption check on top of the invalidation trigger: a
// verifikat another transaction already settles is not offered twice.
// (manualLink deliberately allows N:1 for the manual instalments case;
// bulk-confirming a suggestion is not that case.)
const { data: consumers } = await supabase
.from('transactions')
.select('id')
.eq('company_id', companyId)
.eq('journal_entry_id', tx.potential_journal_entry_id)
.limit(1)
if (consumers && consumers.length > 0) {
result.skipped.push({ transactionId, reason: 'voucher_consumed' })
continue
}
const accountNumber = await resolveSettlementAccount(supabase, companyId, tx.cash_account_id)
// Amount/direction revalidation: a suggestion is a snapshot, and the
// voucher's bank leg can legally change after it was computed (inline
// rattelse strike-and-replace keeps status 'posted', so no invalidation
// trigger fires). Never link on a stale snapshot.
const amountProblem = await verifySuggestedAmount(supabase, tx, accountNumber)
if (amountProblem) {
result.skipped.push({ transactionId, reason: 'amount_mismatch', message: amountProblem })
continue
}
const linkResult = await manualLink(
supabase,
companyId,
transactionId,
tx.potential_journal_entry_id,
userId,
accountNumber,
)
if (!linkResult.success) {
result.skipped.push({
transactionId,
reason: 'link_failed',
message: linkResult.error,
})
continue
}
result.confirmed.push(transactionId)
// Awaited: on serverless an unawaited promise can be frozen when the
// response returns, silently dropping the audit row. logMatchEvent itself
// never throws.
await logMatchEvent(supabase, userId, transactionId, 'linked_to_existing_voucher', {
matchMethod: tx.potential_match_method ?? undefined,
matchConfidence:
tx.potential_match_confidence !== null
? Number(tx.potential_match_confidence)
: undefined,
newState: {
journal_entry_id: tx.potential_journal_entry_id,
reconciliation_method: 'manual',
confirmed_suggestion: true,
},
})
}
return result
}
export async function rejectJournalEntrySuggestions(
supabase: SupabaseClient,
companyId: string,
userId: string,
transactionIds: string[],
): Promise<SuggestionActionResult> {
const result: SuggestionActionResult = { confirmed: [], rejected: [], skipped: [] }
for (const transactionId of transactionIds) {
const tx = await fetchSuggestionRow(supabase, companyId, transactionId)
if (!tx) {
result.skipped.push({ transactionId, reason: 'not_found' })
continue
}
if (!tx.potential_journal_entry_id) {
result.skipped.push({ transactionId, reason: 'no_suggestion' })
continue
}
const { error } = await supabase
.from('transactions')
.update({
potential_journal_entry_id: null,
potential_match_method: null,
potential_match_confidence: null,
})
.eq('id', transactionId)
.eq('company_id', companyId)
if (error) {
result.skipped.push({ transactionId, reason: 'link_failed', message: error.message })
continue
}
result.rejected.push(transactionId)
await logMatchEvent(supabase, userId, transactionId, 'suggestion_cleared', {
previousState: {
potential_journal_entry_id: tx.potential_journal_entry_id,
potential_match_method: tx.potential_match_method,
potential_match_confidence: tx.potential_match_confidence,
},
})
}
return result
}