Files
accounted/components/import/ImportResultStep.tsx
T
Mattsson 08440fed94 feat(reconciliation): match migrated bank history against imported SIE verifikat (#1598)
* feat(reconciliation): match migrated bank history against imported SIE verifikat

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 23:12:27 +02:00

499 lines
21 KiB
TypeScript

'use client'
import Link from 'next/link'
import { useTranslations } from 'next-intl'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import {
CheckCircle,
XCircle,
AlertCircle,
FileText,
ExternalLink,
RotateCcw,
Info,
Undo2,
} from 'lucide-react'
import {
DestructiveConfirmDialog,
useDestructiveConfirm,
} from '@/components/ui/destructive-confirm-dialog'
import { formatCurrency } from '@/lib/utils'
import { useCompany } from '@/contexts/CompanyContext'
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
import TheaterCanvas from '@/components/import/TheaterCanvas'
import type { ImportPreview, ImportResult } from '@/lib/import/types'
import type { TheaterModel } from '@/lib/import/theater-model'
interface ImportResultStepProps {
result: ImportResult
onNewImport: () => void
onUndo?: (importId: string) => Promise<void> | void
/** When the theater ran, the reveal replaces the plain success header:
* the settled constellation beside the personalized story + the bank
* bridge. Absent (failure, oversized file, parse miss) = plain header. */
preview?: ImportPreview | null
theaterModel?: TheaterModel | null
unresolvedVatAccountCount?: number
}
export default function ImportResultStep({
result,
onNewImport,
onUndo,
preview = null,
theaterModel = null,
unresolvedVatAccountCount = 0,
}: ImportResultStepProps) {
const t = useTranslations('import')
const { dialogProps, confirm } = useDestructiveConfirm()
const { isSandbox } = useCompany()
const hasBanking = ENABLED_EXTENSION_IDS.has('enable-banking')
// The reveal only tells a story that is true: it needs the theater model,
// the preview, and actual imported entries (an opening-balances-only run
// must not claim "history in place" over an untouched constellation).
const showReveal =
result.success && !!theaterModel && !!preview && result.journalEntriesCreated > 0
const handleUndoClick = async () => {
if (!result.importId || !onUndo) return
const ok = await confirm({
title: 'Ångra hela importen?',
description: `Detta raderar ${result.journalEntriesCreated} verifikation${result.journalEntriesCreated === 1 ? '' : 'er'} och rensar ingående balanser från den här importen. Bifogade dokument blir okopplade men finns kvar.`,
confirmLabel: 'Ångra import',
})
if (!ok) return
await onUndo(result.importId)
}
const hasErrors = result.errors.length > 0
const skipped = result.details?.skippedVouchers
const untransferred = result.details?.untransferredResults
// Filter out raw warnings when we have structured data for them
let otherWarnings = skipped && skipped.total > 0
? result.warnings.filter((w) => !w.includes('hoppades över'))
: result.warnings
if (untransferred && untransferred.length > 0) {
otherWarnings = otherWarnings.filter((w) => !w.includes('förts om till eget kapital'))
}
return (
<div className="space-y-6">
{/* "Din historia" reveal (theater ran) or the plain success/failure header */}
{showReveal && theaterModel && preview ? (
<Card>
<CardContent className="pt-6">
<div className="grid items-center gap-6 md:grid-cols-[minmax(280px,380px)_1fr]">
<div>
<p className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
{t('reveal_eyebrow')}
</p>
<h2 className="mt-2 font-display text-2xl leading-8 tracking-tight text-balance">
{t('reveal_title', { years: Math.max(theaterModel.years.length, 1) })}
</h2>
<p className="mt-3 text-[13px] text-muted-foreground tabular-nums">
{t('reveal_stats', {
vouchers: result.journalEntriesCreated,
accounts: preview.accountCount,
counterparties: theaterModel.totalCounterparties,
})}
</p>
{/* The balance claim comes from the pre-import file check; it
stays honest only when no unbalanced voucher was skipped. */}
{preview.trialBalance.isBalanced && !(skipped && skipped.unbalanced > 0) && (
<p className="mt-1 text-[13px] tabular-nums text-success">{t('reveal_tie_ok')}</p>
)}
{skipped && skipped.total > 0 && (
<p className="mt-1 text-[13px] text-warning">
{t('reveal_skipped', { count: skipped.total })}
</p>
)}
<div className="mt-6 border-t border-border pt-4">
{/* Sandbox strips live bank connections from /import, so the
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">
{/* 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="/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"
>
{t('reveal_cta_open')}
</Link>
</div>
</div>
</div>
<div className="relative hidden min-h-[360px] md:block">
<TheaterCanvas model={theaterModel} settled />
</div>
</div>
</CardContent>
</Card>
) : (
<Card className={result.success ? 'border-border' : 'border-destructive/50'}>
<CardHeader>
<CardTitle className="flex items-center gap-2">
{result.success ? (
<>
<CheckCircle className="h-6 w-6 text-success" />
Import genomförd
</>
) : (
<>
<XCircle className="h-6 w-6 text-destructive" />
Import misslyckades
</>
)}
</CardTitle>
<CardDescription>
{result.success
? skipped && skipped.total > 0
? `Din bokföring har importerats. ${result.journalEntriesCreated} verifikationer skapades, ${skipped.total} hoppades över: se detaljer nedan.`
: 'Din bokföring har importerats framgångsrikt.'
: 'Det uppstod fel under importen. Läs felmeddelanden nedan för att förstå vad som gick snett och hur du kan åtgärda det.'}
</CardDescription>
</CardHeader>
</Card>
)}
{/* IB resync notice (prior-year backfill) */}
{result.success && result.nextPeriodIBResync && (
<Card className="border-success/50">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<CheckCircle className="h-5 w-5 text-success" />
Ingående balanser synkades om
</CardTitle>
<CardDescription>
Eftersom du importerade ett tidigare räkenskapsår uppdaterades ingående balanser för{' '}
<span className="font-medium">{result.nextPeriodIBResync.nextPeriodName}</span>{' '}
automatiskt (gammal IB makulerad, ny IB skapad från utgående balans).
</CardDescription>
</CardHeader>
</Card>
)}
{result.success && result.nextPeriodIBResyncSkipped && (
<Card className="border-border">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base text-warning">
<AlertCircle className="h-5 w-5" />
Ingående balanser för {result.nextPeriodIBResyncSkipped.nextPeriodName} kunde inte synkas
</CardTitle>
<CardDescription>
Nästa räkenskapsår är låst eller stängt. Lås upp perioden och kör importen igen om du
vill att ingående balanser ska uppdateras automatiskt.
</CardDescription>
</CardHeader>
</Card>
)}
{result.success && unresolvedVatAccountCount > 0 && (
<Card className="border-warning/40">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<AlertCircle className="h-5 w-5 text-warning" />
{t('vat_review_title', { count: unresolvedVatAccountCount })}
</CardTitle>
<CardDescription>{t('vat_review_description')}</CardDescription>
</CardHeader>
<CardContent>
<Button asChild variant="outline">
<Link href="/chart-of-accounts">{t('vat_review_action')}</Link>
</Button>
</CardContent>
</Card>
)}
{/* Dimensions detected (lossless SIE round-trip, dimensions plan PR5) */}
{result.success && result.dimensionsImported && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Info className="h-5 w-5 text-muted-foreground" />
Dimensioner följde med importen
</CardTitle>
<CardDescription>
Filen innehöll kostnadsställen/projekt: {result.dimensionsImported.taggedLines}{' '}
taggade rader importerades
{result.dimensionsImported.values > 0 && (
<> och {result.dimensionsImported.values} nya värden lades till i registret</>
)}
.{' '}
{result.dimensionsImported.toggleEnabled && (
<>Dimensioner aktiverades automatiskt för företaget: du hittar registret under{' '}
<Link href="/dimensions" className="underline underline-offset-4">
Kostnadsställen &amp; projekt
</Link>
.</>
)}
</CardDescription>
</CardHeader>
</Card>
)}
{/* Statistics */}
{result.success && !showReveal && (
<div className="grid grid-cols-2 gap-4 md:grid-cols-3">
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-2 text-muted-foreground mb-1">
<FileText className="h-4 w-4" />
<span className="text-sm">Verifikationer skapade</span>
</div>
<p className="text-2xl font-display tabular-nums">{result.journalEntriesCreated}</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-2 text-muted-foreground mb-1">
<span className="text-sm">Räkenskapsår</span>
</div>
<div className="text-2xl font-display">
{result.fiscalPeriodId ? (
<Badge variant="success">Skapat</Badge>
) : (
<Badge variant="secondary">Befintligt</Badge>
)}
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-2 text-muted-foreground mb-1">
<span className="text-sm">Ingående balanser</span>
</div>
<div className="text-2xl font-display">
{result.openingBalanceEntryId ? (
<Badge variant="success">Importerade</Badge>
) : (
<Badge variant="secondary">Inga</Badge>
)}
</div>
</CardContent>
</Card>
</div>
)}
{/* Errors */}
{hasErrors && (
<Card className="border-destructive/50">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-destructive">
<XCircle className="h-5 w-5" />
Fel ({result.errors.length})
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2 max-h-48 overflow-y-auto">
{result.errors.map((error, i) => (
<div key={i} className="text-sm flex gap-2">
<XCircle className="h-4 w-4 text-destructive flex-shrink-0 mt-0.5" />
<span>{error}</span>
</div>
))}
</div>
{!result.success && (
<div className="text-sm text-muted-foreground border-t pt-3 space-y-1">
<p className="font-medium">Vad kan du göra?</p>
<ul className="list-disc list-inside space-y-0.5 text-muted-foreground">
<li>Kontrollera att SIE-filen exporterades korrekt från källsystemet</li>
<li>Prova att exportera filen igen och ladda upp nytt</li>
<li>Om felet kvarstår, kontakta support med felmeddelandet ovan</li>
</ul>
</div>
)}
</CardContent>
</Card>
)}
{/* Skipped vouchers: structured breakdown */}
{skipped && skipped.total > 0 && (
<Card className="border-muted-foreground/20">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-muted-foreground">
<Info className="h-5 w-5" />
Hoppade över {skipped.total} verifikationer
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
{skipped.empty > 0 && (
<div className="text-sm">
<p className="font-medium">{skipped.empty} tomma verifikationer</p>
<p className="text-muted-foreground">
Platshållare utan bokföringsrader: vanligt i Fortnox och Visma. Påverkar inte din bokföring.
</p>
</div>
)}
{skipped.unbalanced > 0 && (
<div className="text-sm">
<p className="font-medium">{skipped.unbalanced} obalanserade verifikationer</p>
<p className="text-muted-foreground">
Debet och kredit stämmer inte överens i källsystemet. Saldon har justerats automatiskt.
</p>
</div>
)}
{skipped.singleLine > 0 && (
<div className="text-sm">
<p className="font-medium">{skipped.singleLine} enradsverifikationer</p>
<p className="text-muted-foreground">
Verifikationer med bara en rad (t.ex. periodiseringar). Kräver minst två rader för dubbelbokning.
</p>
</div>
)}
{skipped.unmapped > 0 && (
<div className="text-sm">
<p className="font-medium">{skipped.unmapped} verifikationer med ej kopplade konton</p>
<p className="text-muted-foreground">
Innehåller konton som inte kunde kopplas till din kontoplan.
</p>
</div>
)}
</div>
</CardContent>
</Card>
)}
{/* Untransferred prior-year results — the year-end omföring is missing */}
{untransferred && untransferred.length > 0 && (
<Card className="border-border">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-warning">
<AlertCircle className="h-5 w-5" />
Årets resultat är inte omfört
</CardTitle>
<CardDescription>
Följande räkenskapsår saknar omföring av årets resultat till eget kapital.
Senare års balansräkning visar en differens beloppet tills omföringen
bokförs (konto 8999 mot eget kapital, t.ex. 2099) i respektive år.
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-2">
{untransferred.map((u) => (
<div key={u.fiscal_period_id} className="text-sm flex justify-between gap-4">
<span className="font-medium">{u.period_name}</span>
<span className="tabular-nums">
{formatCurrency(u.pl_net, 'SEK', { minimumFractionDigits: 2 })}
</span>
</div>
))}
</div>
</CardContent>
</Card>
)}
{/* Other warnings (filtered) */}
{otherWarnings.length > 0 && (
<Card className="border-border">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-warning">
<AlertCircle className="h-5 w-5" />
Varningar ({otherWarnings.length})
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2 max-h-48 overflow-y-auto">
{otherWarnings.map((warning, i) => (
<div key={i} className="text-sm flex gap-2">
<AlertCircle className="h-4 w-4 text-warning flex-shrink-0 mt-0.5" />
<span>{warning}</span>
</div>
))}
</div>
</CardContent>
</Card>
)}
{/* 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">{t('next_steps_title')}</CardTitle>
<CardDescription>{t('next_steps_match_copy')}</CardDescription>
</CardHeader>
<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>
)}
{/* Actions */}
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-between">
<div className="flex flex-col gap-2 sm:flex-row">
<Button variant="outline" className="min-h-11" onClick={onNewImport}>
<RotateCcw className="mr-2 h-4 w-4" />
Ny import
</Button>
{result.success && result.importId && onUndo && (
<Button variant="outline" className="min-h-11 text-destructive hover:text-destructive" onClick={handleUndoClick}>
<Undo2 className="mr-2 h-4 w-4" />
Ångra import
</Button>
)}
</div>
<div className="flex flex-col gap-2 sm:flex-row">
{result.success && (
<>
<Button variant="outline" className="min-h-11" asChild>
<Link href="/bookkeeping">
Visa bokföring
<ExternalLink className="ml-2 h-4 w-4" />
</Link>
</Button>
<Button className="min-h-11" asChild>
<Link href="/reports">
Visa rapporter
<ExternalLink className="ml-2 h-4 w-4" />
</Link>
</Button>
</>
)}
</div>
</div>
<DestructiveConfirmDialog {...dialogProps} />
</div>
)
}