From 08440fed9468758f6ed8488dd39732d5e014e194 Mon Sep 17 00:00:00 2001 From: Mattsson <111893710+mattssonn@users.noreply.github.com> Date: Thu, 13 Aug 2026 23:12:27 +0200 Subject: [PATCH] feat(reconciliation): match migrated bank history against imported SIE verifikat (#1598) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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 * 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 * 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 --------- Co-authored-by: Claude Fable 5 --- DECISIONS.md | 11 + app/(dashboard)/page.tsx | 43 ++- app/(dashboard)/transactions/page.tsx | 281 +++++++++++++++++- .../enable-banking/sync/cron/route.ts | 38 ++- .../bank-file/execute/__tests__/route.test.ts | 176 +++++++++++ app/api/import/bank-file/execute/route.ts | 63 ++++ .../__tests__/route.test.ts | 151 ++++++++++ .../bank/confirm-suggestions/route.ts | 53 ++++ .../bank/run/__tests__/route.test.ts | 126 +++++++- app/api/reconciliation/bank/run/route.ts | 78 ++++- .../transactions/[id]/match-invoice/route.ts | 40 ++- .../transactions/[id]/match-invoice/route.ts | 33 +- .../[id]/match-supplier-invoice/route.ts | 35 ++- components/dashboard/DashboardContent.tsx | 5 + components/import/ImportResultStep.tsx | 79 +++-- components/onboarding/NewUserChecklist.tsx | 31 ++ .../transactions/SuggestionReviewList.tsx | 182 ++++++++++++ .../transactions/TransactionInboxCard.tsx | 13 + components/transactions/transaction-types.ts | 17 +- .../components/AccountPickerDialog.tsx | 100 ++++++- extensions/general/enable-banking/index.ts | 41 ++- lib/api/schemas.ts | 15 + .../__tests__/bank-reconciliation.test.ts | 53 ++++ .../__tests__/suggestions.test.ts | 280 +++++++++++++++++ .../__tests__/unattended-sweep.test.ts | 249 ++++++++++++++++ lib/reconciliation/bank-reconciliation.ts | 107 ++++++- lib/reconciliation/suggestions.ts | 270 +++++++++++++++++ lib/reconciliation/unattended-sweep.ts | 235 +++++++++++++++ messages/en.json | 36 +++ messages/sv.json | 36 +++ ...0_transactions_potential_journal_entry.sql | 148 +++++++++ ...t_match_log_linked_to_existing_voucher.sql | 40 +++ tests/pg/payment-match-log-actions.pg.test.ts | 65 ++++ ...actions-potential-journal-entry.pg.test.ts | 185 ++++++++++++ types/index.ts | 7 + 35 files changed, 3223 insertions(+), 99 deletions(-) create mode 100644 app/api/import/bank-file/execute/__tests__/route.test.ts create mode 100644 app/api/reconciliation/bank/confirm-suggestions/__tests__/route.test.ts create mode 100644 app/api/reconciliation/bank/confirm-suggestions/route.ts create mode 100644 components/transactions/SuggestionReviewList.tsx create mode 100644 lib/reconciliation/__tests__/suggestions.test.ts create mode 100644 lib/reconciliation/__tests__/unattended-sweep.test.ts create mode 100644 lib/reconciliation/suggestions.ts create mode 100644 lib/reconciliation/unattended-sweep.ts create mode 100644 supabase/migrations/20260813121000_transactions_potential_journal_entry.sql create mode 100644 supabase/migrations/20260813210000_payment_match_log_linked_to_existing_voucher.sql create mode 100644 tests/pg/payment-match-log-actions.pg.test.ts create mode 100644 tests/pg/transactions-potential-journal-entry.pg.test.ts diff --git a/DECISIONS.md b/DECISIONS.md index 565d9a9c..c8035172 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -901,6 +901,13 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-13] Underlag failures resolve the response where it fails (getResponseErrorMessage + status) and an expired session announces itself on the existing session-timeout BroadcastChannel, instead of a global authenticated-fetch wrapper: `throw new Error(json.error)` printed "[object Object]" for the structured envelope, so the middleware 401 on a backgrounded mobile tab surfaced as the generic "Något gick fel" with no way back; a wrapper would have to be threaded through every call site to buy the same thing here. Upload failures also post metadata (status, size, mime, resolved reason) to /api/log, the one API path exempt from the timeout gate, because a request answered before the route runs leaves nothing in the function logs and the user-reported failures were invisible there. [2026-08-13] Oversized phone photos are re-encoded in the browser (2400px long edge, JPEG q0.85 stepping down) rather than raising a platform limit or streaming straight to storage: hosted rejects any request body over 4.5 MB itself (measured against prod: 4.4 MB reaches the route, 4.6 MB returns a plain-text FUNCTION_PAYLOAD_TOO_LARGE), before the route runs and therefore invisibly in the function logs, while the route advertises 10 MB it can never receive. A downscaled photo is still a faithful, durably readable reproduction (BFL 7 kap), which a refusal is not. What cannot be shrunk (PDF, or HEIC where the browser will not decode it) is refused client-side with its actual size named, and 413 was added to the HTTP status map so a rejection in transit still says what happened. Direct-to-storage upload, which would remove the ceiling for PDFs too, is the follow-up, not this fix: it moves sha256/WORM integrity off the server. [2026-08-13] The book-route underlag fix landed as a pinned-document leg inside propagateUnderlagForBookedTransaction rather than the planned "extract categorize-core's propagation block into a shared helper": PR #1547 had already done that extraction overnight and wired /book and bulk-book to the shared helper, but the helper only walked matched inbox items, so a document pinned via transactions.document_id with no unconsumed inbox item (direct upload, or item consumed elsewhere) still booked to "Underlag saknas". Anchoring the pin inside the helper fixes /book, categorize, bulk-book and attach-after-book in one place; the pin is read fresh (not from the caller's pre-booking snapshot) so a concurrent attach still anchors, and the bulk-book RPC's own atomic doc-linking makes the leg a no-op there. +[2026-08-13] Bank↔SIE match suggestions stored as three columns on transactions (potential_journal_entry_id/method/confidence, mirroring potential_invoice_id) with DB-trigger invalidation, not a suggestions table: one candidate per row matches the greedy matcher's contract, the enrichment/consume/clear paths are the exact shape the invoice hints already use, and triggers (self-clear on link/ignore, sibling-clear on consumption, clear on storno reversal) cover every write path including MCP and RPCs where app-side clearing cannot. +[2026-08-13] /api/reconciliation/bank/run apply runs WITHOUT selected_matches now apply at the 0.9 unattended floor and persist the 0.75-0.89 band as suggestions, a deliberate behavior change from apply-everything-including-fuzzy: nobody reviewed those pairs, and auto-committing 0.75-fuzzy matches on an unreviewed run is exactly what the unattended threshold exists to prevent. Reviewed applies (selected_matches) keep the legacy no-floor behavior. +[2026-08-13] "Granska migrerad historik" is a conditional third tab on the existing Transactions page (renders only while suggestion rows exist), not a new page: the plan called for a filtered view, the rows are transactions, and a permanent surface would advertise a migrator-only flow to every company. Its attn line yields to the SKV-reconnect line (max one per page, convention 6). +[2026-08-13] The pre-migration row marker keys on sie_imports.fiscal_year_end, deliberately period-based: it labels which period a row belongs to. This is NOT the #917 trap (suggesting a sync-skip date from fiscal_year_end); the marker suggests nothing, and the account picker's migrator nudge uses fiscal_year_start to pull MORE history, never less. +[2026-08-13] Staging migrations (20260813120000 [file since renamed to 20260813210000, see the rename entry below], 20260813121000) applied via execute_sql with explicit supabase_migrations.schema_migrations inserts under the exact file versions, instead of MCP apply_migration: apply_migration stamps its own apply-time version, which would leave staging's history diverging from the repo files mid-reconcile (the drift Emil is currently draining). Prod untouched; it gets both files at merge. +[2026-08-13] /skeptic on bank-and-sie-match returned BLOCK (correctness + compliance skeptics refuted; regression could not). All four blocking defects fixed in the same worktree: bulk confirm now chunks at the schema's 500-id cap; all_accounts rejects (400) any combination with dry_run/account_number/selected_matches instead of silently applying on a requested preview; the sweep summary JSONB carries `errors` and the checklist note stays silent on an incomplete sweep; confirm revalidates the voucher's bank-leg amount AND direction at click time (closes the inline-rattelse staleness hole, since strike-and-replace keeps status posted and fires no invalidation trigger) and resolves NULL-cash_account_id rows via the PRIMARY cash account instead of hard-coded 1930. +[2026-08-13] Two pre-existing holes the skeptic surfaced were deliberately NOT fixed here (scope containment): (a) the >= 0.9 auto-apply in runReconciliation has never written payment_match_log ('matched' is only logged by the invoice-match routes), which fails the log's own BFL 7:1 charter and predates this feature; (b) the match-invoice routes' storno-conflict branch reverses ANY linked journal entry without checking reconciliation_method, so a reconciliation link to a multi-event SIE verifikat can trigger a whole-entry reversal. Both need their own issues/PRs; this feature widens their exposed population, so they should land before or with the migrator rollout. [2026-08-13] Correction-chain depth guard measures depth by walking correction_of_id/reverses_id links (new lib/core/bookkeeping/correction-chain.ts), never by matching "Rättelse:" prefixes in descriptions: custom verifikationstext (allowed since #1031) would dodge any text-based check. Threshold 3, advisory with an explicit allow_deep_chain bypass on every surface (service option, REST body, MCP tool arg, UI confirm) per the standing soft-guard rule; Christoffer's agent chains hit 10 deep. The guard also fires at MCP staging time, not only at commit, so the agent reconsiders in the same turn instead of after approval. [2026-08-13] The reverse (storno) UI on the journal detail page got the same catch-and-confirm bypass as the correction dialog even though the plan scoped UI work to the correction dialog: reverseEntry carries the same guard, and without a working "Återför ändå" the guard would dead-end in a toast, which the soft-guard rule forbids. [2026-08-13] tools/list payload ceiling bumped 59K to 59.5K for the two allow_deep_chain schema properties (trimmed to one sentence first): the bypass is wire contract agents must discover to act on CORRECTION_CHAIN_TOO_DEEP, not trimmable prose. @@ -941,3 +948,7 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-13] Per-account VAT treatment is class-aware; explicit values extend custom accounts while canonical accounts keep their static BAS momsdeklaration mapping. SIE #SRU and #KTYP never supply it because they encode tax-return fields and account class, not momsdeklaration treatment. VMB carries no default account rate because its VAT base is the margin, not gross sales. [2026-08-13] Issue #1408 separates evidence classification from lock and VAT overlays, and treats import and correction sources as false positives for live-template provenance: 1,316 of 1,361 production signature matches came from those sources, so a broad account signature must not become a correction queue. [2026-08-13] Issue #1408 does not route locked candidates through the current correctEntry service or an unlock: the service creates its storno in the original period, so a compliant locked-period correction needs a separately tested open-period storno and replacement path that preserves the original and full rättelse trail. +[2026-08-13] payment_match_log CHECK migration renamed 20260813120000 -> 20260813210000 at merge time: main gained its own 20260813120000 (fix_franvaro_audit_trigger_definer) while this branch was in flight, and identical versions abort the Supabase apply with a schema_migrations_pkey duplicate. Staging's tracking row was updated to the new version in the same step, freeing the 20260813120000 slot for main's migration when staging reconciles. +[2026-08-13] PR #1598, compliance findings closed with the rollout after the Swedish accounting review escalated them from follow-up to fix-with-rollout: (a) runReconciliation's >= 0.9 auto-apply now writes 'matched' to payment_match_log (behandlingshistorik, BFNAR 2013:2 kap 8); (b) the three match-route storno-conflict branches no longer storno-reverse a reconciliation-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 §). The detach is DEFERRED (round 2, CodeRabbit): nothing is persisted up front; the final transaction update overwrites the pointer and clears reconciliation_method in the same write, so a failure anywhere in the match flow leaves the existing link intact, and the release is logged as 'unmatched' after the commit. +[2026-08-13] PR #1598, CodeRabbit findings: confirm-suggestions maxDuration 300; lookbackTouched on the migrator nudge buttons; persistSuggestions on main's post-backfill sweep; sie_sweep stamp errors logged; sandbox keeps the CSV CTA (file import works there); payment_match_log CHECK swap now NOT VALID + VALIDATE (no table-scan under ACCESS EXCLUSIVE); every logMatchEvent call awaited (serverless can freeze unawaited work). +[2026-08-13] Historical audit gap quantified on prod (read-only): 762 manual-method links across 52 companies since 2026-03-23 have no payment_match_log row (upper bound: includes linked_to_existing_voucher drops AND older unlogged manual paths). Not backfillable (the inserts never landed); the links themselves are intact on transactions. Recorded here as the explicit ops note the compliance review asked for. diff --git a/app/(dashboard)/page.tsx b/app/(dashboard)/page.tsx index f47dfe79..9d3c7610 100644 --- a/app/(dashboard)/page.tsx +++ b/app/(dashboard)/page.tsx @@ -59,7 +59,7 @@ export default async function DashboardPage() { supabase.from('customers').select('*', { count: 'exact', head: true }).eq('company_id', companyId), supabase.from('invoices').select('*', { count: 'exact', head: true }).eq('company_id', companyId), supabase.from('transactions').select('*', { count: 'exact', head: true }).eq('company_id', companyId), - supabase.from('bank_connections').select('id, status, consent_expires, bank_name').eq('company_id', companyId).eq('status', 'active'), + supabase.from('bank_connections').select('id, status, consent_expires, bank_name, last_sie_sweep').eq('company_id', companyId).eq('status', 'active'), supabase.from('sie_imports').select('*', { count: 'exact', head: true }).eq('company_id', companyId).eq('status', 'completed'), // Skatteverket tokens are user-scoped (one BankID identity per user) but // carry the active company_id; either filter would work: we use user_id @@ -158,6 +158,37 @@ export default async function DashboardPage() { const userFirstName = profile?.full_name?.trim().split(/\s+/)[0] ?? null + // Latest SIE reconciliation-sweep summary across both history sources + // (PSD2 sync stamps bank_connections.last_sie_sweep; a bank-file import + // stamps bank_file_imports.sie_sweep). Feeds the checklist's bank step with + // "X matchade, Y att granska" so a migrator sees the sweep outcome without + // hunting for it. Best-effort: absent rows just render no note. + type SieSweepSummaryLite = { + auto_linked?: number + suggested?: number + unmatched?: number + errors?: number + ran_at?: string + } + const { data: latestFileSweep } = await supabase + .from('bank_file_imports') + .select('sie_sweep') + .eq('company_id', companyId) + .not('sie_sweep', 'is', null) + .order('created_at', { ascending: false }) + .limit(1) + .maybeSingle() + const sweepCandidates: SieSweepSummaryLite[] = [ + ...(bankConnections || []) + .map((c) => c.last_sie_sweep as SieSweepSummaryLite | null) + .filter((s): s is SieSweepSummaryLite => Boolean(s)), + ...(latestFileSweep?.sie_sweep ? [latestFileSweep.sie_sweep as SieSweepSummaryLite] : []), + ] + const sieSweep = + sweepCandidates.length > 0 + ? sweepCandidates.reduce((a, b) => ((a.ran_at ?? '') >= (b.ran_at ?? '') ? a : b)) + : null + return ( ) } diff --git a/app/(dashboard)/transactions/page.tsx b/app/(dashboard)/transactions/page.tsx index c3042e76..29848aaa 100644 --- a/app/(dashboard)/transactions/page.tsx +++ b/app/(dashboard)/transactions/page.tsx @@ -42,7 +42,9 @@ import type { ViewMode, SourceFilter, CategorizeHandler, + PotentialVoucher, } from '@/components/transactions/transaction-types' +import { SuggestionReviewList } from '@/components/transactions/SuggestionReviewList' import type { SkattekontoBatchResult, SkattekontoBatchRowResult, @@ -182,7 +184,11 @@ function buildSupplierInvoiceMap( // prod schema cache (see DECISIONS.md 2026-07-06). async function fetchPotentialMatches( supabase: SupabaseClient, - rows: { potential_invoice_id: string | null; potential_supplier_invoice_id: string | null }[], + rows: { + potential_invoice_id: string | null + potential_supplier_invoice_id: string | null + potential_journal_entry_id?: string | null + }[], ) { const potentialInvoiceIds = Array.from( new Set(rows.flatMap((t) => (t.potential_invoice_id ? [t.potential_invoice_id] : []))), @@ -190,6 +196,9 @@ async function fetchPotentialMatches( const potentialSupplierInvoiceIds = Array.from( new Set(rows.flatMap((t) => (t.potential_supplier_invoice_id ? [t.potential_supplier_invoice_id] : []))), ) + const potentialJournalEntryIds = Array.from( + new Set(rows.flatMap((t) => (t.potential_journal_entry_id ? [t.potential_journal_entry_id] : []))), + ) // Chunked .in() lists (PostgREST .in() URL-length convention, same 150 as // the underlag-status effect below): the caller may pass the full pending @@ -206,7 +215,7 @@ async function fetchPotentialMatches( // an unmatchable candidate must not reach the row or the match dialog, which // would otherwise compare the transaction against a 0 kr remaining balance // and call it a partial payment. - const [invoiceResults, supplierInvoiceResults] = await Promise.all([ + const [invoiceResults, supplierInvoiceResults, voucherResults] = await Promise.all([ Promise.all( chunks(potentialInvoiceIds).map((ids) => supabase @@ -227,6 +236,19 @@ async function fetchPotentialMatches( .gt('remaining_amount', 0), ), ), + // Journal-entry match suggestions (the sweep's 0.75-0.89 band). DB + // triggers clear the pointer when the entry is consumed or reversed, but + // the posted-status filter revalidates anyway: a suggestion that no + // longer resolves to a live verifikat must not reach the review surface. + Promise.all( + chunks(potentialJournalEntryIds).map((ids) => + supabase + .from('journal_entries') + .select('id, voucher_series, voucher_number, entry_date, description') + .in('id', ids) + .eq('status', 'posted'), + ), + ), ]) // Non-fatal: the transaction list still renders without match hints, but @@ -237,10 +259,31 @@ async function fetchPotentialMatches( for (const r of supplierInvoiceResults) { if (r.error) console.error('[fetchPotentialMatches] supplier_invoices query failed', r.error) } + for (const r of voucherResults) { + if (r.error) console.error('[fetchPotentialMatches] journal_entries query failed', r.error) + } + + const voucherMap: Record = {} + for (const je of voucherResults.flatMap((r) => (r.data ?? []) as Array<{ + id: string + voucher_series: string + voucher_number: number + entry_date: string + description: string | null + }>)) { + voucherMap[je.id] = { + journal_entry_id: je.id, + voucher_series: je.voucher_series, + voucher_number: je.voucher_number, + entry_date: je.entry_date, + description: je.description, + } + } return { invoiceMap: buildInvoiceMap(invoiceResults.flatMap((r) => r.data ?? [])), supplierInvoiceMap: buildSupplierInvoiceMap(supplierInvoiceResults.flatMap((r) => r.data ?? [])), + voucherMap, } } @@ -500,6 +543,39 @@ export default function TransactionsPage() { const refreshTransactionsInFlightRef = useRef(false) const refreshTransactionsQueuedRef = useRef(false) + // "Kör matchning igen" in the review surface. + const [rerunningMatch, setRerunningMatch] = useState(false) + + // End of the company's completed SIE-import coverage (latest + // fiscal_year_end). Drives the quiet "från perioden före din migrering" + // marker on inbox rows: period-based on purpose, it labels which period a + // row belongs to, it never suggests a sync skip date (that was #917). + const [sieCoverageEnd, setSieCoverageEnd] = useState(null) + useEffect(() => { + if (!companyId) { + setSieCoverageEnd(null) + return + } + let cancelled = false + ;(async () => { + const { data } = await supabase + .from('sie_imports') + .select('fiscal_year_end') + .eq('company_id', companyId) + .eq('status', 'completed') + .not('fiscal_year_end', 'is', null) + .order('fiscal_year_end', { ascending: false }) + .limit(1) + .maybeSingle() + if (!cancelled) { + setSieCoverageEnd((data as { fiscal_year_end?: string } | null)?.fiscal_year_end || null) + } + })() + return () => { + cancelled = true + } + }, [companyId, supabase]) + // Computed lists const uncategorizedTransactions = useMemo( () => transactions @@ -513,6 +589,18 @@ export default function TransactionsPage() { [exitingIds, transactions], ) + // Journal-entry match suggestions awaiting review (the migrator surface). + // potential_voucher is already revalidated (posted-only) at enrichment time, + // and DB triggers clear consumed/reversed suggestions, so this list is + // honest without extra queries. + const suggestionItems = useMemo( + () => + transactions.filter( + (t) => t.potential_voucher && !t.journal_entry_id && !t.is_ignored && !exitingIds.has(t.id), + ), + [exitingIds, transactions], + ) + // Merged inbox: bank tx + SKV rows interleaved by date. Source filter // narrows to one side. SKV rows always go after bank rows on the same // date: bank tx tend to have invoice-match suggestions and we'd rather @@ -891,7 +979,7 @@ export default function TransactionsPage() { const windowIds = new Set(rows.map((r) => r.id)) const olderPending = (pendingRows ?? []).filter((r) => !windowIds.has(r.id)) const allRows = [...rows, ...olderPending].sort((a, b) => b.date.localeCompare(a.date)) - const { invoiceMap, supplierInvoiceMap } = await fetchPotentialMatches(supabase, allRows) + const { invoiceMap, supplierInvoiceMap, voucherMap } = await fetchPotentialMatches(supabase, allRows) // Re-check after the second await: a scope change during the match // enrichment must also discard this response. @@ -903,6 +991,9 @@ export default function TransactionsPage() { potential_supplier_invoice: t.potential_supplier_invoice_id ? supplierInvoiceMap[t.potential_supplier_invoice_id] : undefined, + potential_voucher: t.potential_journal_entry_id + ? voucherMap[t.potential_journal_entry_id] + : undefined, })) setTransactions(transactionsWithInvoices) @@ -973,7 +1064,7 @@ export default function TransactionsPage() { setPagedThroughDate(txData.length >= PAGE_SIZE ? txData[txData.length - 1].date : null) setHasMore(txData.length >= PAGE_SIZE) - const { invoiceMap, supplierInvoiceMap } = await fetchPotentialMatches(supabase, txData) + const { invoiceMap, supplierInvoiceMap, voucherMap } = await fetchPotentialMatches(supabase, txData) // Same staleness rule after the enrichment await: the offsets above were // written under this generation, but a newer fetch has already reset them. @@ -988,6 +1079,9 @@ export default function TransactionsPage() { potential_supplier_invoice: t.potential_supplier_invoice_id ? supplierInvoiceMap[t.potential_supplier_invoice_id] : undefined, + potential_voucher: t.potential_journal_entry_id + ? voucherMap[t.potential_journal_entry_id] + : undefined, })) // The page may overlap pending rows already merged into state: keep the @@ -1939,6 +2033,132 @@ export default function TransactionsPage() { }, 350) } + // "Granska migrerad historik": confirm/reject persisted journal-entry match + // suggestions through the server-side revalidating bulk endpoint. Stale + // pairs come back as skipped, never as a failed batch: the toast reports + // both numbers honestly and the refresh re-derives the list from DB truth. + // Chunked at the schema's 500-id cap (same as BankReconciliationView's + // apply): a migrator's review list can exceed it, and one unchunked POST + // would 400 with zero progress on exactly the flagship bulk action. + const SUGGESTION_CHUNK_SIZE = 500 + + const confirmSuggestions = useCallback( + async (transactionIds: string[]) => { + let confirmed = 0 + let skipped = 0 + try { + for (let i = 0; i < transactionIds.length; i += SUGGESTION_CHUNK_SIZE) { + const chunk = transactionIds.slice(i, i + SUGGESTION_CHUNK_SIZE) + const response = await fetch('/api/reconciliation/bank/confirm-suggestions', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ transaction_ids: chunk, action: 'confirm' }), + }) + const payload = await response.json() + if (!response.ok) { + // Report the partial progress alongside the failure: chunks that + // already committed stay committed. + toast({ + title: t('review_confirm_failed'), + description: getErrorMessage(payload, { context: 'transaction' }), + variant: 'destructive', + }) + return + } + confirmed += (payload.data?.confirmed ?? []).length + skipped += (payload.data?.skipped ?? []).length + } + toast({ + title: t('review_confirm_done_title', { count: confirmed }), + description: + skipped > 0 + ? t('review_confirm_done_skipped', { count: skipped }) + : t('review_confirm_done_description'), + }) + } catch (error) { + toast({ + title: t('review_confirm_failed'), + description: getErrorMessage(error, { context: 'transaction' }), + variant: 'destructive', + }) + } finally { + await refreshTransactions() + } + }, + [refreshTransactions, t, toast], + ) + + const rejectSuggestions = useCallback( + async (transactionIds: string[]) => { + try { + for (let i = 0; i < transactionIds.length; i += SUGGESTION_CHUNK_SIZE) { + const chunk = transactionIds.slice(i, i + SUGGESTION_CHUNK_SIZE) + const response = await fetch('/api/reconciliation/bank/confirm-suggestions', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ transaction_ids: chunk, action: 'reject' }), + }) + if (!response.ok) { + const payload = await response.json() + toast({ + title: t('review_reject_failed'), + description: getErrorMessage(payload, { context: 'transaction' }), + variant: 'destructive', + }) + return + } + } + } catch (error) { + toast({ + title: t('review_reject_failed'), + description: getErrorMessage(error, { context: 'transaction' }), + variant: 'destructive', + }) + } finally { + await refreshTransactions() + } + }, + [refreshTransactions, t, toast], + ) + + // "Kör matchning igen": full per-account sweep over all history. New >= 0.9 + // matches auto-link; the review band lands back here as fresh suggestions. + const rerunMatching = useCallback(async () => { + setRerunningMatch(true) + try { + const response = await fetch('/api/reconciliation/bank/run', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ all_accounts: true }), + }) + const payload = await response.json() + if (!response.ok) { + toast({ + title: t('review_rerun_failed'), + description: getErrorMessage(payload, { context: 'transaction' }), + variant: 'destructive', + }) + return + } + toast({ + title: t('review_rerun_done_title'), + description: t('review_rerun_done_description', { + applied: payload.data?.applied ?? 0, + suggested: payload.data?.suggested ?? 0, + }), + }) + } catch (error) { + toast({ + title: t('review_rerun_failed'), + description: getErrorMessage(error, { context: 'transaction' }), + variant: 'destructive', + }) + } finally { + setRerunningMatch(false) + await refreshTransactions() + } + }, [refreshTransactions, t, toast]) + async function handleMatchInvoice(transactionId: string, invoiceId: string): Promise { try { const response = await fetch(`/api/transactions/${transactionId}/match-invoice`, { @@ -2940,11 +3160,19 @@ export default function TransactionsPage() { setIsDialogOpen(true)} /> - {skvNeedsReconnect && ( + {skvNeedsReconnect ? ( {t('skv_reconnect_body')} - )} + ) : suggestionItems.length > 0 && mode !== 'review' ? ( + // Migrated-history review nudge. Max one attn line per page + // (convention 6): the SKV reconnect line wins when both apply. + setMode('review') }} + > + {t('review_attn_body', { count: suggestionItems.length })} + + ) : null} {/* Toolbar (concept order): [Att bokföra/Alla-seg] [sök] [Välj flera] ... [source ContextPicker far right] */} @@ -2981,6 +3209,29 @@ export default function TransactionsPage() { > {t('mode_all')} + {/* Review tab exists only while suggestions do (or while the user is + standing in it after emptying the list): a permanent third tab + would advertise a migrator surface most companies never need. */} + {(suggestionItems.length > 0 || mode === 'review') && ( + + )}
@@ -3034,6 +3285,15 @@ export default function TransactionsPage() {
))} + ) : mode === 'review' ? ( + ) : mode === 'inbox' ? ( inboxItems.length === 0 ? ( searchTerm || sourceFilter !== 'all' || periodBounds ? ( @@ -3059,8 +3319,12 @@ export default function TransactionsPage() { is usually a migration/import whose counterpart vouchers already exist, and the only match affordance here is per-row. Static text + count (no probe): the reconciliation preview is - the honest source of how many actually match. */} - {selectableInboxIds.length >= 5 && ( + the honest source of how many actually match. + Yields to the review-suggestions attn at the top of the page + (max one ochre sentence per page): when the sweep has already + persisted suggestions, "Granska förslagen" is the more precise + destination for the same backlog. */} + {selectableInboxIds.length >= 5 && suggestionItems.length === 0 && ( ) : ( { const totalDuplicates = syncResults.reduce((sum, r) => sum + r.duplicates, 0) const totalErrors = syncResults.reduce((sum, r) => sum + r.errors, 0) - // Batch reconciliation sweep when SIE overlap detected + // Batch reconciliation sweep when SIE overlap detected. One scoped run + // per enabled cash account (issue #1298): a pooled run matched every + // same-currency account's transactions against 1930's GL lines and could + // persist a cross-account journal_entry_id. if (sieOverlap && totalImported > 0) { try { - const reconResult = await runReconciliation(supabase, connection.company_id, connection.user_id, { - dateFrom: fromDate, - dateTo: toDate, - // Unattended run: nobody reviews a dry-run first, so never commit - // low-confidence (fuzzy / date-range) matches automatically. - confidenceThreshold: DEFAULT_UNATTENDED_CONFIDENCE_THRESHOLD, - }) + const reconResult = await runUnattendedReconciliationSweep( + supabase, + connection.company_id, + connection.user_id, + { dateFrom: fromDate, dateTo: toDate }, + ) + // Stamp the outcome so the UI can render "Vi matchade X av Y" and the + // review surface knows there is something to granska. + await supabase + .from('bank_connections') + .update({ + last_sie_sweep: toSweepSummary(reconResult, { dateFrom: fromDate, dateTo: toDate }), + }) + .eq('id', connection.id) if (reconResult.applied > 0 || reconResult.skippedBelowThreshold > 0) { ctx.log.info('batch reconciliation after sync', { companyId: connection.company_id, applied: reconResult.applied, skippedBelowThreshold: reconResult.skippedBelowThreshold, - total: reconResult.matches.length, + accounts: reconResult.accounts.map((a) => ({ + accountNumber: a.accountNumber, + applied: a.applied, + skippedBelowThreshold: a.skippedBelowThreshold, + })), }) } } catch { diff --git a/app/api/import/bank-file/execute/__tests__/route.test.ts b/app/api/import/bank-file/execute/__tests__/route.test.ts new file mode 100644 index 00000000..621fce92 --- /dev/null +++ b/app/api/import/bank-file/execute/__tests__/route.test.ts @@ -0,0 +1,176 @@ +/** + * Tests for POST /api/import/bank-file/execute, focused on the SIE-overlap + * behavior: a bank file covering a period a completed SIE import already + * booked must (a) suppress auto-categorization to prevent double-booking and + * (b) trigger the per-account reconciliation sweep and stamp its summary, + * because CSV is how a migrator gets pre-PSD2 history into the system. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' +import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers' + +const { supabase, enqueue, reset } = createQueuedMockSupabase() + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), +})) + +vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), + requireCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +const getCompanyRoleMock = vi.fn() +vi.mock('@/lib/auth/require-write', () => ({ + getCompanyRole: (...args: unknown[]) => getCompanyRoleMock(...args), + requireWritePermission: vi.fn().mockResolvedValue({ ok: true }), +})) + +vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() })) + +const ingestMock = vi.fn() +vi.mock('@/lib/transactions/ingest', () => ({ + ingestTransactions: (...args: unknown[]) => ingestMock(...args), +})) + +const sweepMock = vi.fn() +vi.mock('@/lib/reconciliation/unattended-sweep', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + runUnattendedReconciliationSweep: (...args: unknown[]) => sweepMock(...args), + } +}) + +import { POST } from '../route' + +const emptyParams = { params: Promise.resolve({}) } + +function makeBody(overrides: Record = {}) { + return { + transactions: [ + { date: '2025-03-10', description: 'Hyra mars', amount: -12000, currency: 'SEK' }, + { date: '2025-01-05', description: 'Kundbetalning', amount: 25000, currency: 'SEK' }, + ], + format: 'seb', + filename: 'kontoutdrag.csv', + file_hash: 'abc123', + skip_duplicates: true, + auto_categorize: true, + ...overrides, + } +} + +function emptyIngestResult(overrides: Record = {}) { + return { + imported: 2, + duplicates: 0, + reconciled: 0, + auto_categorized: 0, + auto_matched_invoices: 0, + errors: 0, + transaction_ids: ['t-1', 't-2'], + ...overrides, + } +} + +function emptySweepResult(overrides: Record = {}) { + return { + accounts: [], + applied: 1, + errors: 0, + skippedBelowThreshold: 1, + suggested: 1, + unmatched: 0, + ...overrides, + } +} + +describe('POST /api/import/bank-file/execute (SIE overlap)', () => { + beforeEach(() => { + vi.clearAllMocks() + reset() + requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase }) + getCompanyRoleMock.mockResolvedValue({ ok: true, role: 'owner', companyId: 'company-1' }) + ingestMock.mockResolvedValue(emptyIngestResult()) + sweepMock.mockResolvedValue(emptySweepResult()) + }) + + it('returns 401 when unauthenticated', async () => { + requireAuthMock.mockResolvedValue({ + user: null, + supabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + + const request = createMockRequest('/api/import/bank-file/execute', { + method: 'POST', + body: makeBody(), + }) + const response = await POST(request, emptyParams) + expect(response.status).toBe(401) + }) + + it('suppresses auto-categorization, runs the sweep over the file window, and stamps the summary on SIE overlap', async () => { + enqueue({ data: { id: 'import-1' } }) // bank_file_imports upsert + enqueue({ data: { id: 'sie-1' } }) // sie_imports overlap: found + enqueue({ data: null }) // bank_file_imports status update + enqueue({ data: null }) // sie_sweep stamp update + enqueue({ data: [{ id: 't-1' }, { id: 't-2' }] }) // imported tx for event + + const request = createMockRequest('/api/import/bank-file/execute', { + method: 'POST', + body: makeBody(), + }) + const response = await POST(request, emptyParams) + const { status } = await parseJsonResponse(response) + + expect(status).toBe(200) + // Ingest was told to skip auto-categorization (double-booking guard). + const ingestOptions = ingestMock.mock.calls[0][4] as Record + expect(ingestOptions.skipAutoCategorization).toBe(true) + // Sweep ran over the file's own date window (min/max of its rows). + expect(sweepMock).toHaveBeenCalledWith(supabase, 'company-1', 'user-1', { + dateFrom: '2025-01-05', + dateTo: '2025-03-10', + }) + }) + + it('runs no sweep and keeps categorization when there is no SIE overlap', async () => { + enqueue({ data: { id: 'import-1' } }) // upsert + enqueue({ data: null }) // sie_imports overlap: none + enqueue({ data: null }) // status update + enqueue({ data: [{ id: 't-1' }] }) // imported tx for event + + const request = createMockRequest('/api/import/bank-file/execute', { + method: 'POST', + body: makeBody(), + }) + const response = await POST(request, emptyParams) + + expect(response.status).toBe(200) + const ingestOptions = ingestMock.mock.calls[0][4] as Record + expect(ingestOptions.skipAutoCategorization).toBeUndefined() + expect(sweepMock).not.toHaveBeenCalled() + }) + + it('never sweeps for a viewer (raw insert only)', async () => { + getCompanyRoleMock.mockResolvedValue({ ok: true, role: 'viewer', companyId: 'company-1' }) + enqueue({ data: { id: 'import-1' } }) // upsert + enqueue({ data: { id: 'sie-1' } }) // overlap found + enqueue({ data: null }) // status update + enqueue({ data: [{ id: 't-1' }] }) // imported tx for event + + const request = createMockRequest('/api/import/bank-file/execute', { + method: 'POST', + body: makeBody(), + }) + const response = await POST(request, emptyParams) + + expect(response.status).toBe(200) + const ingestOptions = ingestMock.mock.calls[0][4] as Record + expect(ingestOptions.rawInsertOnly).toBe(true) + expect(sweepMock).not.toHaveBeenCalled() + }) +}) diff --git a/app/api/import/bank-file/execute/route.ts b/app/api/import/bank-file/execute/route.ts index ae3f9c0d..1dc4a5df 100644 --- a/app/api/import/bank-file/execute/route.ts +++ b/app/api/import/bank-file/execute/route.ts @@ -10,6 +10,10 @@ import { errorResponseFromCode } from '@/lib/errors/get-structured-error' import type { ParsedBankTransaction, BankFileFormatId } from '@/lib/import/bank-file/types' import type { Transaction } from '@/types' import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message' +import { + runUnattendedReconciliationSweep, + toSweepSummary, +} from '@/lib/reconciliation/unattended-sweep' ensureInitialized() @@ -102,9 +106,30 @@ export const POST = withRouteContext( import_source: format === 'camt053' ? 'camt053' : `csv_${format}`, })) + // Detect SIE overlap, mirroring the enable-banking sync paths: a bank + // file covering a period a completed SIE import already booked must be + // matched against the imported verifikat, not re-booked. CSV is the only + // way a migrator gets deep history (PSD2 windows stop at ~90 days), so + // this path is the primary one for the Fortnox/SIE migrator journey. + const fileDateFrom = transactions.map((t) => t.date).sort()[0] || undefined + const fileDateTo = transactions.map((t) => t.date).sort().reverse()[0] || undefined + let sieOverlap: { id: string } | null = null + if (fileDateFrom) { + const { data } = await supabase + .from('sie_imports') + .select('id') + .eq('company_id', companyId) + .eq('status', 'completed') + .gte('fiscal_year_end', fileDateFrom) + .limit(1) + .maybeSingle() + sieOverlap = data ?? null + } + const ingestOptions: IngestOptions = {} if (settlement_account) ingestOptions.settlementAccount = settlement_account if (role === 'viewer') ingestOptions.rawInsertOnly = true + if (sieOverlap) ingestOptions.skipAutoCategorization = true const ingestResult = await ingestTransactions(supabase, companyId, user.id, rawTransactions, ingestOptions) if (ingestResult.errors > 0 && ingestResult.first_error) { @@ -133,6 +158,44 @@ export const POST = withRouteContext( }) .eq('id', importRecord.id) + // SIE-overlap-gated reconciliation sweep (issue: no sweep fired after a + // bank CSV import, yet CSV is how a migrator gets pre-PSD2 history). One + // scoped run per enabled cash account; >= 0.9 auto-links, the 0.75-0.89 + // band persists as reviewable suggestions. Viewers skip it: the sweep + // updates transactions, which viewers cannot do. + if (sieOverlap && ingestResult.imported > 0 && role !== 'viewer') { + try { + const sweepResult = await runUnattendedReconciliationSweep(supabase, companyId, user.id, { + dateFrom: fileDateFrom, + dateTo: fileDateTo, + }) + const { error: stampError } = await supabase + .from('bank_file_imports') + .update({ + sie_sweep: toSweepSummary(sweepResult, { + dateFrom: fileDateFrom, + dateTo: fileDateTo, + }), + }) + .eq('id', importRecord.id) + if (stampError) { + // The links/suggestions are already written; only the UI summary + // is missing. Say so instead of letting the sweep look unrun. + opLog.warn('failed to stamp sie_sweep summary on bank_file_imports', stampError) + } + if (sweepResult.applied > 0 || sweepResult.suggested > 0) { + opLog.info('post-import SIE reconciliation sweep', { + applied: sweepResult.applied, + suggested: sweepResult.suggested, + unmatched: sweepResult.unmatched, + }) + } + } catch (err) { + // Non-critical: rows stay in "Att bokföra" for manual matching. + opLog.warn('post-import SIE reconciliation sweep failed', err as Error) + } + } + if (ingestResult.imported > 0 && ingestResult.transaction_ids.length > 0) { try { const { data: importedTransactions } = await supabase diff --git a/app/api/reconciliation/bank/confirm-suggestions/__tests__/route.test.ts b/app/api/reconciliation/bank/confirm-suggestions/__tests__/route.test.ts new file mode 100644 index 00000000..d7410476 --- /dev/null +++ b/app/api/reconciliation/bank/confirm-suggestions/__tests__/route.test.ts @@ -0,0 +1,151 @@ +/** + * Tests for POST /api/reconciliation/bank/confirm-suggestions. + * + * Exercises the route through the real withRouteContext wrapper, mocking only + * its auth/company/write dependencies plus the suggestions service. Covers: + * 401, 403 viewer, validation (400), and both actions' happy paths. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' +import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers' + +const { supabase, reset } = createQueuedMockSupabase() + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), +})) + +vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), + requireCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +const requireWriteMock = vi.fn() +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: (...args: unknown[]) => requireWriteMock(...args), +})) + +vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() })) + +const confirmMock = vi.fn() +const rejectMock = vi.fn() +vi.mock('@/lib/reconciliation/suggestions', () => ({ + confirmJournalEntrySuggestions: (...args: unknown[]) => confirmMock(...args), + rejectJournalEntrySuggestions: (...args: unknown[]) => rejectMock(...args), +})) + +import { POST } from '../route' + +const emptyParams = { params: Promise.resolve({}) } +const TX_1 = '11111111-1111-4111-8111-111111111111' +const TX_2 = '22222222-2222-4222-8222-222222222222' + +describe('POST /api/reconciliation/bank/confirm-suggestions', () => { + beforeEach(() => { + vi.clearAllMocks() + reset() + requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase }) + requireWriteMock.mockResolvedValue({ ok: true }) + confirmMock.mockResolvedValue({ confirmed: [TX_1], rejected: [], skipped: [] }) + rejectMock.mockResolvedValue({ confirmed: [], rejected: [TX_1], skipped: [] }) + }) + + it('returns 401 when unauthenticated', async () => { + requireAuthMock.mockResolvedValue({ + user: null, + supabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + + const request = createMockRequest('/api/reconciliation/bank/confirm-suggestions', { + method: 'POST', + body: { transaction_ids: [TX_1], action: 'confirm' }, + }) + + const response = await POST(request, emptyParams) + expect(response.status).toBe(401) + expect(confirmMock).not.toHaveBeenCalled() + }) + + it('returns 403 for a viewer', async () => { + requireWriteMock.mockResolvedValue({ + ok: false, + response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }), + }) + + const request = createMockRequest('/api/reconciliation/bank/confirm-suggestions', { + method: 'POST', + body: { transaction_ids: [TX_1], action: 'confirm' }, + }) + + const response = await POST(request, emptyParams) + expect(response.status).toBe(403) + expect(confirmMock).not.toHaveBeenCalled() + }) + + it('rejects an empty transaction_ids array with 400', async () => { + const request = createMockRequest('/api/reconciliation/bank/confirm-suggestions', { + method: 'POST', + body: { transaction_ids: [], action: 'confirm' }, + }) + + const response = await POST(request, emptyParams) + expect(response.status).toBe(400) + expect(confirmMock).not.toHaveBeenCalled() + }) + + it('rejects an unknown action with 400', async () => { + const request = createMockRequest('/api/reconciliation/bank/confirm-suggestions', { + method: 'POST', + body: { transaction_ids: [TX_1], action: 'maybe' }, + }) + + const response = await POST(request, emptyParams) + expect(response.status).toBe(400) + }) + + it('confirms suggestions and reports skipped pairs in snake_case', async () => { + confirmMock.mockResolvedValue({ + confirmed: [TX_1], + rejected: [], + skipped: [{ transactionId: TX_2, reason: 'voucher_consumed' }], + }) + + const request = createMockRequest('/api/reconciliation/bank/confirm-suggestions', { + method: 'POST', + body: { transaction_ids: [TX_1, TX_2], action: 'confirm' }, + }) + + const response = await POST(request, emptyParams) + const { status, body } = await parseJsonResponse<{ + data: { + confirmed: string[] + skipped: Array<{ transaction_id: string; reason: string }> + } + }>(response) + + expect(status).toBe(200) + expect(body.data.confirmed).toEqual([TX_1]) + expect(body.data.skipped).toEqual([ + { transaction_id: TX_2, reason: 'voucher_consumed', message: undefined }, + ]) + expect(confirmMock).toHaveBeenCalledWith(supabase, 'company-1', 'user-1', [TX_1, TX_2]) + expect(rejectMock).not.toHaveBeenCalled() + }) + + it('routes action=reject to the reject service', async () => { + const request = createMockRequest('/api/reconciliation/bank/confirm-suggestions', { + method: 'POST', + body: { transaction_ids: [TX_1], action: 'reject' }, + }) + + const response = await POST(request, emptyParams) + const { status, body } = await parseJsonResponse<{ data: { rejected: string[] } }>(response) + + expect(status).toBe(200) + expect(body.data.rejected).toEqual([TX_1]) + expect(rejectMock).toHaveBeenCalledWith(supabase, 'company-1', 'user-1', [TX_1]) + expect(confirmMock).not.toHaveBeenCalled() + }) +}) diff --git a/app/api/reconciliation/bank/confirm-suggestions/route.ts b/app/api/reconciliation/bank/confirm-suggestions/route.ts new file mode 100644 index 00000000..387efdb4 --- /dev/null +++ b/app/api/reconciliation/bank/confirm-suggestions/route.ts @@ -0,0 +1,53 @@ +import { NextResponse } from 'next/server' +import { ensureInitialized } from '@/lib/init' +import { withRouteContext } from '@/lib/api/with-route-context' +import { validateBody } from '@/lib/api/validate' +import { ConfirmJeSuggestionsSchema } from '@/lib/api/schemas' +import { + confirmJournalEntrySuggestions, + rejectJournalEntrySuggestions, +} from '@/lib/reconciliation/suggestions' + +ensureInitialized() + +// A full 500-item batch runs sequentially with several queries per pair +// (revalidation is the point), which can exceed the platform's default +// function budget. Same 5-minute allowance as the bank-file and SIE imports. +export const maxDuration = 300 + +/** + * POST /api/reconciliation/bank/confirm-suggestions + * + * Bulk confirm (or reject) journal-entry match suggestions written by the + * SIE reconciliation sweep. Confirming links each transaction to its suggested + * verifikat via the ordinary manual-link path with full server-side + * revalidation per pair; pairs that went stale between suggestion and click + * (entry reversed, verifikat consumed by another row, row booked elsewhere) + * are skipped and reported rather than failing the batch. + */ +export const POST = withRouteContext( + 'reconciliation.bank.confirm_suggestions', + async (request, { supabase, user, companyId }) => { + const validation = await validateBody(request, ConfirmJeSuggestionsSchema) + if (!validation.success) return validation.response + const { transaction_ids, action } = validation.data + + const result = + action === 'confirm' + ? await confirmJournalEntrySuggestions(supabase, companyId, user.id, transaction_ids) + : await rejectJournalEntrySuggestions(supabase, companyId, user.id, transaction_ids) + + return NextResponse.json({ + data: { + confirmed: result.confirmed, + rejected: result.rejected, + skipped: result.skipped.map((s) => ({ + transaction_id: s.transactionId, + reason: s.reason, + message: s.message, + })), + }, + }) + }, + { requireWrite: true }, +) diff --git a/app/api/reconciliation/bank/run/__tests__/route.test.ts b/app/api/reconciliation/bank/run/__tests__/route.test.ts index 6fe98c93..2034fab8 100644 --- a/app/api/reconciliation/bank/run/__tests__/route.test.ts +++ b/app/api/reconciliation/bank/run/__tests__/route.test.ts @@ -31,6 +31,12 @@ vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() })) const runReconciliationMock = vi.fn() vi.mock('@/lib/reconciliation/bank-reconciliation', () => ({ runReconciliation: (...args: unknown[]) => runReconciliationMock(...args), + DEFAULT_UNATTENDED_CONFIDENCE_THRESHOLD: 0.9, +})) + +const sweepMock = vi.fn() +vi.mock('@/lib/reconciliation/unattended-sweep', () => ({ + runUnattendedReconciliationSweep: (...args: unknown[]) => sweepMock(...args), })) import { POST } from '../route' @@ -135,7 +141,11 @@ describe('POST /api/reconciliation/bank/run', () => { ) }) - it('omits the confidence threshold when the client does not send one', async () => { + it('defaults a no-selection apply to the 0.9 unattended floor when the client sends no threshold', async () => { + // Merged semantics (#1571 x bank-and-sie-match): an explicit + // confidence_threshold always wins; WITHOUT one, an apply with no + // selected_matches is effectively unattended, so it floors at 0.9 and + // persists the review band instead of auto-committing fuzzy matches. // cash_accounts lookup: no row, '1930' default is exempt. enqueue({ data: null }) @@ -150,7 +160,7 @@ describe('POST /api/reconciliation/bank/run', () => { supabase, 'company-1', 'user-1', - expect.objectContaining({ confidenceThreshold: undefined }), + expect.objectContaining({ confidenceThreshold: 0.9, persistSuggestions: true }), ) }) @@ -211,7 +221,117 @@ describe('POST /api/reconciliation/bank/run', () => { supabase, 'company-1', 'user-1', - expect.objectContaining({ accountNumber: '1930', currency: 'SEK', dryRun: false }), + // A no-selection apply run persists the review band as suggestions + // behind the unattended confidence floor ("Kör matchning igen"). + expect.objectContaining({ + accountNumber: '1930', + currency: 'SEK', + dryRun: false, + confidenceThreshold: 0.9, + persistSuggestions: true, + }), ) }) + + it('keeps the legacy no-threshold behavior for a reviewed selected_matches apply', async () => { + // cash_accounts lookup: no row, '1930' exempt. + enqueue({ data: null }) + runReconciliationMock.mockResolvedValue({ + matches: [], + applied: 0, + errors: 0, + skippedBelowThreshold: 0, + suggested: 0, + candidates: 0, + }) + + const request = createMockRequest('/api/reconciliation/bank/run', { + method: 'POST', + body: { + selected_matches: [ + { + transaction_id: '11111111-1111-4111-8111-111111111111', + journal_entry_id: '22222222-2222-4222-8222-222222222222', + }, + ], + }, + }) + + const response = await POST(request, emptyParams) + expect(response.status).toBe(200) + + const options = runReconciliationMock.mock.calls[0][3] as Record + expect(options.applyOnly).toHaveLength(1) + // The user already reviewed these pairs in the dry-run preview: no floor, + // no suggestion persistence. + expect(options.confidenceThreshold).toBeUndefined() + expect(options.persistSuggestions).toBeUndefined() + }) + + it('routes all_accounts to the per-account sweep ("Kör matchning igen")', async () => { + sweepMock.mockResolvedValue({ + accounts: [ + { accountNumber: '1930', applied: 3, suggested: 1 }, + { accountNumber: '1931', applied: 1, suggested: 0 }, + ], + applied: 4, + errors: 0, + skippedBelowThreshold: 1, + suggested: 1, + unmatched: 2, + }) + + const request = createMockRequest('/api/reconciliation/bank/run', { + method: 'POST', + body: { all_accounts: true }, + }) + + const response = await POST(request, emptyParams) + const { status, body } = await parseJsonResponse<{ + data: { applied: number; suggested: number; unmatched: number } + }>(response) + + expect(status).toBe(200) + expect(body.data.applied).toBe(4) + expect(body.data.suggested).toBe(1) + expect(body.data.unmatched).toBe(2) + expect(sweepMock).toHaveBeenCalledWith(supabase, 'company-1', 'user-1', { + dateFrom: undefined, + dateTo: undefined, + }) + expect(runReconciliationMock).not.toHaveBeenCalled() + }) + + it('rejects all_accounts combined with dry_run: the sweep has no preview form and must never apply on a requested preview', async () => { + const request = createMockRequest('/api/reconciliation/bank/run', { + method: 'POST', + body: { all_accounts: true, dry_run: true }, + }) + + const response = await POST(request, emptyParams) + expect(response.status).toBe(400) + expect(sweepMock).not.toHaveBeenCalled() + expect(runReconciliationMock).not.toHaveBeenCalled() + }) + + it('rejects all_accounts combined with account_number or selected_matches', async () => { + for (const body of [ + { all_accounts: true, account_number: '1930' }, + { all_accounts: true, confidence_threshold: 0.85 }, + { + all_accounts: true, + selected_matches: [ + { + transaction_id: '11111111-1111-4111-8111-111111111111', + journal_entry_id: '22222222-2222-4222-8222-222222222222', + }, + ], + }, + ]) { + const request = createMockRequest('/api/reconciliation/bank/run', { method: 'POST', body }) + const response = await POST(request, emptyParams) + expect(response.status).toBe(400) + } + expect(sweepMock).not.toHaveBeenCalled() + }) }) diff --git a/app/api/reconciliation/bank/run/route.ts b/app/api/reconciliation/bank/run/route.ts index fa1f81dc..9b0ffb06 100644 --- a/app/api/reconciliation/bank/run/route.ts +++ b/app/api/reconciliation/bank/run/route.ts @@ -1,7 +1,11 @@ import { NextResponse } from 'next/server' import { ensureInitialized } from '@/lib/init' import { withRouteContext } from '@/lib/api/with-route-context' -import { runReconciliation } from '@/lib/reconciliation/bank-reconciliation' +import { + runReconciliation, + DEFAULT_UNATTENDED_CONFIDENCE_THRESHOLD, +} from '@/lib/reconciliation/bank-reconciliation' +import { runUnattendedReconciliationSweep } from '@/lib/reconciliation/unattended-sweep' import { validateBody } from '@/lib/api/validate' import { RunReconciliationSchema } from '@/lib/api/schemas' @@ -12,8 +16,60 @@ export const POST = withRouteContext( async (request, { supabase, user, companyId }) => { const validation = await validateBody(request, RunReconciliationSchema) if (!validation.success) return validation.response - const { date_from, date_to, account_number, dry_run, selected_matches, confidence_threshold } = - validation.data + const { + date_from, + date_to, + account_number, + dry_run, + selected_matches, + confidence_threshold, + all_accounts, + } = validation.data + + // "Kör matchning igen": the per-account sweep across every enabled cash + // account, exactly what the unattended post-sync path runs. New >= 0.9 + // matches auto-link; the 0.75-0.89 band persists as suggestions. + // + // The sweep ALWAYS writes at its own fixed floor: there is no dry-run form + // of it, and silently ignoring dry_run (or a client-sent floor) here would + // turn a requested preview into applied links (the documented + // dry-run-gotcha P0 class). Enforce the mutual exclusion instead of just + // documenting it. + if ( + all_accounts && + (dry_run !== undefined || + account_number || + selected_matches || + confidence_threshold !== undefined) + ) { + return NextResponse.json( + { + error: + 'all_accounts kan inte kombineras med dry_run, account_number, selected_matches eller confidence_threshold', + }, + { status: 400 }, + ) + } + if (all_accounts) { + const sweep = await runUnattendedReconciliationSweep(supabase, companyId, user.id, { + dateFrom: date_from, + dateTo: date_to, + }) + return NextResponse.json({ + data: { + applied: sweep.applied, + errors: sweep.errors, + suggested: sweep.suggested, + unmatched: sweep.unmatched, + skipped_below_threshold: sweep.skippedBelowThreshold, + accounts: sweep.accounts.map((a) => ({ + account_number: a.accountNumber, + applied: a.applied, + suggested: a.suggested, + })), + }, + }) + } const accountNumber = account_number ?? '1930' @@ -52,9 +108,17 @@ export const POST = withRouteContext( transactionId: m.transaction_id, journalEntryId: m.journal_entry_id, })), - // Server-side floor on the apply path (mirrors the v1 route): pairs the - // fresh re-run scores below it are skipped, not applied. - confidenceThreshold: confidence_threshold, + // Server-side floor on the apply path. A client-sent confidence_threshold + // always wins (mirrors the v1 route: pairs the fresh re-run scores below + // it are skipped, not applied). Without one: a no-selection apply run is + // effectively unattended, so it floors at 0.9 and persists the 0.75-0.89 + // band as reviewable suggestions instead of auto-committing fuzzy + // matches; applyOnly runs keep the legacy no-floor behavior (the user + // already reviewed the pairs in the dry-run preview). Ignored on dry runs. + confidenceThreshold: + confidence_threshold ?? + (selected_matches ? undefined : DEFAULT_UNATTENDED_CONFIDENCE_THRESHOLD), + ...(selected_matches ? {} : { persistSuggestions: true }), }) return NextResponse.json({ @@ -74,6 +138,8 @@ export const POST = withRouteContext( })), applied: result.applied, errors: result.errors, + suggested: result.suggested, + skipped_below_threshold: result.skippedBelowThreshold, dry_run: dry_run ?? false, }, }) diff --git a/app/api/transactions/[id]/match-invoice/route.ts b/app/api/transactions/[id]/match-invoice/route.ts index 15cfc3a0..7cbed208 100644 --- a/app/api/transactions/[id]/match-invoice/route.ts +++ b/app/api/transactions/[id]/match-invoice/route.ts @@ -334,9 +334,26 @@ export const POST = withRouteContext( }) } + // A RECONCILIATION link (reconciliation_method set) is not a conflicting + // booking: the entry it points at is an independent verifikat (SIE import, + // salary run, manual booking) that may evidence OTHER affärshändelser, and + // reversing it wholesale as a side effect of matching one payment would be + // an over-broad rättelse (BFL 5 kap 5 §: a correction is scoped to the + // actual error). Nothing is detached HERE: the final transaction update + // below overwrites the pointer and clears reconciliation_method in the + // same write, so a failure anywhere in between leaves the existing link + // fully intact instead of orphaning the row. + const priorReconciliationLink = + transaction.journal_entry_id && transaction.reconciliation_method + ? { + journalEntryId: transaction.journal_entry_id as string, + method: transaction.reconciliation_method as string, + } + : null + // Storno conflicting auto-categorization JE before any other state change. // If storno fails, return immediately: nothing else has been modified. - if (transaction.journal_entry_id) { + if (transaction.journal_entry_id && !priorReconciliationLink) { try { await reverseEntry(supabase, companyId, user.id, transaction.journal_entry_id) @@ -730,6 +747,13 @@ export const POST = withRouteContext( journal_entry_id: journalEntryId, is_business: true, category: 'income_services', + // The invoice match supersedes any prior reconciliation link: the + // stale method label must not survive the re-pointed journal_entry_id + // (deferred detach, see the priorReconciliationLink block above). + // Unconditional literal on purpose: null is already the value on every + // non-reconciliation-linked row, and a literal payload keeps the + // phantom-column scanner able to verify the column set. + reconciliation_method: null, }) .eq('id', transactionId) @@ -738,6 +762,20 @@ export const POST = withRouteContext( return errorResponseFromCode('MATCH_INVOICE_LINK_TX_FAILED', txLog, { requestId }) } + // The deferred detach committed with the update above: record the release + // of the prior reconciliation link so the append-only trail shows the full + // transition (behandlingshistorik, BFNAR 2013:2 kap 8). + if (priorReconciliationLink) { + await logMatchEvent(supabase, user.id, transactionId, 'unmatched', { + invoiceId: invoice_id, + previousState: { + journal_entry_id: priorReconciliationLink.journalEntryId, + reconciliation_method: priorReconciliationLink.method, + }, + newState: { journal_entry_id: journalEntryId, reconciliation_method: null }, + }) + } + logMatchEvent(supabase, user.id, transactionId, 'matched', { invoiceId: invoice_id, matchConfidence: 1.0, diff --git a/app/api/v1/companies/[companyId]/transactions/[id]/match-invoice/route.ts b/app/api/v1/companies/[companyId]/transactions/[id]/match-invoice/route.ts index e8e61070..4b7d489d 100644 --- a/app/api/v1/companies/[companyId]/transactions/[id]/match-invoice/route.ts +++ b/app/api/v1/companies/[companyId]/transactions/[id]/match-invoice/route.ts @@ -412,7 +412,21 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string const { newPaidAmount, newRemaining, isFullyPaid, newStatus } = payment.plan const paidAt = isFullyPaid ? paidAtFromDate(transaction.date) : null - if (transaction.journal_entry_id) { + // A RECONCILIATION link (reconciliation_method set) is not a conflicting + // booking: the entry is an independent verifikat that may evidence OTHER + // affärshändelser; reversing it wholesale would be an over-broad rättelse + // (BFL 5 kap 5 §). Nothing is detached here: the final transaction update + // overwrites the pointer and clears reconciliation_method in the same + // write, so a failure in between leaves the existing link intact. + const priorReconciliationLink = + transaction.journal_entry_id && transaction.reconciliation_method + ? { + journalEntryId: transaction.journal_entry_id as string, + method: transaction.reconciliation_method as string, + } + : null + + if (transaction.journal_entry_id && !priorReconciliationLink) { try { await reverseEntry(ctx.supabase, ctx.companyId!, ctx.userId, transaction.journal_entry_id) const { error: clearErr } = await ctx.supabase @@ -762,6 +776,10 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string potential_invoice_id: null, journal_entry_id: journalEntryId, is_business: true, + // The invoice match supersedes any prior reconciliation link (deferred + // detach, see the priorReconciliationLink block above). Unconditional: + // null is already the value on every non-reconciliation-linked row. + reconciliation_method: null, } if (existingTxCategory) txUpdate.category = existingTxCategory @@ -777,6 +795,19 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string }) } + // Record the release of the prior reconciliation link now that the + // re-point has committed (behandlingshistorik, BFNAR 2013:2 kap 8). + if (priorReconciliationLink) { + await logMatchEvent(ctx.supabase, ctx.userId, txId, 'unmatched', { + invoiceId: invoice_id, + previousState: { + journal_entry_id: priorReconciliationLink.journalEntryId, + reconciliation_method: priorReconciliationLink.method, + }, + newState: { journal_entry_id: journalEntryId, reconciliation_method: null }, + }) + } + logMatchEvent(ctx.supabase, ctx.userId, txId, 'matched', { invoiceId: invoice_id, matchConfidence: 1.0, diff --git a/app/api/v1/companies/[companyId]/transactions/[id]/match-supplier-invoice/route.ts b/app/api/v1/companies/[companyId]/transactions/[id]/match-supplier-invoice/route.ts index 9a336916..8b647ab9 100644 --- a/app/api/v1/companies/[companyId]/transactions/[id]/match-supplier-invoice/route.ts +++ b/app/api/v1/companies/[companyId]/transactions/[id]/match-supplier-invoice/route.ts @@ -244,7 +244,21 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string // 2440/1930 supplier-invoice payment entry: two verifikationer for // one affärshändelse violates BFL 5 kap 6 §. If storno fails, abort // before any further state change. - if (transaction.journal_entry_id) { + // A RECONCILIATION link (reconciliation_method set) is not a conflicting + // booking: the entry is an independent verifikat that may evidence OTHER + // affärshändelser; reversing it wholesale would be an over-broad rättelse + // (BFL 5 kap 5 §). Nothing is detached here: the final transaction update + // overwrites the pointer and clears reconciliation_method in the same + // write, so a failure in between leaves the existing link intact. + const priorReconciliationLink = + transaction.journal_entry_id && transaction.reconciliation_method + ? { + journalEntryId: transaction.journal_entry_id as string, + method: transaction.reconciliation_method as string, + } + : null + + if (transaction.journal_entry_id && !priorReconciliationLink) { try { await reverseEntry( ctx.supabase, @@ -508,6 +522,12 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string potential_supplier_invoice_id: null, journal_entry_id: journalEntryId, is_business: true, + // The supplier-invoice match supersedes any prior reconciliation link + // (deferred detach, see the priorReconciliationLink block above). + // Unconditional literal on purpose: null is already the value on every + // non-reconciliation-linked row, and a literal payload keeps the + // phantom-column scanner able to verify the column set. + reconciliation_method: null, }) .eq('id', txId) .eq('company_id', ctx.companyId!) @@ -517,6 +537,19 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string }) } + // Record the release of the prior reconciliation link now that the + // re-point has committed (behandlingshistorik, BFNAR 2013:2 kap 8). + if (priorReconciliationLink) { + await logMatchEvent(ctx.supabase, ctx.userId, txId, 'unmatched', { + supplierInvoiceId: supplier_invoice_id, + previousState: { + journal_entry_id: priorReconciliationLink.journalEntryId, + reconciliation_method: priorReconciliationLink.method, + }, + newState: { journal_entry_id: journalEntryId, reconciliation_method: null }, + }) + } + // Propagate a document pinned to the transaction onto the payment // verifikat, mirroring the dashboard route (BFL 5 kap 6 §). Guarded to // unlinked current-version docs only: a doc already serving another diff --git a/components/dashboard/DashboardContent.tsx b/components/dashboard/DashboardContent.tsx index b99be018..6aca4670 100644 --- a/components/dashboard/DashboardContent.tsx +++ b/components/dashboard/DashboardContent.tsx @@ -57,6 +57,9 @@ interface DashboardContentProps { * started" instead of a false "all caught up". */ emptyLedger?: boolean + /** Latest SIE reconciliation-sweep outcome, for the checklist's bank step + * ("X matchade, Y att granska"). Null when no sweep has run. */ + sieSweep?: { auto_linked: number; suggested: number; unmatched: number; errors: number } | null } /** @@ -79,6 +82,7 @@ export default function DashboardContent({ agentBuilt = true, vatLine = null, emptyLedger = false, + sieSweep = null, }: DashboardContentProps) { const t = useTranslations('dashboard') const hasAi = useCapability(CAPABILITY.ai) @@ -137,6 +141,7 @@ export default function DashboardContent({ hasInboxItems={!!onboardingProgress?.hasInboxItems} hasAgentBuilt={agentBuilt} vatLine={vatLine} + sieSweep={sieSweep} /> {/* Build-assistant hero: shown only until the company has a verified diff --git a/components/import/ImportResultStep.tsx b/components/import/ImportResultStep.tsx index d20365d9..5f5f319c 100644 --- a/components/import/ImportResultStep.tsx +++ b/components/import/ImportResultStep.tsx @@ -114,13 +114,24 @@ export default function ImportResultStep({ bridge quiets down to the plain way onward there. */} {!isSandbox &&

{t('reveal_bridge')}

}
- {!isSandbox && ( + {/* Both migrator paths: PSD2 covers recent history (banks + cap the window around 90 days), CSV upload reaches the + older period the SIE file covers. Either way the + overlap is matched against the imported verifikat. + Sandbox hides only the live bank connection; file-based + import works there and its CTA stays. */} + {!isSandbox && hasBanking && ( )} + )} - {/* Next steps */} - {result.success && ( + {/* Next steps: the migrator bridge. Not instructions to read, an action + to take: fetch the bank history so it can be matched against the + verifikat that were just imported, instead of landing as anonymous + "Att bokföra" rows. */} + {result.success && !showReveal && ( - Nästa steg + {t('next_steps_title')} + {t('next_steps_match_copy')} - -
-
- 1 -
-
-

Granska importerade verifikationer

-

- Kontrollera att allt ser korrekt ut i bokföringslistan -

-
-
-
-
- 2 -
-
-

Verifiera balanserna

-

- Jämför huvudboken med din tidigare bokföring -

-
-
-
-
- 3 -
-
-

Fortsätt med ny bokföring

-

- Nu kan du börja lägga till nya transaktioner -

-
+ +
+ {/* Sandbox hides only the live bank connection; file-based + import works there and its CTA stays. */} + {!isSandbox && hasBanking && ( + + )} +
+

{t('next_steps_window_hint')}

)} diff --git a/components/onboarding/NewUserChecklist.tsx b/components/onboarding/NewUserChecklist.tsx index aa4e37bb..2d5d06e2 100644 --- a/components/onboarding/NewUserChecklist.tsx +++ b/components/onboarding/NewUserChecklist.tsx @@ -29,6 +29,12 @@ interface NewUserChecklistProps { hasAgentBuilt?: boolean /** Personalized VAT-deadline line for the Skatteverket step (null = say nothing). */ vatLine?: VatDeadlineLine + /** Latest SIE reconciliation-sweep outcome: surfaces "X matchade, Y att + * granska" on the bank step so a migrator sees what the sweep did with + * their history. Null = no sweep has run, say nothing. A sweep with + * errors > 0 was incomplete (a whole account may have been skipped), so it + * also says nothing rather than presenting partial numbers as the result. */ + sieSweep?: { auto_linked: number; suggested: number; unmatched: number; errors: number } | null } /** @@ -69,6 +75,7 @@ export default function NewUserChecklist({ hasInboxItems = false, hasAgentBuilt = false, vatLine = null, + sieSweep = null, }: NewUserChecklistProps) { const t = useTranslations('initial_setup') const router = useRouter() @@ -292,6 +299,22 @@ export default function NewUserChecklist({ ) : undefined } + doneNote={ + step2Done && + sieSweep && + sieSweep.errors === 0 && + (sieSweep.auto_linked > 0 || sieSweep.suggested > 0) ? ( + + {t('step_bank_sweep_note', { + matched: sieSweep.auto_linked, + toReview: sieSweep.suggested, + })} + + ) : undefined + } > {t('step_bank_description')} @@ -398,6 +421,7 @@ function Step({ badge, action, marks, + doneNote, last = false, children, }: { @@ -408,6 +432,10 @@ function Step({ badge?: string action?: (variant: 'default' | 'outline') => React.ReactNode marks?: React.ReactNode + /** Small note rendered next to the title once the step is DONE: the one + * exception to "done steps collapse to their title" (e.g. the bank step's + * sweep outcome, which is the payoff the migrator is waiting for). */ + doneNote?: React.ReactNode last?: boolean children: React.ReactNode }) { @@ -455,6 +483,9 @@ function Step({ )}
{!done && action?.(open ? 'default' : 'outline')} + {done && doneNote && ( + {doneNote} + )}
{open && (
diff --git a/components/transactions/SuggestionReviewList.tsx b/components/transactions/SuggestionReviewList.tsx new file mode 100644 index 00000000..99c72ba2 --- /dev/null +++ b/components/transactions/SuggestionReviewList.tsx @@ -0,0 +1,182 @@ +'use client' + +import { useState } from 'react' +import { useTranslations } from 'next-intl' +import { MoreHorizontal, Loader2 } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' +import { EmptyState } from '@/components/ui/empty-state' +import { TH_CLASS, TD_CLASS } from '@/components/ui/dry-table' +import { formatCurrency, formatDate, cn } from '@/lib/utils' +import type { TransactionWithInvoice } from './transaction-types' + +/** + * "Granska migrerad historik": the review surface for journal-entry match + * suggestions the reconciliation sweep persisted (the 0.75-0.89 band). Each + * row shows the bank transaction beside its suggested verifikat; confirming + * goes through the server-side revalidating bulk endpoint, so a stale pair + * degrades to a reported skip, never a wrong link. Per-row fallbacks: open the + * match dialog to pick another verifikat, or reject the suggestion (the row + * returns to the ordinary "Att bokföra" flow as backstop). + */ +interface SuggestionReviewListProps { + items: TransactionWithInvoice[] + /** Bulk/single confirm: resolves when the API call and list refresh are done. */ + onConfirm: (transactionIds: string[]) => Promise + onReject: (transactionIds: string[]) => Promise + onOpenMatchVoucher: (tx: TransactionWithInvoice) => void + onRerunMatching: () => Promise + rerunning: boolean +} + +export function SuggestionReviewList({ + items, + onConfirm, + onReject, + onOpenMatchVoucher, + onRerunMatching, + rerunning, +}: SuggestionReviewListProps) { + const t = useTranslations('tx_review') + const [busyIds, setBusyIds] = useState>(new Set()) + const [bulkBusy, setBulkBusy] = useState(false) + + const runRows = async (ids: string[], action: (ids: string[]) => Promise) => { + setBusyIds((prev) => new Set([...prev, ...ids])) + try { + await action(ids) + } finally { + setBusyIds((prev) => { + const next = new Set(prev) + for (const id of ids) next.delete(id) + return next + }) + } + } + + const confirmAll = async () => { + setBulkBusy(true) + try { + await runRows(items.map((i) => i.id), onConfirm) + } finally { + setBulkBusy(false) + } + } + + return ( +
+
+

+ {t('intro', { count: items.length })} +

+
+ + +
+
+ + {items.length === 0 ? ( + + ) : ( +
+ + + + + + + + + + + {items.map((tx) => { + const busy = busyIds.has(tx.id) + const voucher = tx.potential_voucher + return ( + + + + + + + + ) + })} + +
{t('th_date')}{t('th_description')}{t('th_amount')}{t('th_suggestion')} +
+ {formatDate(tx.date)} + + {tx.description} + + {formatCurrency(tx.amount, tx.currency)} + + {voucher ? ( + + {voucher.voucher_series}-{voucher.voucher_number} + + {' · '} + {formatDate(voucher.entry_date)} + {typeof tx.potential_match_confidence === 'number' && ( + <> {' · '}{Math.round(tx.potential_match_confidence * 100)} % + )} + + + ) : ( + {t('suggestion_gone')} + )} + +
+ + + + + + + onOpenMatchVoucher(tx)}> + {t('open_match_dialog')} + + void runRows([tx.id], onReject)}> + {t('reject')} + + + +
+
+
+ )} +
+ ) +} diff --git a/components/transactions/TransactionInboxCard.tsx b/components/transactions/TransactionInboxCard.tsx index 0cdd6afb..5e5188df 100644 --- a/components/transactions/TransactionInboxCard.tsx +++ b/components/transactions/TransactionInboxCard.tsx @@ -82,6 +82,11 @@ interface TransactionInboxCardProps { * gates the move action, which is pointless with a single account. */ cashAccounts?: CashAccount[] onToggleSelect: (id: string) => void + /** End date of the company's completed SIE-import coverage. Rows on or + * before it are pre-migration history: they most likely correspond to an + * already-imported verifikat, so the row carries a quiet marker steering + * toward matching rather than re-booking. */ + preMigrationCutoff?: string | null } /** @@ -109,6 +114,7 @@ export default function TransactionInboxCard({ onMoveCashAccount, cashAccounts, onToggleSelect, + preMigrationCutoff = null, }: TransactionInboxCardProps) { const t = useTranslations('tx_inbox_card') const tMethod = useTranslations('tx_method') @@ -299,6 +305,13 @@ export default function TransactionInboxCard({ Möjlig 1930↔1630 )} + {/* Quiet pre-migration marker (muted text, not a chip: it is + context, not an exception state). ISO dates compare lexically. */} + {preMigrationCutoff && transaction.date <= preMigrationCutoff && ( + + {t('pre_migration_marker')} + + )} '), bank rows not yet tied to a diff --git a/extensions/general/enable-banking/components/AccountPickerDialog.tsx b/extensions/general/enable-banking/components/AccountPickerDialog.tsx index 76ee6b16..6bffaa8d 100644 --- a/extensions/general/enable-banking/components/AccountPickerDialog.tsx +++ b/extensions/general/enable-banking/components/AccountPickerDialog.tsx @@ -95,6 +95,8 @@ export function AccountPickerDialog({ // user must see why and be able to correct the picks. const [saveError, setSaveError] = useState(null) const [lastBookedDate, setLastBookedDate] = useState(null) + // Earliest completed SIE import coverage start: present = migrator flow. + const [sieCoverageStart, setSieCoverageStart] = useState(null) const [chartAccounts, setChartAccounts] = useState([]) const [chartError, setChartError] = useState(false) const [ledgerByUid, setLedgerByUid] = useState>({}) @@ -203,23 +205,43 @@ export function AccountPickerDialog({ // fiscal period's end, which can lie months past the last actually booked // transaction and would make the user skip everything unbooked in between. // Only matters on the initial activation flow: selection edits don't re-run sync. + // + // Alongside it: the earliest completed SIE import's coverage start. For a + // migrator the right move is the OPPOSITE of skipping the booked overlap: + // fetch the whole period and let the post-sync sweep match bank rows against + // the imported verifikat. The nudge below flips accordingly. useEffect(() => { if (!open || !isInitialSelection || !company?.id) { setLastBookedDate(null) + setSieCoverageStart(null) return } let cancelled = false ;(async () => { - const { data } = await supabase - .from('journal_entries') - .select('entry_date') - .eq('company_id', company.id) - .eq('status', 'posted') - .order('entry_date', { ascending: false }) - .limit(1) - .maybeSingle() + const [entryRes, sieRes] = await Promise.all([ + supabase + .from('journal_entries') + .select('entry_date') + .eq('company_id', company.id) + .eq('status', 'posted') + .order('entry_date', { ascending: false }) + .limit(1) + .maybeSingle(), + supabase + .from('sie_imports') + .select('fiscal_year_start') + .eq('company_id', company.id) + .eq('status', 'completed') + .not('fiscal_year_start', 'is', null) + .order('fiscal_year_start', { ascending: true }) + .limit(1) + .maybeSingle(), + ]) if (cancelled) return - setLastBookedDate((data as { entry_date?: string } | null)?.entry_date || null) + setLastBookedDate((entryRes.data as { entry_date?: string } | null)?.entry_date || null) + setSieCoverageStart( + (sieRes.data as { fiscal_year_start?: string } | null)?.fiscal_year_start || null, + ) })() return () => { cancelled = true } }, [open, isInitialSelection, company?.id, supabase]) @@ -588,7 +610,65 @@ export function AccountPickerDialog({

- {bookedCoverage && ( + {sieCoverageStart ? ( +
+ {/* Migrator flow: a completed SIE import exists, so the booked + overlap is exactly what the post-sync sweep matches bank + rows against. Pulling from the SIE year's start is the + recommended move; skipping the overlap (the non-migrator + nudge) would leave the imported verifikat unreconciled. */} +
+

+ Du har importerat bokföring. Hämta bankhistorik från{' '} + {sieCoverageStart}{' '} + (importens början) så matchar vi transaktionerna automatiskt mot din importerade + bokföring i stället för att bokföra dem igen. +

+ +
+ {daysBetween(sieCoverageStart) > 90 && ( +

+ De flesta banker lämnar bara ut ca 90 dagars historik via bankkopplingen. Når + hämtningen inte hela vägen tillbaka kan du ladda upp kontoutdrag (CSV) under{' '} + Importera för den äldre perioden, matchningen + fungerar likadant. +

+ )} + {bookedCoverage && ( +

+ Vill du ändå hoppa över det som redan är bokfört kan du{' '} + {' '} + i stället. +

+ )} +
+ ) : bookedCoverage && (
{/* Stated as a fact with an opt-in shortcut, not as "vi föreslår": the selected default below is the fiscal-year diff --git a/extensions/general/enable-banking/index.ts b/extensions/general/enable-banking/index.ts index 846f55cd..eb1ba34d 100644 --- a/extensions/general/enable-banking/index.ts +++ b/extensions/general/enable-banking/index.ts @@ -13,6 +13,10 @@ import { } from './lib/api-client' import { syncAccountTransactions } from './lib/sync' import { findReusableSessions, countLiveSiblings } from './lib/session-sharing' +import { + runUnattendedReconciliationSweep, + toSweepSummary, +} from '@/lib/reconciliation/unattended-sweep' import { runReconciliation, DEFAULT_UNATTENDED_CONFIDENCE_THRESHOLD, @@ -762,22 +766,38 @@ export const enableBankingExtension: Extension = { // When SIE overlap is detected, run a batch reconciliation sweep. // The greedy algorithm considers all candidates globally (highest- // confidence first) and catches matches the inline per-transaction - // pass may have missed due to processing order. + // pass may have missed due to processing order. One scoped run per + // enabled cash account (issue #1298): the pooled run could persist a + // cross-account journal_entry_id. // Skip for viewers: reconciliation updates transactions which viewers cannot do. if (sieOverlap && totalImported > 0 && !isViewer) { try { - const reconResult = await runReconciliation(supabase, companyId, user.id, { - dateFrom: fromDate, - dateTo: toDate, - // This sweep applies without a human reviewing a dry-run, so - // never commit low-confidence (fuzzy / date-range) matches. - confidenceThreshold: DEFAULT_UNATTENDED_CONFIDENCE_THRESHOLD, - }) + const reconResult = await runUnattendedReconciliationSweep( + supabase, + companyId, + user.id, + { dateFrom: fromDate, dateTo: toDate }, + ) + // Stamp the outcome so the UI can render "Vi matchade X av Y" and + // the review surface knows there is something to granska. + await supabase + .from('bank_connections') + .update({ + last_sie_sweep: toSweepSummary(reconResult, { + dateFrom: fromDate, + dateTo: toDate, + }), + }) + .eq('id', connection.id) if (reconResult.applied > 0 || reconResult.skippedBelowThreshold > 0) { log.info('Post-sync batch reconciliation matched additional transactions', { applied: reconResult.applied, skippedBelowThreshold: reconResult.skippedBelowThreshold, - total: reconResult.matches.length, + accounts: reconResult.accounts.map((a) => ({ + accountNumber: a.accountNumber, + applied: a.applied, + skippedBelowThreshold: a.skippedBelowThreshold, + })), }) } } catch { @@ -1483,6 +1503,9 @@ export const enableBankingExtension: Extension = { // Unattended run: nobody reviews a dry-run first, so never // commit low-confidence (fuzzy / date-range) matches. confidenceThreshold: DEFAULT_UNATTENDED_CONFIDENCE_THRESHOLD, + // ...but don't DROP them either: the 0.75-0.89 band feeds + // the "Granska förslag" review surface. + persistSuggestions: true, }) totalAutoMatched += reconResult.applied if (reconResult.applied > 0 || reconResult.skippedBelowThreshold > 0) { diff --git a/lib/api/schemas.ts b/lib/api/schemas.ts index b590c81b..093aef43 100644 --- a/lib/api/schemas.ts +++ b/lib/api/schemas.ts @@ -2207,6 +2207,13 @@ export const MarkOpeningBalanceSchema = z.object({ export const RunReconciliationSchema = z.object({ date_from: isoDate.optional(), date_to: isoDate.optional(), + // Run the per-cash-account unattended sweep over every enabled cash account + // ("Kör matchning igen" in the review surface) instead of one account. The + // sweep always applies at the unattended threshold and persists suggestions; + // there is no dry-run form. The route REJECTS (400) any combination with + // dry_run, account_number or selected_matches rather than silently ignoring + // them: a request that asked for a preview must never apply writes. + all_accounts: z.boolean().optional(), // BAS settlement account to reconcile against (e.g. '1930', '1932'). Defaults // to '1930' server-side so existing clients stay correct. account_number: accountNumber.optional(), @@ -2231,6 +2238,14 @@ export const RunReconciliationSchema = z.object({ confidence_threshold: z.number().min(0).max(1).optional(), }) +// Confirm or reject persisted journal-entry match suggestions +// (transactions.potential_journal_entry_id). Each pair is revalidated +// server-side at confirm time; stale pairs are skipped, never failing the batch. +export const ConfirmJeSuggestionsSchema = z.object({ + transaction_ids: z.array(uuid).min(1).max(500), + action: z.enum(['confirm', 'reject']), +}) + // ============================================================ // Report query schemas // ============================================================ diff --git a/lib/reconciliation/__tests__/bank-reconciliation.test.ts b/lib/reconciliation/__tests__/bank-reconciliation.test.ts index 4c9722ee..7486a034 100644 --- a/lib/reconciliation/__tests__/bank-reconciliation.test.ts +++ b/lib/reconciliation/__tests__/bank-reconciliation.test.ts @@ -605,6 +605,59 @@ describe('runReconciliation', () => { expect(result.errors).toBe(0) }) + it('persists the below-threshold band as suggestions when persistSuggestions is set', async () => { + const { supabase, enqueue } = createQueueMockSupabase() + + // Fuzzy match: amount off by 1 öre on the exact date → 0.75 confidence, + // below the 0.9 unattended floor. + const tx = makeTransaction({ id: 'tx-1', amount: 1000.01, date: '2024-06-15', currency: 'SEK' }) + const glLine: UnlinkedGLLine = makeGLLine({ + line_id: 'line-1', + journal_entry_id: 'je-1', + debit_amount: 1000, + entry_date: '2024-06-15', + }) + + enqueue({ data: [glLine] }) // RPC: GL lines + enqueue({ data: [tx] }) // transactions + enqueue({ data: [{ id: 'tx-1' }] }) // suggestion update .select('id') + + const result = await runReconciliation(supabase as never, 'company-1', 'user-1', { + confidenceThreshold: 0.9, + persistSuggestions: true, + }) + + expect(result.applied).toBe(0) + expect(result.skippedBelowThreshold).toBe(1) + expect(result.suggested).toBe(1) + expect(result.candidates).toBe(1) + }) + + it('does not count a suggestion whose optimistic-lock update matched zero rows', async () => { + const { supabase, enqueue } = createQueueMockSupabase() + + const tx = makeTransaction({ id: 'tx-1', amount: 1000.01, date: '2024-06-15', currency: 'SEK' }) + const glLine: UnlinkedGLLine = makeGLLine({ + line_id: 'line-1', + journal_entry_id: 'je-1', + debit_amount: 1000, + entry_date: '2024-06-15', + }) + + enqueue({ data: [glLine] }) + enqueue({ data: [tx] }) + // A concurrent writer booked the row: .is('journal_entry_id', null) → 0 rows. + enqueue({ data: [] }) + + const result = await runReconciliation(supabase as never, 'company-1', 'user-1', { + confidenceThreshold: 0.9, + persistSuggestions: true, + }) + + expect(result.suggested).toBe(0) + expect(result.skippedBelowThreshold).toBe(1) + }) + it('counts a conflicted apply (0 rows updated) as an error, not applied', async () => { const { supabase, enqueue } = createQueueMockSupabase() diff --git a/lib/reconciliation/__tests__/suggestions.test.ts b/lib/reconciliation/__tests__/suggestions.test.ts new file mode 100644 index 00000000..5ed2ed5c --- /dev/null +++ b/lib/reconciliation/__tests__/suggestions.test.ts @@ -0,0 +1,280 @@ +/** + * Tests for confirm/reject of persisted journal-entry match suggestions. + * + * The contract under test: every pair is revalidated server-side at confirm + * time (row state, voucher consumption, and the voucher's bank-leg amount and + * direction), stale pairs are skipped (never failing the batch), and a + * verifikat cannot be consumed twice within one bulk confirm. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +const { manualLinkMock } = vi.hoisted(() => ({ manualLinkMock: vi.fn() })) + +vi.mock('../bank-reconciliation', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, manualLink: manualLinkMock } +}) + +const { logMatchEventMock } = vi.hoisted(() => ({ logMatchEventMock: vi.fn() })) +vi.mock('@/lib/invoices/match-log', () => ({ + logMatchEvent: (...args: unknown[]) => logMatchEventMock(...args), +})) + +import { + confirmJournalEntrySuggestions, + rejectJournalEntrySuggestions, +} from '../suggestions' + +/** Queue-based chainable supabase stub, same pattern as the reconciliation + * suite: each awaited chain consumes the next queued result. */ +function createQueueMockSupabase() { + const resultQueue: { data: unknown; error: unknown }[] = [] + const enqueue = (...results: { data?: unknown; error?: unknown }[]) => { + for (const r of results) { + resultQueue.push({ data: r.data ?? null, error: r.error ?? null }) + } + } + const buildChain = (): unknown => { + const handler: ProxyHandler = { + get(_target, prop) { + if (prop === 'then') { + const next = resultQueue.shift() ?? { data: null, error: null } + return (resolve: (v: unknown) => void) => resolve(next) + } + return (..._args: unknown[]) => buildChain() + }, + } + return new Proxy({}, handler) + } + const supabase = { + from: vi.fn().mockImplementation(() => buildChain()), + rpc: vi.fn().mockImplementation(() => buildChain()), + } + return { supabase, enqueue } +} + +const TX_1 = 'tx-1' +const TX_2 = 'tx-2' +const JE_1 = 'je-1' +const CA_ID = 'ca-1' + +function suggestionRow(overrides: Record = {}) { + return { + id: TX_1, + amount: -1000, + currency: 'SEK', + journal_entry_id: null, + potential_journal_entry_id: JE_1, + potential_match_method: 'auto_date_range', + potential_match_confidence: '0.85', + cash_account_id: CA_ID, + ...overrides, + } +} + +/** A voucher bank leg agreeing with suggestionRow's -1000 (credit = money out). */ +function matchingLegs() { + return [{ debit_amount: 0, credit_amount: 1000, currency: null, amount_in_currency: null }] +} + +describe('confirmJournalEntrySuggestions', () => { + beforeEach(() => { + vi.clearAllMocks() + manualLinkMock.mockResolvedValue({ success: true }) + }) + + it('confirms a valid suggestion via manualLink against the row\'s own settlement account', async () => { + const { supabase, enqueue } = createQueueMockSupabase() + enqueue({ data: suggestionRow() }) // fetch tx + enqueue({ data: [] }) // consumers of JE_1: none + enqueue({ data: { ledger_account: '1932' } }) // cash account resolve + enqueue({ data: matchingLegs() }) // amount revalidation legs + + const result = await confirmJournalEntrySuggestions(supabase as never, 'company-1', 'user-1', [ + TX_1, + ]) + + expect(result.confirmed).toEqual([TX_1]) + expect(result.skipped).toEqual([]) + expect(manualLinkMock).toHaveBeenCalledWith(supabase, 'company-1', TX_1, JE_1, 'user-1', '1932') + expect(logMatchEventMock).toHaveBeenCalledWith( + supabase, + 'user-1', + TX_1, + 'linked_to_existing_voucher', + expect.objectContaining({ matchMethod: 'auto_date_range', matchConfidence: 0.85 }), + ) + }) + + it('skips a transaction whose suggested verifikat is already consumed', async () => { + const { supabase, enqueue } = createQueueMockSupabase() + enqueue({ data: suggestionRow() }) + enqueue({ data: [{ id: 'other-tx' }] }) // someone already settles JE_1 + + const result = await confirmJournalEntrySuggestions(supabase as never, 'company-1', 'user-1', [ + TX_1, + ]) + + expect(result.confirmed).toEqual([]) + expect(result.skipped).toEqual([{ transactionId: TX_1, reason: 'voucher_consumed' }]) + expect(manualLinkMock).not.toHaveBeenCalled() + }) + + it('never fails the batch: second row suggesting the same verifikat is skipped after the first consumes it', async () => { + const { supabase, enqueue } = createQueueMockSupabase() + // Row 1: full happy path. + enqueue({ data: suggestionRow() }) + enqueue({ data: [] }) + enqueue({ data: { ledger_account: '1930' } }) + enqueue({ data: matchingLegs() }) + // Row 2 (fetched AFTER row 1 linked): the sibling-clear trigger has wiped + // its suggestion, so it reads as no_suggestion. + enqueue({ data: suggestionRow({ id: TX_2, potential_journal_entry_id: null, potential_match_method: null, potential_match_confidence: null }) }) + + const result = await confirmJournalEntrySuggestions(supabase as never, 'company-1', 'user-1', [ + TX_1, + TX_2, + ]) + + expect(result.confirmed).toEqual([TX_1]) + expect(result.skipped).toEqual([{ transactionId: TX_2, reason: 'no_suggestion' }]) + expect(manualLinkMock).toHaveBeenCalledTimes(1) + }) + + it('reports manualLink refusals as link_failed with the service message', async () => { + const { supabase, enqueue } = createQueueMockSupabase() + enqueue({ data: suggestionRow() }) + enqueue({ data: [] }) + enqueue({ data: { ledger_account: '1930' } }) + enqueue({ data: matchingLegs() }) + manualLinkMock.mockResolvedValue({ success: false, error: 'Verifikationen är inte bokförd ännu.' }) + + const result = await confirmJournalEntrySuggestions(supabase as never, 'company-1', 'user-1', [ + TX_1, + ]) + + expect(result.confirmed).toEqual([]) + expect(result.skipped).toEqual([ + { transactionId: TX_1, reason: 'link_failed', message: 'Verifikationen är inte bokförd ännu.' }, + ]) + }) + + it('skips not-found and already-linked rows', async () => { + const { supabase, enqueue } = createQueueMockSupabase() + enqueue({ data: null }) // TX_1 not found + enqueue({ data: suggestionRow({ id: TX_2, journal_entry_id: 'je-existing' }) }) + + const result = await confirmJournalEntrySuggestions(supabase as never, 'company-1', 'user-1', [ + TX_1, + TX_2, + ]) + + expect(result.confirmed).toEqual([]) + expect(result.skipped).toEqual([ + { transactionId: TX_1, reason: 'not_found' }, + { transactionId: TX_2, reason: 'already_linked' }, + ]) + }) + + it('resolves NULL-cash_account_id rows via the PRIMARY cash account, falling back to 1930', async () => { + const { supabase, enqueue } = createQueueMockSupabase() + enqueue({ data: suggestionRow({ cash_account_id: null }) }) + enqueue({ data: [] }) + // Primary lookup: the company's primary account is 1920, so the unassigned + // row must validate against 1920 (the sweep created the suggestion under + // the primary's scope), never a hard-coded 1930. + enqueue({ data: { ledger_account: '1920' } }) + enqueue({ data: matchingLegs() }) + + await confirmJournalEntrySuggestions(supabase as never, 'company-1', 'user-1', [TX_1]) + + expect(manualLinkMock).toHaveBeenCalledWith(supabase, 'company-1', TX_1, JE_1, 'user-1', '1920') + }) + + it('falls back to 1930 when no primary cash account exists', async () => { + const { supabase, enqueue } = createQueueMockSupabase() + enqueue({ data: suggestionRow({ cash_account_id: null }) }) + enqueue({ data: [] }) + enqueue({ data: null }) // no primary row + enqueue({ data: matchingLegs() }) + + await confirmJournalEntrySuggestions(supabase as never, 'company-1', 'user-1', [TX_1]) + + expect(manualLinkMock).toHaveBeenCalledWith(supabase, 'company-1', TX_1, JE_1, 'user-1', '1930') + }) + + it('skips with amount_mismatch when the voucher bank leg no longer agrees with the transaction', async () => { + // Inline rattelse re-priced the voucher's bank leg from 1000 to 500 after + // the suggestion was computed: status stays posted, so no invalidation + // trigger fired. Confirm must refuse instead of asserting a false + // correspondence (BFL 5 kap 7 §). + const { supabase, enqueue } = createQueueMockSupabase() + enqueue({ data: suggestionRow() }) + enqueue({ data: [] }) + enqueue({ data: { ledger_account: '1930' } }) + enqueue({ data: [{ debit_amount: 0, credit_amount: 500, currency: null, amount_in_currency: null }] }) + + const result = await confirmJournalEntrySuggestions(supabase as never, 'company-1', 'user-1', [ + TX_1, + ]) + + expect(result.confirmed).toEqual([]) + expect(result.skipped).toHaveLength(1) + expect(result.skipped[0]).toMatchObject({ transactionId: TX_1, reason: 'amount_mismatch' }) + expect(manualLinkMock).not.toHaveBeenCalled() + }) + + it('skips with amount_mismatch when the direction flipped', async () => { + const { supabase, enqueue } = createQueueMockSupabase() + enqueue({ data: suggestionRow({ amount: 1000 }) }) // money IN + enqueue({ data: [] }) + enqueue({ data: { ledger_account: '1930' } }) + enqueue({ data: matchingLegs() }) // credit leg = money OUT + + const result = await confirmJournalEntrySuggestions(supabase as never, 'company-1', 'user-1', [ + TX_1, + ]) + + expect(result.skipped[0]).toMatchObject({ transactionId: TX_1, reason: 'amount_mismatch' }) + expect(manualLinkMock).not.toHaveBeenCalled() + }) +}) + +describe('rejectJournalEntrySuggestions', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('clears the suggestion and logs suggestion_cleared with the previous state', async () => { + const { supabase, enqueue } = createQueueMockSupabase() + enqueue({ data: suggestionRow() }) + enqueue({ data: null }) // clear update + + const result = await rejectJournalEntrySuggestions(supabase as never, 'company-1', 'user-1', [ + TX_1, + ]) + + expect(result.rejected).toEqual([TX_1]) + expect(logMatchEventMock).toHaveBeenCalledWith( + supabase, + 'user-1', + TX_1, + 'suggestion_cleared', + expect.objectContaining({ + previousState: expect.objectContaining({ potential_journal_entry_id: JE_1 }), + }), + ) + }) + + it('skips rows without a suggestion', async () => { + const { supabase, enqueue } = createQueueMockSupabase() + enqueue({ data: suggestionRow({ potential_journal_entry_id: null }) }) + + const result = await rejectJournalEntrySuggestions(supabase as never, 'company-1', 'user-1', [ + TX_1, + ]) + + expect(result.rejected).toEqual([]) + expect(result.skipped).toEqual([{ transactionId: TX_1, reason: 'no_suggestion' }]) + }) +}) diff --git a/lib/reconciliation/__tests__/unattended-sweep.test.ts b/lib/reconciliation/__tests__/unattended-sweep.test.ts new file mode 100644 index 00000000..711b9c62 --- /dev/null +++ b/lib/reconciliation/__tests__/unattended-sweep.test.ts @@ -0,0 +1,249 @@ +/** + * Tests for the per-cash-account unattended reconciliation sweep (issue #1298). + * + * The sweep is the shared entry point for every unattended caller (EB cron, + * manual EB sync, bank-file import). What matters here is the fan-out contract: + * one scoped runReconciliation call per enabled cash account, the legacy + * 1930/SEK fallback for companies with no cash_accounts rows, per-account + * failure isolation, and honest aggregation. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +const { runReconciliationMock } = vi.hoisted(() => ({ + runReconciliationMock: vi.fn(), +})) + +vi.mock('../bank-reconciliation', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + runReconciliation: runReconciliationMock, + } +}) + +import { runUnattendedReconciliationSweep, toSweepSummary } from '../unattended-sweep' +import { DEFAULT_UNATTENDED_CONFIDENCE_THRESHOLD } from '../bank-reconciliation' + +function emptyRunResult(overrides: Partial<{ + applied: number + errors: number + skippedBelowThreshold: number + suggested: number + candidates: number + matches: unknown[] +}> = {}) { + return { + matches: overrides.matches ?? [], + applied: overrides.applied ?? 0, + errors: overrides.errors ?? 0, + skippedBelowThreshold: overrides.skippedBelowThreshold ?? 0, + suggested: overrides.suggested ?? 0, + candidates: overrides.candidates ?? 0, + } +} + +/** Chainable stub for the cash_accounts lookup: every method returns the chain, + * awaiting it resolves the configured result. */ +function makeSupabase(result: { data: unknown; error: unknown }) { + const chain: Record = {} + const handler: ProxyHandler = { + get(_target, prop) { + if (prop === 'then') { + return (resolve: (v: unknown) => void) => resolve(result) + } + return () => new Proxy(chain, handler) + }, + } + return { + from: vi.fn().mockImplementation(() => new Proxy(chain, handler)), + } +} + +const CA_1930 = { + id: '11111111-1111-4111-8111-111111111111', + ledger_account: '1930', + currency: 'SEK', + is_primary: true, +} +const CA_1931 = { + id: '22222222-2222-4222-8222-222222222222', + ledger_account: '1931', + currency: 'SEK', + is_primary: false, +} +const CA_1932_EUR = { + id: '33333333-3333-4333-8333-333333333333', + ledger_account: '1932', + currency: 'EUR', + is_primary: false, +} + +describe('runUnattendedReconciliationSweep', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('runs once per enabled cash account with that account\'s scope', async () => { + const supabase = makeSupabase({ data: [CA_1930, CA_1931, CA_1932_EUR], error: null }) + runReconciliationMock.mockResolvedValue(emptyRunResult()) + + const result = await runUnattendedReconciliationSweep( + supabase as never, + 'company-1', + 'user-1', + { dateFrom: '2026-01-01', dateTo: '2026-06-30' }, + ) + + expect(runReconciliationMock).toHaveBeenCalledTimes(3) + + // Primary 1930: claims unassigned NULL-cash_account_id rows. + expect(runReconciliationMock).toHaveBeenNthCalledWith(1, supabase, 'company-1', 'user-1', { + dateFrom: '2026-01-01', + dateTo: '2026-06-30', + accountNumber: '1930', + currency: 'SEK', + cashAccountId: CA_1930.id, + includeUnassigned: true, + confidenceThreshold: DEFAULT_UNATTENDED_CONFIDENCE_THRESHOLD, + persistSuggestions: true, + }) + // Non-primary same-currency 1931: strict scope, never claims NULL rows. + expect(runReconciliationMock).toHaveBeenNthCalledWith(2, supabase, 'company-1', 'user-1', { + dateFrom: '2026-01-01', + dateTo: '2026-06-30', + accountNumber: '1931', + currency: 'SEK', + cashAccountId: CA_1931.id, + includeUnassigned: false, + confidenceThreshold: DEFAULT_UNATTENDED_CONFIDENCE_THRESHOLD, + persistSuggestions: true, + }) + // Foreign account reconciles in its own currency. + expect(runReconciliationMock).toHaveBeenNthCalledWith(3, supabase, 'company-1', 'user-1', { + dateFrom: '2026-01-01', + dateTo: '2026-06-30', + accountNumber: '1932', + currency: 'EUR', + cashAccountId: CA_1932_EUR.id, + includeUnassigned: false, + confidenceThreshold: DEFAULT_UNATTENDED_CONFIDENCE_THRESHOLD, + persistSuggestions: true, + }) + + expect(result.accounts).toHaveLength(3) + }) + + it('falls back to a single legacy 1930/SEK run when the company has no cash_accounts rows', async () => { + const supabase = makeSupabase({ data: [], error: null }) + runReconciliationMock.mockResolvedValue(emptyRunResult({ applied: 2 })) + + const result = await runUnattendedReconciliationSweep(supabase as never, 'company-1', 'user-1') + + expect(runReconciliationMock).toHaveBeenCalledTimes(1) + expect(runReconciliationMock).toHaveBeenCalledWith(supabase, 'company-1', 'user-1', { + dateFrom: undefined, + dateTo: undefined, + accountNumber: '1930', + currency: 'SEK', + cashAccountId: undefined, + includeUnassigned: true, + confidenceThreshold: DEFAULT_UNATTENDED_CONFIDENCE_THRESHOLD, + persistSuggestions: true, + }) + expect(result.applied).toBe(2) + expect(result.accounts[0].cashAccountId).toBeNull() + }) + + it('aggregates per-account results into sweep totals', async () => { + const supabase = makeSupabase({ data: [CA_1930, CA_1931], error: null }) + runReconciliationMock + .mockResolvedValueOnce( + emptyRunResult({ applied: 3, skippedBelowThreshold: 2, matches: [1, 2, 3, 4, 5] }), + ) + .mockResolvedValueOnce(emptyRunResult({ applied: 1, errors: 1, matches: [1, 2] })) + + const result = await runUnattendedReconciliationSweep(supabase as never, 'company-1', 'user-1') + + expect(result.applied).toBe(4) + expect(result.errors).toBe(1) + expect(result.skippedBelowThreshold).toBe(2) + expect(result.accounts[0]).toMatchObject({ accountNumber: '1930', applied: 3, proposed: 5 }) + expect(result.accounts[1]).toMatchObject({ accountNumber: '1931', applied: 1, proposed: 2 }) + }) + + it('aggregates suggested and derives unmatched from the candidate pool', async () => { + const supabase = makeSupabase({ data: [CA_1930, CA_1931], error: null }) + runReconciliationMock + .mockResolvedValueOnce( + emptyRunResult({ applied: 5, suggested: 2, candidates: 10, matches: [1, 2, 3, 4, 5, 6, 7] }), + ) + .mockResolvedValueOnce(emptyRunResult({ applied: 1, suggested: 0, candidates: 3, matches: [1] })) + + const result = await runUnattendedReconciliationSweep(supabase as never, 'company-1', 'user-1') + + expect(result.applied).toBe(6) + expect(result.suggested).toBe(2) + // 13 candidates, 6 auto-linked, 2 suggested: 5 rows left for the backstop. + expect(result.unmatched).toBe(5) + }) + + it('one account failing does not abort the others; the failure counts as one error', async () => { + const supabase = makeSupabase({ data: [CA_1930, CA_1931], error: null }) + runReconciliationMock + .mockRejectedValueOnce(new Error('transient')) + .mockResolvedValueOnce(emptyRunResult({ applied: 2, matches: [1, 2] })) + + const result = await runUnattendedReconciliationSweep(supabase as never, 'company-1', 'user-1') + + expect(runReconciliationMock).toHaveBeenCalledTimes(2) + expect(result.errors).toBe(1) + expect(result.applied).toBe(2) + expect(result.accounts[0]).toMatchObject({ accountNumber: '1930', applied: 0, errors: 1 }) + expect(result.accounts[1]).toMatchObject({ accountNumber: '1931', applied: 2, errors: 0 }) + }) + + it('throws when the cash_accounts lookup fails (never degrades to the pooled run)', async () => { + const supabase = makeSupabase({ data: null, error: { message: 'rls denied' } }) + + await expect( + runUnattendedReconciliationSweep(supabase as never, 'company-1', 'user-1'), + ).rejects.toThrow('Kunde inte hämta kassakonton') + expect(runReconciliationMock).not.toHaveBeenCalled() + }) + + it('carries errors into the stamped summary so a crashed account never reads as "all done"', async () => { + const supabase = makeSupabase({ data: [CA_1930, CA_1931], error: null }) + runReconciliationMock + .mockResolvedValueOnce(emptyRunResult({ applied: 5, candidates: 5, matches: [1, 2, 3, 4, 5] })) + // 1931 throws: its candidates never enter the pool, so unmatched: 0 + // would be a lie without the errors field. + .mockRejectedValueOnce(new Error('transient')) + + const result = await runUnattendedReconciliationSweep(supabase as never, 'company-1', 'user-1', { + dateFrom: '2026-01-01', + dateTo: '2026-06-30', + }) + const summary = toSweepSummary(result, { dateFrom: '2026-01-01', dateTo: '2026-06-30' }) + + expect(summary.auto_linked).toBe(5) + expect(summary.unmatched).toBe(0) + expect(summary.errors).toBe(1) + expect(summary.date_from).toBe('2026-01-01') + }) + + it('honors an explicit confidenceThreshold override', async () => { + const supabase = makeSupabase({ data: [CA_1930], error: null }) + runReconciliationMock.mockResolvedValue(emptyRunResult()) + + await runUnattendedReconciliationSweep(supabase as never, 'company-1', 'user-1', { + confidenceThreshold: 0.95, + }) + + expect(runReconciliationMock).toHaveBeenCalledWith( + supabase, + 'company-1', + 'user-1', + expect.objectContaining({ confidenceThreshold: 0.95 }), + ) + }) +}) diff --git a/lib/reconciliation/bank-reconciliation.ts b/lib/reconciliation/bank-reconciliation.ts index 100f687e..cf190c48 100644 --- a/lib/reconciliation/bank-reconciliation.ts +++ b/lib/reconciliation/bank-reconciliation.ts @@ -67,6 +67,19 @@ export interface ReconciliationRunResult { * for human review. Always 0 on dry runs and when no threshold was given. */ skippedBelowThreshold: number + /** + * Below-threshold matches persisted as suggestions on the transaction + * (potential_journal_entry_id + method + confidence) for the review surface. + * Always 0 unless persistSuggestions was set on a non-dry apply run with a + * confidence threshold. + */ + suggested: number + /** + * Unmatched, non-ignored transactions the run considered (the candidate pool + * on the bank side). Lets callers report "X av Y matchade" without a second + * count query. + */ + candidates: number } /** @@ -198,6 +211,18 @@ export interface ReconciliationOptions { * committed without human review. */ confidenceThreshold?: number + /** + * Persist below-threshold matches (the 0.75-0.89 band: auto_fuzzy, + * auto_date_range) onto the transaction's potential_journal_entry_id / + * potential_match_method / potential_match_confidence columns instead of + * dropping them, so the review surface can offer them for confirmation. + * Only meaningful together with confidenceThreshold on a non-dry apply run; + * ignored otherwise. Suggestions are soft data: the same optimistic + * `.is('journal_entry_id', null)` guard as the apply path, plus DB triggers + * that clear them when the row is booked/ignored or the entry is consumed + * or reversed. + */ + persistSuggestions?: boolean } /** @@ -417,6 +442,7 @@ export async function runReconciliation( includeUnassigned = true, applyOnly, confidenceThreshold, + persistSuggestions = false, } = options // Fetch unlinked GL lines via RPC @@ -451,14 +477,28 @@ export async function runReconciliation( }) if (transactions.length === 0 || glLines.length === 0) { - return { matches: [], applied: 0, errors: 0, skippedBelowThreshold: 0 } + return { + matches: [], + applied: 0, + errors: 0, + skippedBelowThreshold: 0, + suggested: 0, + candidates: transactions.length, + } } // Run greedy matching, highest confidence first let matches = greedyMatch(transactions, glLines, currency) if (dryRun) { - return { matches, applied: 0, errors: 0, skippedBelowThreshold: 0 } + return { + matches, + applied: 0, + errors: 0, + skippedBelowThreshold: 0, + suggested: 0, + candidates: transactions.length, + } } // When the caller reviewed a dry-run and ticked a subset, apply ONLY pairs @@ -477,12 +517,13 @@ export async function runReconciliation( // counted separately. This is the server-side guardrail for unattended // callers (nightly sync / cron), where nobody reviews a dry-run first. let toApply = matches - let skippedBelowThreshold = 0 + let belowThresholdMatches: ReconciliationMatch[] = [] if (confidenceThreshold !== undefined) { const floor = Math.max(0, Math.min(1, confidenceThreshold)) toApply = matches.filter((m) => m.confidence >= floor) - skippedBelowThreshold = matches.length - toApply.length + belowThresholdMatches = matches.filter((m) => m.confidence < floor) } + const skippedBelowThreshold = belowThresholdMatches.length // Apply matches let applied = 0 @@ -511,6 +552,18 @@ export async function runReconciliation( errors++ } else { applied++ + // Behandlingshistorik (BFNAR 2013:2 kap 8, BFL 7:1): every auto-applied + // link is a match event and must land in the append-only log, exactly + // like the invoice-match and confirm-suggestion paths. The bus event + // below goes to event_log (30-day TTL) and is NOT an audit record. + await logMatchEvent(supabase, userId, match.transaction.id, 'matched', { + matchConfidence: match.confidence, + matchMethod: match.method, + newState: { + journal_entry_id: match.glLine.journal_entry_id, + reconciliation_method: match.method, + }, + }) try { eventBus.emit({ type: 'transaction.reconciled', @@ -531,7 +584,51 @@ export async function runReconciliation( } } - return { matches, applied, errors, skippedBelowThreshold } + // Persist the below-threshold band as reviewable suggestions instead of + // dropping it. Same optimistic-lock guard as the apply loop: a row that got + // booked or linked between the read and this write matches zero rows, and + // the DB trigger clears any suggestion the moment a link lands, so a stale + // suggestion can never shadow a real link. + let suggested = 0 + if (persistSuggestions) { + for (const match of belowThresholdMatches) { + try { + const { data: suggestedRows, error } = await supabase + .from('transactions') + .update({ + potential_journal_entry_id: match.glLine.journal_entry_id, + potential_match_method: match.method, + potential_match_confidence: match.confidence, + }) + .eq('id', match.transaction.id) + .eq('company_id', companyId) + .is('journal_entry_id', null) + .select('id') + + if (!error && suggestedRows && suggestedRows.length > 0) { + suggested++ + // Awaited: an unawaited promise can be frozen on serverless when the + // response returns, silently dropping the audit row. + await logMatchEvent(supabase, userId, match.transaction.id, 'auto_suggested', { + matchConfidence: match.confidence, + matchMethod: match.method, + newState: { potential_journal_entry_id: match.glLine.journal_entry_id }, + }) + } + } catch { + // Suggestions are best-effort: never fail the run over one row. + } + } + } + + return { + matches, + applied, + errors, + skippedBelowThreshold, + suggested, + candidates: transactions.length, + } } // ============================================================ diff --git a/lib/reconciliation/suggestions.ts b/lib/reconciliation/suggestions.ts new file mode 100644 index 00000000..200168b2 --- /dev/null +++ b/lib/reconciliation/suggestions.ts @@ -0,0 +1,270 @@ +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 { + 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 { + 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 { + 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 { + 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 { + 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 +} diff --git a/lib/reconciliation/unattended-sweep.ts b/lib/reconciliation/unattended-sweep.ts new file mode 100644 index 00000000..5921868c --- /dev/null +++ b/lib/reconciliation/unattended-sweep.ts @@ -0,0 +1,235 @@ +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 { + 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), + } +} diff --git a/messages/en.json b/messages/en.json index fe2de335..b87a7e9e 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1451,6 +1451,7 @@ "step_books_sie": "+ SIE", "step_bank_title": "Connect the bank", "step_bank_description": "Fetch transactions automatically, or import a bank statement.", + "step_bank_sweep_note": "{matched, plural, one {# matched against the import} other {# matched against the import}}, {toReview, plural, one {# to review} other {# to review}}", "step_bank_action": "Connect the bank", "step_assistant_title": "Build your bookkeeping assistant", "step_assistant_beta": "Beta", @@ -2702,6 +2703,7 @@ "delete_aria": "Delete transaction", "edit_title_aria": "Edit title", "edited_badge": "edited", + "pre_migration_marker": "from the period before your migration", "original_name_tooltip": "Original bank name: {name}", "edit_title_dialog_title": "Edit transaction title", "edit_title_warning": "Are you sure you want to edit the title of this transaction?", @@ -3291,6 +3293,23 @@ "saving": "Saving...", "save": "Save transaction" }, + "tx_review": { + "intro": "{count, plural, one {# bank transaction matches a voucher in your imported bookkeeping. Confirm the link and nothing gets booked twice.} other {# bank transactions match vouchers in your imported bookkeeping. Confirm the links and nothing gets booked twice.}}", + "rerun": "Run matching again", + "confirm_all": "Confirm all ({count})", + "empty_title": "No suggestions to review", + "empty_description": "All match suggestions are handled. Remaining transactions are under To book.", + "th_date": "Date", + "th_description": "Description", + "th_amount": "Amount", + "th_suggestion": "Suggested voucher", + "th_actions": "Actions", + "suggestion_gone": "The suggestion is no longer valid", + "confirm": "Confirm", + "row_menu": "More actions", + "open_match_dialog": "Pick another voucher…", + "reject": "Reject the suggestion" + }, "tx_history": { "search_placeholder": "Search transactions...", "filter_all": "All", @@ -5463,6 +5482,17 @@ "skv_err_commit_failed": "draft created, not posted", "skv_err_other": "failed", "mode_all": "All", + "mode_review": "Review suggestions", + "review_attn_body": "{count, plural, one {# historical bank transaction matches} other {# historical bank transactions match}} your imported bookkeeping.", + "review_attn_cta": "Review the suggestions", + "review_confirm_failed": "Could not link the suggestions", + "review_confirm_done_title": "{count, plural, one {# transaction linked} other {# transactions linked}}", + "review_confirm_done_description": "Linked to existing vouchers. No new bookkeeping was created.", + "review_confirm_done_skipped": "{count, plural, one {# was skipped} other {# were skipped}}: the suggestion had changed since it was created.", + "review_reject_failed": "Could not reject the suggestion", + "review_rerun_failed": "Matching could not run", + "review_rerun_done_title": "Matching finished", + "review_rerun_done_description": "{applied, plural, one {# transaction was linked automatically} other {# transactions were linked automatically}}, {suggested, plural, one {# new suggestion to review} other {# new suggestions to review}}.", "footer_to_handle": "{count, plural, =0 {Nothing to handle} =1 {1 to handle} other {# to handle}}", "recon_attn": "{count, plural, one {1 unbooked bank transaction: the bank reconciliation can find automatic matches against existing vouchers.} other {# unbooked bank transactions: the bank reconciliation can find automatic matches against existing vouchers.}}", "recon_attn_action": "Preview matches" @@ -6888,7 +6918,13 @@ "reveal_skipped": "{count, plural, one {# voucher was skipped, see details below.} other {# vouchers were skipped, see details below.}}", "reveal_bridge": "The history is in place. What's missing is the present: the bank.", "reveal_cta_bank": "Connect the bank", + "reveal_cta_csv": "Upload bank statement (CSV)", "reveal_cta_open": "Open Accounted", + "next_steps_title": "Next step: fetch your bank history", + "next_steps_match_copy": "Connect your bank or upload bank statements, and we match the bank history against what you just imported.", + "next_steps_cta_bank": "Connect your bank", + "next_steps_cta_csv": "Upload bank statement (CSV)", + "next_steps_window_hint": "The bank connection fetches recent history (most banks stop around 90 days). For older periods, upload bank statements as CSV, matching works the same way.", "vat_review_title": "{count, plural, one {# account has no VAT treatment} other {# accounts have no VAT treatment}}", "vat_review_description": "Set VAT treatments on imported revenue and purchase accounts so the VAT return uses the correct boxes.", "vat_review_action": "Review VAT treatments in the chart", diff --git a/messages/sv.json b/messages/sv.json index d90c4d2a..7c749fd5 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -1451,6 +1451,7 @@ "step_books_sie": "+ SIE", "step_bank_title": "Koppla banken", "step_bank_description": "Hämta transaktioner automatiskt, eller importera ett kontoutdrag.", + "step_bank_sweep_note": "{matched, plural, one {# matchad mot importen} other {# matchade mot importen}}, {toReview, plural, one {# att granska} other {# att granska}}", "step_bank_action": "Koppla banken", "step_assistant_title": "Bygg din bokföringsassistent", "step_assistant_beta": "Beta", @@ -2702,6 +2703,7 @@ "delete_aria": "Ta bort transaktion", "edit_title_aria": "Ändra titel", "edited_badge": "redigerad", + "pre_migration_marker": "från perioden före din migrering", "original_name_tooltip": "Bankens originalnamn: {name}", "edit_title_dialog_title": "Ändra transaktionens titel", "edit_title_warning": "Är du säker på att du vill ändra titeln på den här transaktionen?", @@ -3291,6 +3293,23 @@ "saving": "Sparar...", "save": "Spara transaktion" }, + "tx_review": { + "intro": "{count, plural, one {# banktransaktion matchar ett verifikat i din importerade bokföring. Bekräfta kopplingen så bokförs ingenting dubbelt.} other {# banktransaktioner matchar verifikat i din importerade bokföring. Bekräfta kopplingarna så bokförs ingenting dubbelt.}}", + "rerun": "Kör matchning igen", + "confirm_all": "Bekräfta alla ({count})", + "empty_title": "Inga förslag att granska", + "empty_description": "Alla matchförslag är hanterade. Kvarvarande transaktioner hittar du under Att bokföra.", + "th_date": "Datum", + "th_description": "Beskrivning", + "th_amount": "Belopp", + "th_suggestion": "Föreslaget verifikat", + "th_actions": "Åtgärder", + "suggestion_gone": "Förslaget är inte längre giltigt", + "confirm": "Bekräfta", + "row_menu": "Fler åtgärder", + "open_match_dialog": "Välj annat verifikat…", + "reject": "Avvisa förslaget" + }, "tx_history": { "search_placeholder": "Sök transaktioner...", "filter_all": "Alla", @@ -5463,6 +5482,17 @@ "skv_err_commit_failed": "utkast skapat, ej bokfört", "skv_err_other": "misslyckades", "mode_all": "Alla", + "mode_review": "Granska förslag", + "review_attn_body": "{count, plural, one {# historisk banktransaktion matchar} other {# historiska banktransaktioner matchar}} din importerade bokföring.", + "review_attn_cta": "Granska förslagen", + "review_confirm_failed": "Kunde inte koppla förslagen", + "review_confirm_done_title": "{count, plural, one {# transaktion kopplad} other {# transaktioner kopplade}}", + "review_confirm_done_description": "Kopplade till befintliga verifikat. Ingen ny bokföring skapades.", + "review_confirm_done_skipped": "{count, plural, one {# hoppades över} other {# hoppades över}}: förslaget hade ändrats sedan det skapades.", + "review_reject_failed": "Kunde inte avvisa förslaget", + "review_rerun_failed": "Matchningen kunde inte köras", + "review_rerun_done_title": "Matchning klar", + "review_rerun_done_description": "{applied, plural, one {# transaktion kopplades automatiskt} other {# transaktioner kopplades automatiskt}}, {suggested, plural, one {# nytt förslag att granska} other {# nya förslag att granska}}.", "footer_to_handle": "{count, plural, =0 {Inget att hantera} =1 {1 att hantera} other {# att hantera}}", "recon_attn": "{count, plural, one {1 obokförd banktransaktion: bankavstämningen kan hitta automatiska träffar mot befintliga verifikationer.} other {# obokförda banktransaktioner: bankavstämningen kan hitta automatiska träffar mot befintliga verifikationer.}}", "recon_attn_action": "Förhandsgranska träffar" @@ -6888,7 +6918,13 @@ "reveal_skipped": "{count, plural, one {# verifikat hoppades över, se detaljer nedan.} other {# verifikat hoppades över, se detaljer nedan.}}", "reveal_bridge": "Historiken är på plats. Det som saknas är nuet: banken.", "reveal_cta_bank": "Koppla banken", + "reveal_cta_csv": "Ladda upp kontoutdrag (CSV)", "reveal_cta_open": "Öppna Accounted", + "next_steps_title": "Nästa steg: hämta bankhistoriken", + "next_steps_match_copy": "Koppla din bank eller ladda upp kontoutdrag, så matchar vi bankhistoriken mot det du just importerade.", + "next_steps_cta_bank": "Koppla din bank", + "next_steps_cta_csv": "Ladda upp kontoutdrag (CSV)", + "next_steps_window_hint": "Bankkopplingen hämtar den senaste tidens historik (de flesta banker stannar vid ca 90 dagar). För äldre perioder laddar du upp kontoutdrag som CSV, matchningen fungerar likadant.", "vat_review_title": "{count, plural, one {# konto saknar momskod} other {# konton saknar momskod}}", "vat_review_description": "Sätt momskod på importerade intäkts- och inköpskonton så att momsdeklarationen hamnar i rätt ruta.", "vat_review_action": "Granska momskoder i kontoplanen", diff --git a/supabase/migrations/20260813121000_transactions_potential_journal_entry.sql b/supabase/migrations/20260813121000_transactions_potential_journal_entry.sql new file mode 100644 index 00000000..82851f03 --- /dev/null +++ b/supabase/migrations/20260813121000_transactions_potential_journal_entry.sql @@ -0,0 +1,148 @@ +-- Migration: persisted journal-entry match suggestions on transactions +-- +-- The 4-pass bank matcher proposes matches in a 0.75-0.89 confidence band +-- (auto_fuzzy, auto_date_range) that unattended sweeps must never auto-apply. +-- Until now those proposals were computed and then dropped: the migrator who +-- imported a Fortnox SIE file and connected their bank never saw them. These +-- columns persist the band as reviewable suggestions, mirroring +-- potential_invoice_id / potential_supplier_invoice_id (one candidate per row). +-- +-- Suggestions are soft data: nullable columns, no journal writes, fully +-- reversible. Confirming one goes through the ordinary manual-link path +-- (server-side revalidation at click time); rejecting clears the columns. + +ALTER TABLE public.transactions + ADD COLUMN potential_journal_entry_id UUID + REFERENCES public.journal_entries (id) ON DELETE SET NULL, + ADD COLUMN potential_match_method TEXT, + ADD COLUMN potential_match_confidence NUMERIC(3,2); + +-- A suggestion is all-or-nothing (id + method + confidence together), and a +-- transaction that is already linked to a journal entry carries no suggestion. +-- The BEFORE-UPDATE trigger below clears the trio whenever a link lands or the +-- row is ignored, so ordinary writers never trip this. ON DELETE SET NULL on +-- the FK nulls only the id; the trigger function also re-nulls method and +-- confidence on any write, and the CHECK is written to tolerate the transient +-- id-only-null state a FK SET NULL leaves behind (method/confidence dangling +-- without an id is inert for every reader, which keys on the id). +ALTER TABLE public.transactions + ADD CONSTRAINT transactions_potential_je_check CHECK ( + potential_journal_entry_id IS NULL + OR ( + potential_match_method IS NOT NULL + AND potential_match_confidence IS NOT NULL + AND journal_entry_id IS NULL + ) + ); + +-- The review surface asks "does this company have suggestions?" and lists them; +-- suggestion rows are a small fraction of transactions. +CREATE INDEX idx_transactions_potential_je + ON public.transactions (company_id) + WHERE potential_journal_entry_id IS NOT NULL; + +-- ============================================================ +-- Suggestion invalidation, transaction side +-- ============================================================ +-- Clear the suggestion whenever the transaction stops being an open row: +-- booked or linked (journal_entry_id set, by ANY path: categorization, +-- reconciliation, MCP, RPCs) or explicitly ignored. BEFORE trigger so the +-- cleared values land in the same write and the CHECK above always sees a +-- consistent row. + +CREATE OR REPLACE FUNCTION public.clear_potential_journal_entry_on_close() +RETURNS TRIGGER +LANGUAGE plpgsql +SET search_path = public +AS $$ +BEGIN + IF NEW.journal_entry_id IS NOT NULL OR NEW.is_ignored IS TRUE THEN + NEW.potential_journal_entry_id := NULL; + NEW.potential_match_method := NULL; + NEW.potential_match_confidence := NULL; + END IF; + RETURN NEW; +END; +$$; + +CREATE TRIGGER transactions_clear_potential_je + BEFORE INSERT OR UPDATE ON public.transactions + FOR EACH ROW + EXECUTE FUNCTION public.clear_potential_journal_entry_on_close(); + +-- ============================================================ +-- Suggestion invalidation, journal-entry side +-- ============================================================ +-- (a) The suggested entry is consumed by another transaction's link: other +-- rows still suggesting it would double-consume the verifikat on bulk +-- confirm, so their suggestions are cleared as soon as any link lands. +-- SECURITY DEFINER: the writer linking their own transaction may not have +-- UPDATE visibility over sibling rows under RLS, but the scope is derived +-- entirely from the row being written (company_id + journal_entry_id), +-- never from caller input. + +CREATE OR REPLACE FUNCTION public.clear_sibling_je_suggestions_on_link() +RETURNS TRIGGER +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +BEGIN + UPDATE public.transactions + SET potential_journal_entry_id = NULL, + potential_match_method = NULL, + potential_match_confidence = NULL + WHERE company_id = NEW.company_id + AND potential_journal_entry_id = NEW.journal_entry_id + AND id <> NEW.id; + RETURN NULL; +END; +$$; + +CREATE TRIGGER transactions_clear_sibling_je_suggestions + AFTER UPDATE OF journal_entry_id ON public.transactions + FOR EACH ROW + WHEN (NEW.journal_entry_id IS NOT NULL + AND NEW.journal_entry_id IS DISTINCT FROM OLD.journal_entry_id) + EXECUTE FUNCTION public.clear_sibling_je_suggestions_on_link(); + +-- (b) The suggested entry is reversed via storno: it is no longer a live +-- verifikat to settle against, so pending suggestions pointing at it are +-- stale. Same SECURITY DEFINER rationale as above. + +CREATE OR REPLACE FUNCTION public.clear_je_suggestions_on_reversal() +RETURNS TRIGGER +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +BEGIN + UPDATE public.transactions + SET potential_journal_entry_id = NULL, + potential_match_method = NULL, + potential_match_confidence = NULL + WHERE company_id = NEW.company_id + AND potential_journal_entry_id = NEW.id; + RETURN NULL; +END; +$$; + +CREATE TRIGGER journal_entries_clear_je_suggestions_on_reversal + AFTER UPDATE OF status ON public.journal_entries + FOR EACH ROW + WHEN (NEW.status = 'reversed' AND OLD.status IS DISTINCT FROM 'reversed') + EXECUTE FUNCTION public.clear_je_suggestions_on_reversal(); + +-- ============================================================ +-- Sweep summaries +-- ============================================================ +-- One JSONB per surface so the UI can render "Vi matchade X av Y" without +-- recomputing: {auto_linked, suggested, unmatched, date_from, date_to, ran_at}. + +ALTER TABLE public.bank_connections + ADD COLUMN last_sie_sweep JSONB; + +ALTER TABLE public.bank_file_imports + ADD COLUMN sie_sweep JSONB; + +NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/20260813210000_payment_match_log_linked_to_existing_voucher.sql b/supabase/migrations/20260813210000_payment_match_log_linked_to_existing_voucher.sql new file mode 100644 index 00000000..47940bc9 --- /dev/null +++ b/supabase/migrations/20260813210000_payment_match_log_linked_to_existing_voucher.sql @@ -0,0 +1,40 @@ +-- Migration: widen payment_match_log.action CHECK with 'linked_to_existing_voucher' +-- +-- The code has emitted action = 'linked_to_existing_voucher' since the +-- existing-voucher link paths shipped (lib/transactions/link-journal-entry.ts, +-- lib/reconciliation/bank-reconciliation.ts / autoReconcileTransactionForLinkedVoucher), +-- but the CHECK from 20260323120000 never included the value. logMatchEvent is +-- fire-and-forget, so every such insert failed the constraint silently and the +-- link went unlogged: a BFL 7:1 audit-trail gap, since match/unmatch events are +-- rakenskapsinformation. This restores logging for existing-voucher links. +-- +-- Backfill of the silently dropped rows is NOT possible (the inserts never +-- landed anywhere); the transactions themselves still carry the link via +-- journal_entry_id + reconciliation_method. A one-off count of affected links +-- since 2026-03-23 can be estimated on prod from transactions rows with +-- reconciliation_method = 'manual' joined against the absence of a matching +-- payment_match_log row; left as an ops follow-up, not a migration concern. + +-- NOT VALID + VALIDATE: the plain ADD CONSTRAINT would scan the table under +-- an ACCESS EXCLUSIVE lock. NOT VALID makes both ALTERs brief metadata-only +-- locks, and VALIDATE scans under SHARE UPDATE EXCLUSIVE, which does not +-- block concurrent match-log inserts. The new set is a strict superset of the +-- old CHECK, so validation cannot fail on existing rows. + +ALTER TABLE public.payment_match_log + DROP CONSTRAINT payment_match_log_action_check; + +ALTER TABLE public.payment_match_log + ADD CONSTRAINT payment_match_log_action_check CHECK (action IN ( + 'matched', + 'unmatched', + 'auto_suggested', + 'suggestion_cleared', + 'storno_conflict_resolved', + 'linked_to_existing_voucher' + )) NOT VALID; + +ALTER TABLE public.payment_match_log + VALIDATE CONSTRAINT payment_match_log_action_check; + +NOTIFY pgrst, 'reload schema'; diff --git a/tests/pg/payment-match-log-actions.pg.test.ts b/tests/pg/payment-match-log-actions.pg.test.ts new file mode 100644 index 00000000..deecce88 --- /dev/null +++ b/tests/pg/payment-match-log-actions.pg.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest' +import { seedCompany, insertTransaction } from '@/tests/pg/fixtures' +import { getPool } from '@/tests/pg/setup' + +/** + * pg-real coverage for the payment_match_log action CHECK + * (20260323120000_payment_match_log.sql + + * 20260813210000_payment_match_log_linked_to_existing_voucher.sql). + * + * Locks in Gap F: the code has emitted action = 'linked_to_existing_voucher' + * since the existing-voucher link paths shipped, but the original CHECK never + * included the value, and because logMatchEvent is fire-and-forget the + * constraint violation was swallowed: every existing-voucher link went + * unlogged. This suite asserts the widened CHECK accepts the value, so a + * future rewrite of the constraint that forgets it fails here instead of + * silently reopening the audit-trail gap. + */ + +async function insertLogRow(params: { + userId: string + transactionId: string + action: string +}): Promise { + await getPool().query( + `INSERT INTO public.payment_match_log (user_id, transaction_id, action) + VALUES ($1, $2, $3)`, + [params.userId, params.transactionId, params.action], + ) +} + +describe('payment_match_log.action CHECK', () => { + it('accepts every action the code emits, including linked_to_existing_voucher', async () => { + const { userId, companyId } = await seedCompany() + const txId = await insertTransaction({ companyId, userId }) + + // The full MatchAction union from lib/invoices/match-log.ts. If a new + // action is added there, this list (and the CHECK) must grow with it. + const actions = [ + 'matched', + 'unmatched', + 'auto_suggested', + 'suggestion_cleared', + 'storno_conflict_resolved', + 'linked_to_existing_voucher', + ] + for (const action of actions) { + await expect(insertLogRow({ userId, transactionId: txId, action })).resolves.not.toThrow() + } + + const { rows } = await getPool().query( + `SELECT count(*)::int AS n FROM public.payment_match_log WHERE transaction_id = $1`, + [txId], + ) + expect(rows[0].n).toBe(actions.length) + }) + + it('still rejects unknown actions', async () => { + const { userId, companyId } = await seedCompany() + const txId = await insertTransaction({ companyId, userId }) + + await expect( + insertLogRow({ userId, transactionId: txId, action: 'not_a_real_action' }), + ).rejects.toThrow(/payment_match_log_action_check/) + }) +}) diff --git a/tests/pg/transactions-potential-journal-entry.pg.test.ts b/tests/pg/transactions-potential-journal-entry.pg.test.ts new file mode 100644 index 00000000..fee3e207 --- /dev/null +++ b/tests/pg/transactions-potential-journal-entry.pg.test.ts @@ -0,0 +1,185 @@ +import { describe, expect, it } from 'vitest' +import { seedCompany, insertTransaction, insertDraftJournalEntry } from '@/tests/pg/fixtures' +import { getPool } from '@/tests/pg/setup' + +/** + * pg-real coverage for persisted journal-entry match suggestions + * (20260813121000_transactions_potential_journal_entry.sql). + * + * Locks in: + * - the all-or-nothing CHECK on the suggestion trio, + * - self-clear on link / ignore (BEFORE trigger), + * - sibling-clear when the suggested verifikat is consumed by another + * transaction's link (the double-consume guard bulk confirm relies on), + * - clear on storno reversal of the suggested entry, + * - the sweep-summary JSONB columns. + */ + +// status: 'posted' routes to insertPostedJournalEntry, which inserts a default +// balanced 1930/3001 line pair in the same transaction: adding lines afterwards +// would trip the line-immutability trigger. +async function insertPostedEntry(params: { + userId: string + companyId: string + fiscalPeriodId: string +}): Promise { + return insertDraftJournalEntry({ ...params, status: 'posted' }) +} + +async function setSuggestion(txId: string, jeId: string): Promise { + await getPool().query( + `UPDATE public.transactions + SET potential_journal_entry_id = $2, + potential_match_method = 'auto_date_range', + potential_match_confidence = 0.85 + WHERE id = $1`, + [txId, jeId], + ) +} + +async function getSuggestion(txId: string): Promise<{ + potential_journal_entry_id: string | null + potential_match_method: string | null + potential_match_confidence: string | null +}> { + const { rows } = await getPool().query( + `SELECT potential_journal_entry_id, potential_match_method, potential_match_confidence + FROM public.transactions WHERE id = $1`, + [txId], + ) + return rows[0] +} + +describe('transactions potential_journal_entry: schema', () => { + it('accepts a full suggestion trio on an unlinked transaction', async () => { + const { userId, companyId, fiscalPeriodId } = await seedCompany() + const jeId = await insertPostedEntry({ userId, companyId, fiscalPeriodId }) + const txId = await insertTransaction({ companyId, userId }) + + await setSuggestion(txId, jeId) + + const row = await getSuggestion(txId) + expect(row.potential_journal_entry_id).toBe(jeId) + expect(row.potential_match_method).toBe('auto_date_range') + expect(Number(row.potential_match_confidence)).toBe(0.85) + }) + + it('rejects a partial trio (id without method/confidence)', async () => { + const { userId, companyId, fiscalPeriodId } = await seedCompany() + const jeId = await insertPostedEntry({ userId, companyId, fiscalPeriodId }) + const txId = await insertTransaction({ companyId, userId }) + + await expect( + getPool().query( + `UPDATE public.transactions SET potential_journal_entry_id = $2 WHERE id = $1`, + [txId, jeId], + ), + ).rejects.toThrow(/transactions_potential_je_check/) + }) +}) + +describe('transactions potential_journal_entry: self-clear trigger', () => { + it('clears the suggestion when the transaction is linked to any journal entry', async () => { + const { userId, companyId, fiscalPeriodId } = await seedCompany() + const suggestedJe = await insertPostedEntry({ userId, companyId, fiscalPeriodId }) + const otherJe = await insertPostedEntry({ userId, companyId, fiscalPeriodId }) + const txId = await insertTransaction({ companyId, userId }) + await setSuggestion(txId, suggestedJe) + + // Link the row elsewhere (any path setting journal_entry_id qualifies). + await getPool().query( + `UPDATE public.transactions SET journal_entry_id = $2 WHERE id = $1`, + [txId, otherJe], + ) + + const row = await getSuggestion(txId) + expect(row.potential_journal_entry_id).toBeNull() + expect(row.potential_match_method).toBeNull() + expect(row.potential_match_confidence).toBeNull() + }) + + it('clears the suggestion when the transaction is ignored', async () => { + const { userId, companyId, fiscalPeriodId } = await seedCompany() + const jeId = await insertPostedEntry({ userId, companyId, fiscalPeriodId }) + const txId = await insertTransaction({ companyId, userId }) + await setSuggestion(txId, jeId) + + await getPool().query(`UPDATE public.transactions SET is_ignored = true WHERE id = $1`, [txId]) + + const row = await getSuggestion(txId) + expect(row.potential_journal_entry_id).toBeNull() + }) +}) + +describe('transactions potential_journal_entry: sibling-clear on consumption', () => { + it('clears other rows suggesting a verifikat once any transaction links to it', async () => { + const { userId, companyId, fiscalPeriodId } = await seedCompany() + const jeId = await insertPostedEntry({ userId, companyId, fiscalPeriodId }) + const txA = await insertTransaction({ companyId, userId, externalId: 'ext-a' }) + const txB = await insertTransaction({ companyId, userId, externalId: 'ext-b' }) + await setSuggestion(txA, jeId) + await setSuggestion(txB, jeId) + + // txA consumes the verifikat. + await getPool().query( + `UPDATE public.transactions SET journal_entry_id = $2 WHERE id = $1`, + [txA, jeId], + ) + + // txB's suggestion is gone: bulk confirm can no longer double-consume. + const rowB = await getSuggestion(txB) + expect(rowB.potential_journal_entry_id).toBeNull() + expect(rowB.potential_match_method).toBeNull() + }) + + it('does not clear suggestions pointing at OTHER verifikat', async () => { + const { userId, companyId, fiscalPeriodId } = await seedCompany() + const je1 = await insertPostedEntry({ userId, companyId, fiscalPeriodId }) + const je2 = await insertPostedEntry({ userId, companyId, fiscalPeriodId }) + const txA = await insertTransaction({ companyId, userId, externalId: 'ext-a2' }) + const txB = await insertTransaction({ companyId, userId, externalId: 'ext-b2' }) + await setSuggestion(txA, je1) + await setSuggestion(txB, je2) + + await getPool().query( + `UPDATE public.transactions SET journal_entry_id = $2 WHERE id = $1`, + [txA, je1], + ) + + const rowB = await getSuggestion(txB) + expect(rowB.potential_journal_entry_id).toBe(je2) + }) +}) + +describe('transactions potential_journal_entry: clear on reversal', () => { + it('clears suggestions pointing at an entry that is reversed via storno', async () => { + const { userId, companyId, fiscalPeriodId } = await seedCompany() + const jeId = await insertPostedEntry({ userId, companyId, fiscalPeriodId }) + const txId = await insertTransaction({ companyId, userId }) + await setSuggestion(txId, jeId) + + // The storno path's status transition (posted -> reversed). + await getPool().query( + `UPDATE public.journal_entries SET status = 'reversed' WHERE id = $1`, + [jeId], + ) + + const row = await getSuggestion(txId) + expect(row.potential_journal_entry_id).toBeNull() + }) +}) + +describe('sweep summary columns', () => { + it('bank_file_imports.sie_sweep and bank_connections.last_sie_sweep exist as JSONB', async () => { + const { rows } = await getPool().query( + `SELECT table_name, column_name, data_type + FROM information_schema.columns + WHERE (table_name = 'bank_file_imports' AND column_name = 'sie_sweep') + OR (table_name = 'bank_connections' AND column_name = 'last_sie_sweep')`, + ) + expect(rows).toHaveLength(2) + for (const row of rows) { + expect(row.data_type).toBe('jsonb') + } + }) +}) diff --git a/types/index.ts b/types/index.ts index 20b930de..d305948f 100644 --- a/types/index.ts +++ b/types/index.ts @@ -625,6 +625,13 @@ export interface Transaction { // Potential supplier invoice match (suggested, not confirmed) potential_supplier_invoice_id: string | null + // Potential journal-entry match (suggested by the reconciliation sweep, not + // confirmed). All three set together, or all null; cleared by DB triggers + // when the row is booked/ignored or the entry is consumed/reversed. + potential_journal_entry_id?: string | null + potential_match_method?: string | null + potential_match_confidence?: number | null + // Bookkeeping journal_entry_id: string | null mcc_code: number | null