* 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>
271 lines
9.7 KiB
TypeScript
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
|
|
}
|