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
@@ -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
+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>
)}
@@ -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({
<LogoMark src="/logos/enable-banking-icon.png" name="Enable Banking" mono />
) : undefined
}
doneNote={
step2Done &&
sieSweep &&
sieSweep.errors === 0 &&
(sieSweep.auto_linked > 0 || sieSweep.suggested > 0) ? (
<Link
href="/transactions"
className="underline decoration-border underline-offset-4 transition-colors hover:text-foreground"
>
{t('step_bank_sweep_note', {
matched: sieSweep.auto_linked,
toReview: sieSweep.suggested,
})}
</Link>
) : undefined
}
>
{t('step_bank_description')}
</Step>
@@ -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({
)}
</div>
{!done && action?.(open ? 'default' : 'outline')}
{done && doneNote && (
<span className="text-xs tabular-nums text-muted-foreground">{doneNote}</span>
)}
</div>
{open && (
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-2">
@@ -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<void>
onReject: (transactionIds: string[]) => Promise<void>
onOpenMatchVoucher: (tx: TransactionWithInvoice) => void
onRerunMatching: () => Promise<void>
rerunning: boolean
}
export function SuggestionReviewList({
items,
onConfirm,
onReject,
onOpenMatchVoucher,
onRerunMatching,
rerunning,
}: SuggestionReviewListProps) {
const t = useTranslations('tx_review')
const [busyIds, setBusyIds] = useState<Set<string>>(new Set())
const [bulkBusy, setBulkBusy] = useState(false)
const runRows = async (ids: string[], action: (ids: string[]) => Promise<void>) => {
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 (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<p className="text-[12.5px] text-muted-foreground">
{t('intro', { count: items.length })}
</p>
<div className="flex items-center gap-2">
<Button
size="sm"
variant="outline"
disabled={rerunning || bulkBusy}
onClick={() => void onRerunMatching()}
>
{rerunning && <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />}
{t('rerun')}
</Button>
<Button size="sm" disabled={bulkBusy || items.length === 0} onClick={() => void confirmAll()}>
{bulkBusy && <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />}
{t('confirm_all', { count: items.length })}
</Button>
</div>
</div>
{items.length === 0 ? (
<EmptyState title={t('empty_title')} description={t('empty_description')} />
) : (
<div className="overflow-x-auto">
<table className="w-full border-collapse text-[13px]">
<thead>
<tr>
<th className={TH_CLASS}>{t('th_date')}</th>
<th className={TH_CLASS}>{t('th_description')}</th>
<th className={cn(TH_CLASS, 'text-right')}>{t('th_amount')}</th>
<th className={TH_CLASS}>{t('th_suggestion')}</th>
<th className={TH_CLASS} aria-label={t('th_actions')} />
</tr>
</thead>
<tbody className="stagger-enter">
{items.map((tx) => {
const busy = busyIds.has(tx.id)
const voucher = tx.potential_voucher
return (
<tr key={tx.id} className="group transition-colors duration-150 hover:bg-secondary/35">
<td className={cn(TD_CLASS, 'whitespace-nowrap tabular-nums text-muted-foreground')}>
{formatDate(tx.date)}
</td>
<td className={cn(TD_CLASS, 'max-w-[280px]')}>
<span className="block truncate">{tx.description}</span>
</td>
<td className={cn(TD_CLASS, 'whitespace-nowrap text-right tabular-nums')}>
{formatCurrency(tx.amount, tx.currency)}
</td>
<td className={cn(TD_CLASS, 'whitespace-nowrap')}>
{voucher ? (
<span className="tabular-nums">
{voucher.voucher_series}-{voucher.voucher_number}
<span className="text-muted-foreground">
{' · '}
{formatDate(voucher.entry_date)}
{typeof tx.potential_match_confidence === 'number' && (
<> {' · '}{Math.round(tx.potential_match_confidence * 100)} %</>
)}
</span>
</span>
) : (
<span className="text-muted-foreground">{t('suggestion_gone')}</span>
)}
</td>
<td className={cn(TD_CLASS, 'whitespace-nowrap text-right')}>
<div className="flex items-center justify-end gap-1">
<Button
size="sm"
variant="outline"
className="h-7 px-3.5 text-xs"
disabled={busy || bulkBusy || !voucher}
onClick={() => void runRows([tx.id], onConfirm)}
>
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : t('confirm')}
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="icon"
variant="ghost"
className="h-7 w-7"
disabled={busy || bulkBusy}
aria-label={t('row_menu')}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onOpenMatchVoucher(tx)}>
{t('open_match_dialog')}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => void runRows([tx.id], onReject)}>
{t('reject')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
</div>
)
}
@@ -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
</Badge>
)}
{/* Quiet pre-migration marker (muted text, not a chip: it is
context, not an exception state). ISO dates compare lexically. */}
{preMigrationCutoff && transaction.date <= preMigrationCutoff && (
<span className="shrink-0 text-xs text-muted-foreground">
{t('pre_migration_marker')}
</span>
)}
</span>
</td>
<td
+15 -2
View File
@@ -1,13 +1,26 @@
import type { Transaction, TransactionCategory, Invoice, Customer, SupplierInvoice, VatTreatment } from '@/types'
/** Revalidated journal-entry match suggestion hung onto a row (mirrors
* potential_invoice): present only when the suggested entry is still posted. */
export interface PotentialVoucher {
journal_entry_id: string
voucher_series: string
voucher_number: number
entry_date: string
description: string | null
}
// Shared transaction type with potential invoice data
export interface TransactionWithInvoice extends Transaction {
potential_invoice?: Invoice & { customer?: Customer }
potential_supplier_invoice?: SupplierInvoice
potential_voucher?: PotentialVoucher
}
// Page view modes
export type ViewMode = 'inbox' | 'history'
// Page view modes. 'review' is the migrator surface: rows whose sweep
// suggestion awaits confirmation ("Granska migrerad historik"); the tab only
// renders while such rows exist.
export type ViewMode = 'inbox' | 'history' | 'review'
export type HistoryFilter = 'all' | 'business' | 'private'
// Source filter (concept scene 10 account chooser), shared by both view modes:
// everything, one cash account ('acct:<id>'), bank rows not yet tied to a