feat(reconciliation): match migrated bank history against imported SIE verifikat (#1598)

* feat(reconciliation): match migrated bank history against imported SIE verifikat

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-08-13 23:12:27 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 07e89d9b52
commit 08440fed94
35 changed files with 3223 additions and 99 deletions
+39 -40
View File
@@ -114,13 +114,24 @@ export default function ImportResultStep({
bridge quiets down to the plain way onward there. */}
{!isSandbox && <p className="font-display text-base">{t('reveal_bridge')}</p>}
<div className="mt-3 flex flex-wrap items-center gap-3">
{!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 && (
<Button asChild size="sm">
<Link href={hasBanking ? '/import?mode=psd2' : '/import?mode=bank'}>
{t('reveal_cta_bank')}
</Link>
<Link href="/import?mode=psd2">{t('reveal_cta_bank')}</Link>
</Button>
)}
<Button
asChild
size="sm"
variant={!isSandbox && hasBanking ? 'outline' : 'default'}
>
<Link href="/import?mode=bank">{t('reveal_cta_csv')}</Link>
</Button>
<Link
href="/"
className="text-xs text-muted-foreground underline decoration-border underline-offset-4 transition-colors hover:text-foreground"
@@ -415,46 +426,34 @@ export default function ImportResultStep({
</Card>
)}
{/* 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 && (
<Card className="bg-muted/50">
<CardHeader>
<CardTitle className="text-base">Nästa steg</CardTitle>
<CardTitle className="text-base">{t('next_steps_title')}</CardTitle>
<CardDescription>{t('next_steps_match_copy')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-start gap-3">
<div className="w-6 h-6 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-sm font-medium">
1
</div>
<div>
<p className="font-medium">Granska importerade verifikationer</p>
<p className="text-sm text-muted-foreground">
Kontrollera att allt ser korrekt ut i bokföringslistan
</p>
</div>
</div>
<div className="flex items-start gap-3">
<div className="w-6 h-6 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-sm font-medium">
2
</div>
<div>
<p className="font-medium">Verifiera balanserna</p>
<p className="text-sm text-muted-foreground">
Jämför huvudboken med din tidigare bokföring
</p>
</div>
</div>
<div className="flex items-start gap-3">
<div className="w-6 h-6 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-sm font-medium">
3
</div>
<div>
<p className="font-medium">Fortsätt med ny bokföring</p>
<p className="text-sm text-muted-foreground">
Nu kan du börja lägga till nya transaktioner
</p>
</div>
<CardContent className="space-y-3">
<div className="flex flex-wrap items-center gap-3">
{/* Sandbox hides only the live bank connection; file-based
import works there and its CTA stays. */}
{!isSandbox && hasBanking && (
<Button asChild size="sm">
<Link href="/import?mode=psd2">{t('next_steps_cta_bank')}</Link>
</Button>
)}
<Button
asChild
size="sm"
variant={!isSandbox && hasBanking ? 'outline' : 'default'}
>
<Link href="/import?mode=bank">{t('next_steps_cta_csv')}</Link>
</Button>
</div>
<p className="text-sm text-muted-foreground">{t('next_steps_window_hint')}</p>
</CardContent>
</Card>
)}