'use client' import Link from 'next/link' import { Fragment, useState, useEffect, useCallback, useRef } from 'react' import { useTranslations } from 'next-intl' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Button } from '@/components/ui/button' import { Checkbox } from '@/components/ui/checkbox' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Badge } from '@/components/ui/badge' import { Switch } from '@/components/ui/switch' import { Skeleton } from '@/components/ui/skeleton' import { Progress } from '@/components/ui/progress' import { InfoTooltip } from '@/components/ui/info-tooltip' import { EmptyState } from '@/components/ui/empty-state' import { AttnLine } from '@/components/ui/attn-line' import { TH_CLASS, TD_CLASS } from '@/components/ui/dry-table' import { AccountNumber } from '@/components/ui/account-number' import { AlertCircle, ArrowRightLeft, ChevronDown, ChevronRight, Landmark, Link2, Unlink, Play, Eye, EyeOff, PiggyBank, MoreHorizontal } from 'lucide-react' import { formatCurrency, formatDate } from '@/lib/utils' import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver' import { CashAccountSelector } from '@/components/common/CashAccountSelector' import { MatchVerifikationPicker, type UnlinkedGLLine } from '@/components/reconciliation/MatchVerifikationPicker' import DuplicateBookingDialog from '@/components/transactions/DuplicateBookingDialog' import type { BookedDuplicateCandidate } from '@/lib/transactions/booking-duplicate-detection' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' import { DestructiveConfirmDialog, useDestructiveConfirm, } from '@/components/ui/destructive-confirm-dialog' import { useToast } from '@/components/ui/use-toast' import { ToastAction } from '@/components/ui/toast' import type { CashAccount } from '@/types' import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message' function formatAmount(amount: number): string { return amount.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) } /** A bridge step, always carrying its sign so the column reads as a running * adjustment. Only the plus is added: Intl already renders negatives with a * real minus sign (U+2212), and prefixing an ASCII hyphen to an absolute value * would put a different glyph in this column than every other amount on the * page. */ function formatSigned(amount: number, currency?: string): string { const rendered = formatCurrency(amount, currency) return amount > 0 ? `+${rendered}` : rendered } /** Anchors for the bridge rows: clicking a step scrolls to the list it names. * The dashboard panel is the scroll container, so scrollIntoView (which walks * up to the nearest scrollable ancestor) is correct here and window.scrollTo * would not be. */ const UNMATCHED_TX_SECTION_ID = 'recon-unmatched-transactions' const UNMATCHED_GL_SECTION_ID = 'recon-unmatched-gl-lines' function scrollToSection(id: string) { document.getElementById(id)?.scrollIntoView({ behavior: 'smooth', block: 'start' }) } const METHOD_LABELS: Record = { auto_exact: 'Exakt matchning', auto_date_range: 'Datumintervall', auto_reference: 'Referensmatchning', auto_fuzzy: 'Ungefärlig matchning', manual: 'Manuell', } // journal_entries.source_type values that can appear on a bank-account GL line, // mapped to Swedish. Falls back to the raw value for anything unmapped so a new // enum value degrades to today's behaviour instead of an empty cell. const SOURCE_TYPE_LABELS: Record = { manual: 'Manuell', import: 'Import', bank_transaction: 'Banktransaktion', invoice_paid: 'Kundfaktura betald', invoice_cash_payment: 'Kontantfaktura', supplier_invoice_paid: 'Leverantörsfaktura betald', supplier_invoice_cash_payment: 'Leverantörsfaktura (kontant)', salary_payment: 'Löneutbetalning', system: 'System', inbox_item: 'Inkorgsunderlag', currency_revaluation: 'Valutaomvärdering', year_end: 'Bokslut', reminder_fee: 'Påminnelseavgift', } // Same thresholds as MatchVerifikationPicker's confidenceBadge: the dry-run // preview must read identically to the per-row picker. function confidenceLabel(confidence: number): { label: string variant: 'success' | 'secondary' | 'outline' } { if (confidence >= 0.85) return { label: 'Stark', variant: 'success' } if (confidence >= 0.6) return { label: 'Trolig', variant: 'secondary' } return { label: 'Svag', variant: 'outline' } } /** Pre-tick strong matches; fuzzy (0.75) stays unticked for explicit opt-in. */ const PRESELECT_CONFIDENCE = 0.85 const matchKey = (transactionId: string, journalEntryId: string) => `${transactionId}:${journalEntryId}` // One-click bookings for transactions with no upstream invoice/voucher to match // against: the common "stuck on the unmatched list" cause (small ränteintäkter, // bankavgifter, valutakursdifferenser). These reuse the existing bank_finance // booking templates; the categorize endpoint rewrites the bank leg to the // transaction's actual settlement account, so they book correctly on ANY cash // account (1930, a savings account, a EUR account…), not just 1930. // `account` is the non-bank leg (revenue/cost): the bank leg is the selected // account. Income templates apply to positive amounts, expense to negative. const QUICK_BOOK_TEMPLATES: { id: string label: string account: string direction: 'income' | 'expense' }[] = [ { id: 'bank_interest_income', label: 'ränteintäkt', account: '8310', direction: 'income' }, { id: 'bank_currency_gain', label: 'valutakursvinst', account: '3960', direction: 'income' }, { id: 'bank_fees', label: 'bankavgift', account: '6570', direction: 'expense' }, { id: 'bank_interest_expense', label: 'räntekostnad', account: '8410', direction: 'expense' }, { id: 'bank_currency_loss', label: 'valutakursförlust', account: '7960', direction: 'expense' }, ] // ============================================================ // Types // ============================================================ interface ReconciliationStatus { /** Bank-feed total EXCLUDING ignored rows: the reconciling bank side. */ bank_transaction_total: number /** Sum of ignored rows in the window; informational, not in the difference. */ ignored_transaction_total: number ignored_transaction_count: number /** * @deprecated Kept on the server response for back-compat. The UI no longer * reads it: `gl_1930_period_movement` is required. */ gl_1930_balance: number gl_1930_period_movement: number gl_1930_opening_balance: number gl_1930_correction_adjustment: number difference: number is_reconciled: boolean matched_count: number unmatched_transaction_count: number /** Sum behind unmatched_transaction_count: one leg of the bridge. */ unmatched_transaction_total: number unmatched_gl_line_count: number /** Sum behind unmatched_gl_line_count, signed like a bank movement. null on a * foreign account whose candidate lines carry no amount in that currency. */ unmatched_gl_line_total: number | null /** What is left of the difference once both lists are accounted for; null * whenever unmatched_gl_line_total is. */ unexplained_difference: number | null } interface UnmatchedTransaction { id: string date: string description: string amount: number reference: string | null currency: string is_ignored?: boolean } interface MatchedTransaction { id: string date: string description: string amount: number reconciliation_method: string | null journal_entry_id: string | null } interface DryRunMatch { transaction_id: string transaction_date: string transaction_description: string transaction_amount: number journal_entry_id: string voucher_number: number voucher_series: string entry_date: string entry_description: string method: string confidence: number } // ============================================================ // Component // ============================================================ interface BankReconciliationViewProps { /** * The fiscal period to reconcile, from the page-level räkenskapsår selector in * the report header (FocusedReport). The view no longer owns a selector of its * own: that duplicate, hidden behind the loading skeleton, deadlocked the page * (#771). */ periodId: string /** period_start / period_end of that period; seeds the date window (#751). */ periodBounds: { start: string; end: string } | null /** * Deep-link bridge (?autorun=1, e.g. from the transactions inbox banner): * runs the dry-run preview automatically ONCE, only after the first load has * recorded appliedDates and while the typed dates still match it, so the * preview can never cover a different window than the on-screen lists. */ autoRun?: boolean } export function BankReconciliationView({ periodId, periodBounds, autoRun }: BankReconciliationViewProps) { const t = useTranslations('reports') const [status, setStatus] = useState(null) const [unmatchedTx, setUnmatchedTx] = useState([]) const [glLines, setGlLines] = useState([]) const [matchedTx, setMatchedTx] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) // The window is scoped to a fiscal period (issue #751/#771): a bank // reconciliation is inherently per-period, and a "full history" window spans // the fiscal-year boundary: mixing a prior period's movements with the current // year's IB and manufacturing a phantom difference equal to the IB. The period // is owned by the page-level FiscalYearSelector in the report header and passed // in as props, so the view always mounts with a known window. It used to host // its OWN selector inside the action bar and gate the first fetch on a // `periodReady` flag, but that selector lived below the loading-skeleton // early-return, so it never mounted, the flag never flipped, and the page hung // on a permanent skeleton (#771). dateFrom/dateTo are seeded from periodBounds // here and stay editable as a manual override (applied via "Filtrera"). const [dateFrom, setDateFrom] = useState(periodBounds?.start ?? '') const [dateTo, setDateTo] = useState(() => { const today = new Date().toISOString().slice(0, 10) if (periodBounds && periodBounds.end < today) return periodBounds.end return today }) const [accountNumber, setAccountNumber] = useState('1930') const [cashAccounts, setCashAccounts] = useState([]) // Date filters apply on demand (the "Filtrera" button or an account switch), // never on every keystroke. Editing a date used to re-create fetchAll and // re-trigger its effect: the "switching months reloads automatically" // annoyance. fetchAll reads the live dates from refs so an explicit run always // uses the latest typed values without putting them in its dependency array. const dateFromRef = useRef(dateFrom) const dateToRef = useRef(dateTo) useEffect(() => { dateFromRef.current = dateFrom }, [dateFrom]) useEffect(() => { dateToRef.current = dateTo }, [dateTo]) const [dryRunResults, setDryRunResults] = useState(null) // Which preview rows apply on "Tillämpa". Strong matches (≥0.85) are // pre-ticked; fuzzy ones require an explicit opt-in tick. const [selectedPairs, setSelectedPairs] = useState>(new Set()) // The date window the on-screen lists were last fetched with. Preview/apply // read THIS window (not the live inputs) so they can never run against a // different window than the lists the user is looking at; a mismatch between // typed and applied dates renders a "klicka Filtrera" hint instead. const [appliedDates, setAppliedDates] = useState<{ from: string; to: string } | null>(null) const [runLoading, setRunLoading] = useState(false) const [applyLoading, setApplyLoading] = useState(false) const [linkLoading, setLinkLoading] = useState(null) const [unlinkLoading, setUnlinkLoading] = useState(null) // Per-verifikat loading for the "Märk som ingående balans" re-tag action. const [markLoading, setMarkLoading] = useState(null) const [actionLoading, setActionLoading] = useState(null) // Booking-time duplicate guard (TRANSACTION_BOOK_POSSIBLE_DUPLICATE) fired // for a quick-book: opened as the shared match/ignore/book-anyway dialog // instead of a dead-end toast; this page's whole purpose is matching. const [duplicateWarning, setDuplicateWarning] = useState<{ transactionId: string retry: () => Promise candidate: BookedDuplicateCandidate } | null>(null) const [duplicateProcessing, setDuplicateProcessing] = useState(false) // Opt-in: also surface vouchers already matched to a bank transaction as // candidates, so a second/third transaction can be attached to the same // verifikat (N:1, e.g. a salary run paid out in several transfers). Only // affects the per-row picker candidates; the "Omatchade verifikationer" table // below stays unmatched-only (it lists vouchers that still need a transaction). const [includeMatched, setIncludeMatched] = useState(false) const [showMatched, setShowMatched] = useState(false) // Default expanded so users discover the undo path. The card itself only // renders when ignoredTx.length > 0: collapsing it by default hid the // recovery affordance from anyone who didn't already know it was there. const [showIgnored, setShowIgnored] = useState(true) const [ignoredTx, setIgnoredTx] = useState([]) const [selectedMatch, setSelectedMatch] = useState>({}) // True when the unmatched list hit the API's 500-row cap: surfaced so a long // date range doesn't silently hide rows and let the user think they're done. const [unmatchedTruncated, setUnmatchedTruncated] = useState(false) // Per-transaction ranked match candidates, lazily fetched when the row's // picker is first focused. Passing transaction_id to /unmatched-entries makes // the server rank candidates and attach confidence: the same intelligence // MatchVoucherDialog gets on the Transactions page. Keyed by transaction id; // cleared whenever the lists refetch (the candidate set may have changed). const [rankedCandidates, setRankedCandidates] = useState>({}) /** The one unmatched row whose match picker is open. Single-open by design: * the picker is a heavy control (it fetches ranked candidates per row), and * rendering one per row is exactly what made this list unusable. */ const [expandedTxId, setExpandedTxId] = useState(null) const rankedFetchInFlight = useRef>(new Set()) // Bumped whenever fetchAll clears the ranked cache: an in-flight ranked // response from before the clear must not repopulate the fresh cache, or // that row's picker would show pre-refetch candidates until the next reload. const rankedGenerationRef = useRef(0) // Aborts the previous in-flight load when the account/date filters change, so // a slow stale response can't overwrite the freshly-selected account's data // (the intermittent "flips between accounts" bug). const fetchAbortRef = useRef(null) const { dialogProps: confirmDialogProps, confirm } = useDestructiveConfirm() const { toast } = useToast() // Derive the currency for the selected ledger account from cash_accounts. // Without this the lists below would hardcode SEK and silently return zero // rows for users on 1932 EUR (or any other non-SEK cash account). const accountCurrency = cashAccounts.find((a) => a.ledger_account === accountNumber)?.currency ?? 'SEK' // glLines feeds the per-row picker (which may include already-matched vouchers // when includeMatched is on). The "Omatchade verifikationer" table below must // stay unmatched-only: a voucher with a linked transaction isn't something // that still needs one. const unmatchedGlLines = glLines.filter((l) => !(l.linked_transaction_count ?? 0)) // The typed dates differ from what the lists (and preview/apply) run against. // Surfaced as a hint so a user can't edit a date, skip Filtrera, and believe // the preview covered the window they typed. const datesDirty = appliedDates !== null && (appliedDates.from !== dateFrom || appliedDates.to !== dateTo) // Promote the preview flow while there is unmatched work and no preview has // run yet: an attention line above the toolbar plus the Förhandsgranska // button in the default (filled) variant. Suppressed while datesDirty: that // state owns the page's single attention line and disables the button anyway. const previewPromoted = unmatchedTx.length > 0 && dryRunResults === null && !datesDirty // Every ticked preview pair is a strong match (>= the Stark badge floor): // the apply button relabels to "Matcha X starka träffar" and the apply // request carries confidence_threshold so the server re-run enforces the // same floor. Manually ticked weaker pairs drop back to the plain label and // an unthresholded apply. const allSelectedStrong = dryRunResults !== null && selectedPairs.size > 0 && dryRunResults .filter((m) => selectedPairs.has(matchKey(m.transaction_id, m.journal_entry_id))) .every((m) => m.confidence >= PRESELECT_CONFIDENCE) useEffect(() => { let cancelled = false fetch('/api/cash-accounts') .then((r) => r.json()) .then((j) => { if (!cancelled && Array.isArray(j.data)) setCashAccounts(j.data as CashAccount[]) }) .catch(() => { // Non-critical: falls back to 'SEK' currency, matches old behaviour. }) return () => { cancelled = true } }, []) const fetchAll = useCallback(async (opts?: { silent?: boolean }) => { // Cancel any in-flight load: it may be for a different account. Without // this, switching accounts quickly lets an older response land last and // overwrite the current account's data. fetchAbortRef.current?.abort() const controller = new AbortController() fetchAbortRef.current = controller const { signal } = controller // Only wholesale reloads (mount, account/period switch, Filtrera) show the // skeleton. Row mutations refresh silently and update in place: the old // behaviour unmounted the entire page on EVERY link/unlink/quick-book, // losing scroll position and flashing 30 skeletons for 30 matches. if (!opts?.silent) { setLoading(true) // A wholesale reload means the window/account/candidate set may have // changed: a preview computed for the previous window must not leave an // enabled "Tillämpa" button behind. Silent row-mutation refetches keep // the preview (same window; the intersection guard on apply covers rows // that got linked meanwhile). setDryRunResults(null) setSelectedPairs(new Set()) } setError(null) // The candidate pool changes with the data: drop stale per-row rankings // and invalidate any ranked fetch already in flight. setRankedCandidates({}) rankedFetchInFlight.current.clear() rankedGenerationRef.current++ try { const fromValue = dateFromRef.current const toValue = dateToRef.current const params = new URLSearchParams() if (fromValue) params.set('date_from', fromValue) if (toValue) params.set('date_to', toValue) params.set('account_number', accountNumber) const qs = `?${params}` // The candidate-lines fetch optionally includes already-matched vouchers // (for N:1); the status endpoint must NOT (its movement/diff is computed // independently) so it keeps the plain qs. const glParams = new URLSearchParams(params) if (includeMatched) glParams.set('include_matched', 'true') const glQs = `?${glParams}` const txParams = new URLSearchParams() txParams.set('currency', accountCurrency) txParams.set('account_number', accountNumber) if (fromValue) txParams.set('date_from', fromValue) if (toValue) txParams.set('date_to', toValue) const unmatchedQs = `?unmatched=true&${txParams}` const reconciledQs = `?reconciled=true&${txParams}` const [statusRes, glRes, unmatchedRes, matchedRes] = await Promise.all([ fetch(`/api/reconciliation/bank/status${qs}`, { signal }), fetch(`/api/reconciliation/bank/unmatched-entries${glQs}`, { signal }), fetch(`/api/transactions${unmatchedQs}`, { signal }), fetch(`/api/transactions${reconciledQs}`, { signal }), ]) const [statusData, glData, unmatchedData, matchedData] = await Promise.all([ statusRes.json(), glRes.json(), unmatchedRes.json(), matchedRes.json(), ]) // A newer load superseded this one while we awaited: discard these // stale results rather than clobber the current account's data. if (signal.aborted) return if (statusData.data) setStatus(statusData.data) setGlLines(glData.data || []) setUnmatchedTx(unmatchedData.data || []) setMatchedTx(matchedData.data || []) setUnmatchedTruncated(Boolean(unmatchedData.has_more)) // Record the window these lists represent: preview/apply run against it. setAppliedDates({ from: fromValue, to: toValue }) // Refresh the ignored list whenever the main lists refresh. // Deliberately NOT filtered by account or currency: if a user ignored // a row on 1932 EUR and then switched to 1930 SEK, the recovery card // would disappear and the row would feel "stuck". Company-wide scope // keeps the Återställ path reachable from any account selection. The // date filter is also dropped so old ignores stay visible. try { const ignoredRes = await fetch(`/api/transactions?unmatched=true&only_ignored=true`, { signal }) const ignoredData = await ignoredRes.json() if (!signal.aborted) setIgnoredTx(ignoredData.data || []) } catch { if (!signal.aborted) setIgnoredTx([]) } } catch (e) { // Aborts are expected when the user switches account/date quickly. if (signal.aborted || (e instanceof DOMException && e.name === 'AbortError')) return console.error('[reconciliation] fetchAll failed', e) setError('Kunde inte hämta avstämningsdata') } finally { // Only the latest load owns the spinner; a superseded load must not flip // it off while the fresh one is still running. if (!signal.aborted) setLoading(false) } // Deliberately excludes dateFrom/dateTo: editing a date must NOT auto-fetch // (it read from refs above). Re-runs on account / currency change, on the // matched-toggle flip (which changes the candidate set), and on mount; the // "Filtrera" button calls fetchAll() explicitly for date changes. }, [accountNumber, accountCurrency, includeMatched]) // Re-seed the date window whenever the selected räkenskapsår changes (driven // by the page-level FiscalYearSelector in the report header). dateTo is clamped // to today for the current (open) year so we don't claim to reconcile into the // future; a past year ends at its period_end. // // We write dateFromRef/dateToRef SYNCHRONOUSLY here, not just the state: fetchAll // reads the window from the refs, and the [dateFrom]/[dateTo] sync effects above // only refresh them on the NEXT commit: too late for the fetch effect below, // which runs on this same period-switch commit. Without the synchronous ref // write the first load after a year switch would use the PREVIOUS period's // window (off-by-one). This effect MUST stay declared ABOVE the fetch effect so // React runs it first. // // Keyed on periodId ONLY: switching the bank account must re-fetch (via the // fetch effect, whose fetchAll identity changes) but must NOT re-seed the dates // and discard a manual "Datum från/till" edit. useEffect(() => { if (!periodBounds) return const today = new Date().toISOString().slice(0, 10) const from = periodBounds.start const to = periodBounds.end < today ? periodBounds.end : today setDateFrom(from) setDateTo(to) dateFromRef.current = from dateToRef.current = to // eslint-disable-next-line react-hooks/exhaustive-deps }, [periodId]) // Load on mount, when the bank account / currency / matched-toggle change // (fetchAll identity), and when the räkenskapsår switches (periodId). fetchAll // reads the window from the refs, which the effect above has already refreshed // for a period switch. Manual date edits intentionally do NOT auto-fetch: that // stays on the explicit "Filtrera" button (which calls fetchAll() directly). useEffect(() => { fetchAll() }, [fetchAll, periodId]) // Reset transient per-account UI state when the selected account changes. A // verifikation pick or a dry-run preview computed for the previous account is // meaningless against the new one, and applying it would cross-link. useEffect(() => { setSelectedMatch({}) setDryRunResults(null) setSelectedPairs(new Set()) }, [accountNumber]) const handleDryRun = async () => { setRunLoading(true) setDryRunResults(null) setSelectedPairs(new Set()) try { const res = await fetch('/api/reconciliation/bank/run', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ // The APPLIED window: never the live inputs, which may not match the // lists on screen until the user clicks Filtrera. date_from: appliedDates?.from || undefined, date_to: appliedDates?.to || undefined, account_number: accountNumber, dry_run: true, }), }) const result = await res.json() if (!res.ok || result.error) { // An error envelope parses as JSON, so the catch below never fires for // it: without this check the button just stopped spinning and NOTHING // rendered, leaving the user staring at an unchanged page. setError( typeof result.error === 'string' ? result.error : 'Kunde inte köra förhandsgranskning', ) return } if (result.data?.matches) { const matches = result.data.matches as DryRunMatch[] setDryRunResults(matches) setSelectedPairs( new Set( matches .filter((m) => m.confidence >= PRESELECT_CONFIDENCE) .map((m) => matchKey(m.transaction_id, m.journal_entry_id)), ), ) } } catch { setError('Kunde inte köra förhandsgranskning') } finally { setRunLoading(false) } } // One-shot autorun bridge (?autorun=1): trigger the same dry-run the // Förhandsgranska button runs, exactly once, and only once the first load // has recorded appliedDates with the typed dates still matching it // (datesDirty false). Firing earlier could preview a different window than // the on-screen lists. Consumed even when there is nothing to preview, so a // later data refresh never surprises the user with an unprompted run. const autoRunConsumedRef = useRef(false) useEffect(() => { if (!autoRun || autoRunConsumedRef.current) return if (loading || !appliedDates || datesDirty) return autoRunConsumedRef.current = true if (unmatchedTx.length > 0) void handleDryRun() // handleDryRun is recreated every render; the consumed-ref guarantees the // single run, so depending on it would only add noise. // eslint-disable-next-line react-hooks/exhaustive-deps }, [autoRun, loading, appliedDates, datesDirty, unmatchedTx.length]) const toggleMatchSelection = (key: string) => { setSelectedPairs((prev) => { const next = new Set(prev) if (next.has(key)) next.delete(key) else next.add(key) return next }) } // Matches RunReconciliationSchema's selected_matches .max(500). A first // reconciliation after a year of imports can preview (and pre-tick) far more // than 500 matches: applied in sequential chunks so the flow never dead-ends // on the payload cap. Chunking is safe: the server intersects each chunk with // a fresh match run, so pairs applied by an earlier chunk simply drop out of // later ones and unselected pairs are never applied. const APPLY_CHUNK_SIZE = 500 const handleApply = async () => { if (!dryRunResults || selectedPairs.size === 0) return const selected = dryRunResults.filter((m) => selectedPairs.has(matchKey(m.transaction_id, m.journal_entry_id)), ) const requested = selected.length setApplyLoading(true) let applied = 0 let failed = false let failMessage: string | undefined try { for (let i = 0; i < selected.length; i += APPLY_CHUNK_SIZE) { const chunk = selected.slice(i, i + APPLY_CHUNK_SIZE) const res = await fetch('/api/reconciliation/bank/run', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ date_from: appliedDates?.from || undefined, date_to: appliedDates?.to || undefined, account_number: accountNumber, dry_run: false, selected_matches: chunk.map((m) => ({ transaction_id: m.transaction_id, journal_entry_id: m.journal_entry_id, })), // Strong-only apply: when every ticked pair is >= the Stark floor, // ask the server to enforce that floor on its fresh re-run too. A // mixed selection omits it so manually ticked weaker pairs still // apply (the intersection guard still protects them). ...(allSelectedStrong ? { confidence_threshold: PRESELECT_CONFIDENCE } : {}), }), }) const result = await res.json() if (!res.ok || result.error) { failed = true failMessage = typeof result.error === 'string' ? result.error : undefined break } applied += result.data?.applied ?? 0 } } catch { failed = true } // Report what actually happened: the old flow cleared the preview and // said nothing, even when the API had failed outright. if (failed) { toast({ variant: 'destructive', title: applied > 0 ? `${applied} av ${requested} matchningar tillämpade; resten misslyckades` : 'Kunde inte tillämpa matchningarna', description: failMessage, }) } else if (applied === requested) { toast({ variant: 'success', title: `${applied} ${applied === 1 ? 'matchning tillämpad' : 'matchningar tillämpade'}`, }) } else { toast({ variant: 'destructive', title: `${applied} av ${requested} matchningar tillämpade`, description: 'Resten kunde inte tillämpas: underlaget kan ha ändrats sedan förhandsgranskningen. Kör en ny förhandsgranskning.', }) } // Refetch whenever anything may have been written; on a clean failure with // zero applied the preview survives so the user can retry. try { if (!failed || applied > 0) { setDryRunResults(null) setSelectedPairs(new Set()) await fetchAll({ silent: true }) } } finally { setApplyLoading(false) } } /** * Lazily fetch ranked, confidence-scored candidates for one transaction the * first time its picker is focused. The endpoint ranks and attaches * confidence when transaction_id is passed: without it every row shows the * same unranked list and no Stark/Trolig/Svag badges (the intelligence the * Transactions page's MatchVoucherDialog has had all along). */ const ensureRankedCandidates = useCallback( async (transactionId: string) => { if (rankedCandidates[transactionId] || rankedFetchInFlight.current.has(transactionId)) { return } rankedFetchInFlight.current.add(transactionId) const generation = rankedGenerationRef.current try { const params = new URLSearchParams() // The APPLIED window: the same one the lists and preview run against. // Reading the live refs here would let a typed-but-not-filtered date // silently give this row a different candidate set than the tables on // screen. Refs are only the pre-first-load fallback. const from = appliedDates?.from ?? dateFromRef.current const to = appliedDates?.to ?? dateToRef.current if (from) params.set('date_from', from) if (to) params.set('date_to', to) params.set('account_number', accountNumber) params.set('transaction_id', transactionId) if (includeMatched) params.set('include_matched', 'true') const res = await fetch(`/api/reconciliation/bank/unmatched-entries?${params}`) const json = await res.json() // Discard if fetchAll cleared the cache while we were in flight: // committing would pin pre-refetch candidates on this row. if (res.ok && Array.isArray(json.data) && rankedGenerationRef.current === generation) { setRankedCandidates((prev) => ({ ...prev, [transactionId]: json.data })) } } catch { // Non-critical: the picker falls back to the unranked shared list. } finally { rankedFetchInFlight.current.delete(transactionId) } }, [accountNumber, includeMatched, rankedCandidates, appliedDates], ) /** Open one row's match picker (closing any other) and fetch its ranked * candidates. The fetch used to hang off onFocusCapture on an always-rendered * picker; it now runs on expand, which is the same moment the user asks for * candidates but only for rows they actually open. */ const toggleExpandedTx = useCallback( (transactionId: string) => { setExpandedTxId((current) => (current === transactionId ? null : transactionId)) void ensureRankedCandidates(transactionId) }, [ensureRankedCandidates], ) const handleManualLink = async (transactionId: string) => { const journalEntryId = selectedMatch[transactionId] if (!journalEntryId) return setLinkLoading(transactionId) try { const res = await fetch('/api/reconciliation/bank/link', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ transaction_id: transactionId, journal_entry_id: journalEntryId, account_number: accountNumber, }), }) const result = await res.json() if (!res.ok || result.error) { // Row-level failures surface next to where the user is working: the // old top-of-page banner was off-screen when acting on row 40. toast({ variant: 'destructive', title: 'Kunde inte matcha transaktionen', description: typeof result.error === 'string' ? result.error : undefined, }) } else { setSelectedMatch((prev) => { const next = { ...prev } delete next[transactionId] return next }) setExpandedTxId((current) => (current === transactionId ? null : current)) toast({ variant: 'success', title: 'Transaktionen matchades mot verifikationen' }) await fetchAll({ silent: true }) } } catch { toast({ variant: 'destructive', title: 'Kunde inte matcha transaktionen' }) } finally { setLinkLoading(null) } } const handleUnlink = async (transactionId: string) => { setUnlinkLoading(transactionId) try { const res = await fetch('/api/reconciliation/bank/unlink', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ transaction_id: transactionId }), }) const result = await res.json() if (!res.ok || result.error) { toast({ variant: 'destructive', title: 'Kunde inte avmatcha transaktionen', description: typeof result.error === 'string' ? result.error : undefined, }) } else { toast({ variant: 'success', title: 'Matchningen togs bort' }) await fetchAll({ silent: true }) } } catch { toast({ variant: 'destructive', title: 'Kunde inte avmatcha transaktionen' }) } finally { setUnlinkLoading(null) } } /** * Re-tag a manual/import voucher that is really an ingående balans as * source_type='opening_balance'. Such a voucher (common after a migration * where the IB was booked as an ordinary verifikat) otherwise stays in the * period movement and shows up as a phantom difference equal to the IB. After * re-tagging it drops out of the diff and is surfaced as "IB: räknas inte". */ const handleMarkOpeningBalance = async (journalEntryId: string) => { setMarkLoading(journalEntryId) try { const res = await fetch('/api/reconciliation/bank/mark-opening-balance', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ journal_entry_id: journalEntryId }), }) const result = await res.json() if (!res.ok || result.error) { toast({ variant: 'destructive', title: 'Kunde inte markera verifikationen som ingående balans', description: typeof result.error === 'string' ? result.error : undefined, }) } else { toast({ variant: 'success', title: 'Verifikationen markerades som ingående balans' }) await fetchAll({ silent: true }) } } catch { toast({ variant: 'destructive', title: 'Kunde inte markera verifikationen som ingående balans', }) } finally { setMarkLoading(null) } } /** * Inline one-click booking for an unmatched transaction with no upstream * voucher to match against (ränteintäkter, bankavgifter, valutakurs- * differenser). Calls the standard categorize endpoint with a bank_finance * template so the resulting verifikation is identical to the /transactions * flow: no parallel booking path. The categorize endpoint rewrites the bank * leg to the transaction's actual settlement account, so this is correct on * any cash account. */ const handleQuickBook = async ( transactionId: string, templateId: string, // Set after the user confirmed the duplicate warning: force is bound to // the reviewed candidate's voucher and re-detected server-side. forceOpts?: { expectedDuplicateJournalEntryId: string }, ) => { setActionLoading(transactionId) try { const res = await fetch(`/api/transactions/${transactionId}/categorize`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ is_business: true, template_id: templateId, confirm_no_match: true, ...(forceOpts ? { force: true, expected_duplicate_journal_entry_id: forceOpts.expectedDuplicateJournalEntryId } : {}), }), }) const result = await res.json() if (!res.ok || result.error) { const candidate = result?.error?.details?.candidate as BookedDuplicateCandidate | undefined if (result?.error?.code === 'TRANSACTION_BOOK_POSSIBLE_DUPLICATE' && candidate) { // The affärshändelse already looks booked. On the reconciliation // page the right resolutions (match the voucher, ignore a duplicate // import, or book anyway) all live in the shared dialog: never // dead-end in a toast with no way forward. setDuplicateWarning({ transactionId, retry: () => handleQuickBook(transactionId, templateId, { expectedDuplicateJournalEntryId: candidate.journal_entry_id, }), candidate, }) return } toast({ variant: 'destructive', title: 'Kunde inte bokföra transaktionen', description: getUserErrorMessage(result.error) || (typeof result.error === 'string' ? result.error : undefined), }) return } if (result.journal_entry_error) { toast({ variant: 'destructive', title: 'Kunde inte bokföra transaktionen', description: result.journal_entry_error, }) return } toast({ variant: 'success', title: 'Transaktionen bokfördes' }) await fetchAll({ silent: true }) } catch { toast({ variant: 'destructive', title: 'Kunde inte bokföra transaktionen' }) } finally { setActionLoading(null) } } /** * Move a transaction to another of the company's cash accounts (PATCH * /api/transactions/[id]/cash-account). The row then leaves THIS account's * unmatched list and surfaces on the target account's reconciliation, which * is the fix for rows stuck under the wrong (or the primary) account: * cross-account matching is deliberately blocked, so the row must move to * where its verifikat lives. Server-side gating rejects booked/matched rows. */ const handleMoveToAccount = async (tx: UnmatchedTransaction, target: CashAccount) => { setActionLoading(tx.id) try { const res = await fetch(`/api/transactions/${tx.id}/cash-account`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ account_number: target.ledger_account }), }) const result = await res.json() if (!res.ok || result.error) { toast({ variant: 'destructive', title: 'Kunde inte flytta transaktionen', description: getUserErrorMessage(result.error) || (typeof result.error === 'string' ? result.error : undefined), }) return } toast({ variant: 'success', title: `Transaktionen flyttades till ${target.name || `Bankkonto ${target.currency}`} (${target.ledger_account})`, }) // Both accounts' totals change (the row leaves this report and joins the // target's), so refresh the whole view, status card included. await fetchAll({ silent: true }) } catch { toast({ variant: 'destructive', title: 'Kunde inte flytta transaktionen' }) } finally { setActionLoading(null) } } const handleIgnore = async (tx: UnmatchedTransaction) => { // Even though Ignorera is fully reversible, it's still a state change the // user could miss after a misclick: the row vanishes from the unmatched // list immediately. Confirmation before the write + an explicit Ångra // toast on success gives two recovery affordances. The persistent // "Ignorerade transaktioner" card is the third. const ok = await confirm({ title: 'Ignorera transaktionen?', description: `${tx.description}: ${formatCurrency(tx.amount, tx.currency)} (${formatDate(tx.date)}) försvinner från avstämningen utan att bokföras. Du kan återställa den från "Ignorerade transaktioner" nedan när som helst.`, confirmLabel: 'Ignorera', cancelLabel: 'Avbryt', variant: 'warning', }) if (!ok) return setActionLoading(tx.id) try { const res = await fetch(`/api/transactions/${tx.id}/ignore`, { method: 'POST', }) const result = await res.json() if (!res.ok || result.error) { toast({ variant: 'destructive', title: 'Kunde inte ignorera transaktionen', description: typeof result.error === 'string' ? result.error : undefined, }) return } await fetchAll({ silent: true }) toast({ title: 'Transaktionen ignorerad', description: `${tx.description}: ${formatCurrency(tx.amount, tx.currency)}`, action: ( handleUnignore(tx.id)} > Ångra ), }) } catch { toast({ variant: 'destructive', title: 'Kunde inte ignorera transaktionen' }) } finally { setActionLoading(null) } } const handleUnignore = async (transactionId: string) => { setActionLoading(transactionId) try { const res = await fetch(`/api/transactions/${transactionId}/ignore`, { method: 'DELETE', }) const result = await res.json() if (!res.ok || result.error) { toast({ variant: 'destructive', title: 'Kunde inte återställa transaktionen', description: typeof result.error === 'string' ? result.error : undefined, }) return } await fetchAll({ silent: true }) } catch { toast({ variant: 'destructive', title: 'Kunde inte återställa transaktionen' }) } finally { setActionLoading(null) } } if (loading) { return ( ) } if (error && !status) { return ( {error} ) } // Bridge derivations. `unexplained_difference` is null exactly when the GL // side cannot be expressed in the account's currency (a foreign account: the // candidate RPCs project no FX columns), and the card falls back to the flat // figures rather than showing a bridge whose middle row has no honest amount. const bridgeDerivable = status?.unexplained_difference != null const reconcilableCount = status ? status.matched_count + status.unmatched_transaction_count : 0 const matchedPercent = reconcilableCount > 0 ? Math.round((status!.matched_count / reconcilableCount) * 100) : 0 // What the reconciliation leaves out, as one line instead of three stacked // paragraphs. Amounts stay on screen (BFL: the user must be able to see what // was excluded); the legal reasoning moved into the tooltip beside them. const excludedItems: string[] = [] if (status) { if (status.gl_1930_opening_balance !== 0) { excludedItems.push( t('recon_excl_ib', { amount: formatCurrency(status.gl_1930_opening_balance) }) ) } if (status.ignored_transaction_count > 0) { excludedItems.push( t('recon_excl_ignored', { count: status.ignored_transaction_count, amount: formatCurrency(status.ignored_transaction_total, accountCurrency), }) ) } } const exclusionNotes: string[] = [] if (excludedItems.length > 0) { exclusionNotes.push(t('recon_excl_prefix', { items: excludedItems.join(', ') })) } // Corrections are the opposite case: INCLUDED, exactly as on the balance // sheet. Stated here because a large correction figure is the single most // common "why is the booked amount so big" question on this card. if (status && status.gl_1930_correction_adjustment !== 0) { exclusionNotes.push( t('recon_excl_corrections', { amount: formatCurrency(status.gl_1930_correction_adjustment), }) ) } return (
{error && ( {error} )} {/* Status Card */} {status && (
Avstämning mot {/* Convention 5: chips mark exceptions. Being mid-year and not yet reconciled is the NORMAL state, so a permanent destructive "Ej avstämd" badge marked nothing and just manufactured alarm. Fully reconciled is the state worth marking. */} {status.is_reconciled ? ( Avstämd ) : ( {t('recon_open_items', { count: status.unmatched_transaction_count + status.unmatched_gl_line_count, })} )}
{/* Reconciliation is a count-down-to-zero task; the only progress signal used to be three comma-separated numbers in 12px grey. */} {reconcilableCount > 0 && (
{t('recon_progress', { matched: status.matched_count, total: reconcilableCount, })} {matchedPercent} %
)} {/* THE BRIDGE. The old card printed the bank total, the ledger total and a red difference, leaving the user to work out what the difference consisted of: the page already knew, exactly. Every krona of it is (unmatched bank rows) - (unmatched vouchers), so the two middle rows both explain the number AND navigate to the list that resolves them. Only the residual after those two can mean something is actually wrong. Falls back to the flat figures when the residual is not derivable (a foreign account: see unmatched_gl_line_total). */}
Banktransaktioner i perioden {formatCurrency(status.bank_transaction_total, accountCurrency)}
{bridgeDerivable && ( <> {status.unmatched_transaction_count > 0 && ( )} {status.unmatched_gl_line_count > 0 && ( )} )} {/* GL-side figures (bokfört, IB, rättelser, differens) stay in SEK: journal entries are booked in SEK regardless of the cash account's currency. Only the bank-feed total above is in the account's own currency. */}
Bokfört på i perioden {formatCurrency(status.gl_1930_period_movement)}
{bridgeDerivable ? (
{t('recon_unexplained_label')} {formatCurrency(status.unexplained_difference ?? 0, accountCurrency)}
) : (
Differens {formatCurrency(status.difference)}
)} {bridgeDerivable && Math.abs(status.unexplained_difference ?? 0) >= 0.01 && (

{t('recon_unexplained_note')}

)}
{/* What the reconciliation deliberately leaves out. Three stacked paragraphs of legal prose used to sit in the card; the amounts stay visible (they are compliance-relevant) but the reasoning moved behind the tooltip. */} {exclusionNotes.length > 0 && (

{exclusionNotes.join(' ')}

)}
)} {/* Toolbar: flat on the panel, no box (UI-migration language) */}
{/* Promote the bulk flow before the first preview: many users never found Förhandsgranska and matched a whole migration row by row. */} {previewPromoted && !runLoading && ( {t('recon_unmatched_attn', { count: unmatchedTx.length })} )}
setDateFrom(e.target.value)} className="mt-1" />
setDateTo(e.target.value)} className="mt-1" />
{dryRunResults && dryRunResults.length > 0 && ( )}
{datesDirty && ( Datumfiltret är ändrat men inte tillämpat: klicka Filtrera för att uppdatera listorna innan du förhandsgranskar. )}
{/* Dry Run Preview */} {dryRunResults && dryRunResults.length > 0 && (

Förhandsgranskning ({dryRunResults.length})

{dryRunResults.map((m) => { const key = matchKey(m.transaction_id, m.journal_entry_id) const badge = confidenceLabel(m.confidence) return ( ) })}
Transaktion Datum Belopp ↔ Verifikation Datum Metod Träff
toggleMatchSelection(key)} aria-label={`Tillämpa matchning för ${m.transaction_description}`} /> {m.transaction_description} {formatDate(m.transaction_date)} {formatAmount(m.transaction_amount)} ↔ {formatVoucher(m)} {m.entry_description} {formatDate(m.entry_date)} {METHOD_LABELS[m.method] || m.method} {badge.label}
)} {dryRunResults && dryRunResults.length === 0 && (

Inga automatiska matchningar hittades.

)} {/* Unmatched Transactions */} {unmatchedTx.length > 0 && (

Omatchade transaktioner ({unmatchedTx.length})

{unmatchedGlLines.length > 0 && (

{unmatchedGlLines.length} verifikation{unmatchedGlLines.length === 1 ? '' : 'er'} att matcha mot

)}
{unmatchedTruncated && (

Visar de senaste 500 transaktionerna: begränsa datumintervallet för att se fler.

)} {/* One line per transaction (locked convention 4). This list used to render a ~230px card per row, each carrying an always-open, always empty "Matcha mot verifikation" search field: for a company with a real backlog that is thousands of pixels of empty search boxes, and it gave the RAREST action (pairing with an existing voucher) the only visible affordance while the common ones (bokför, ignorera) stayed hidden behind the row menu. The picker now renders for the one row the user opens, and its ranked candidates are fetched then. */}
{unmatchedTx.map((tx) => { const isPositive = tx.amount > 0 const isOpen = expandedTxId === tx.id // Quick-book options matching the transaction's direction. The // bank leg books to the SELECTED account (the categorize endpoint // rewrites it from the cash_account_id), so these are correct on // any account, not just 1930. const quickBooks = QUICK_BOOK_TEMPLATES.filter((t) => isPositive ? t.direction === 'income' : t.direction === 'expense', ) // Other enabled cash accounts this row could move to. Same // currency only: the server hard-rejects a cross-currency move // (the row would vanish from every report's currency scope). const moveTargets = cashAccounts.filter( (a) => a.enabled && a.ledger_account !== accountNumber && a.currency.toUpperCase() === tx.currency.toUpperCase(), ) return ( {isOpen && ( )} ) })}
Datum Beskrivning Belopp
{formatDate(tx.date)} {isPositive ? '+' : ''} {formatCurrency(tx.amount, tx.currency)} {/* Opens the picker; the button INSIDE it performs the match. Two controls labelled "Matcha" in one row would read as the same action twice. */} {quickBooks.length > 0 && ( <> Bokför direkt {quickBooks.map((t) => { // Read as "debit mot credit": income debits the // bank (selected account), credits revenue; // expense debits the cost account, credits bank. const legs = isPositive ? `${accountNumber} mot ${t.account}` : `${t.account} mot ${accountNumber}` return ( handleQuickBook(tx.id, t.id)} disabled={actionLoading === tx.id} >
Bokför som {t.label} {legs}
) })} )} {moveTargets.length > 0 && ( <> Flytta till annat konto {moveTargets.map((account) => ( handleMoveToAccount(tx, account)} disabled={actionLoading === tx.id} >
Flytta till {account.name || `Bankkonto ${account.currency}`} {account.ledger_account}
))} )} handleIgnore(tx)} disabled={actionLoading === tx.id} >
Ignorera transaktion… Dölj utan att bokföra. Går att återställa.
{glLines.length === 0 && ( Inga omatchade verifikationer på{' '} )}
setSelectedMatch((prev) => ({ ...prev, [tx.id]: v })) } disabled={linkLoading === tx.id || glLines.length === 0} />
)} {/* Unmatched GL Lines */} {unmatchedGlLines.length > 0 && (

Omatchade verifikationer på ({unmatchedGlLines.length})

{unmatchedGlLines.map((line) => { const amount = line.debit_amount > 0 ? line.debit_amount : -line.credit_amount const isRetaggable = line.source_type === 'manual' || line.source_type === 'import' return ( ) })}
Ver.nr Datum Beskrivning Belopp Typ
{formatVoucher(line)} {formatDate(line.entry_date)} {line.line_description || line.entry_description} {formatCurrency(amount)} {SOURCE_TYPE_LABELS[line.source_type] ?? line.source_type} {isRetaggable && ( )}
)} {/* Ignored transactions (undo) */} {ignoredTx.length > 0 && (
{showIgnored && (
{ignoredTx.map((tx) => ( ))}
Datum Beskrivning Valuta Belopp
{formatDate(tx.date)} {tx.description} {tx.currency} {formatCurrency(tx.amount, tx.currency)}
)}
)} {/* Recently Matched */} {matchedTx.length > 0 && (
{showMatched && (
{matchedTx.map((tx) => ( ))}
Datum Beskrivning Belopp Metod Verifikation
{formatDate(tx.date)} {tx.description} {formatCurrency(tx.amount, accountCurrency)} {tx.reconciliation_method ? METHOD_LABELS[tx.reconciliation_method] || tx.reconciliation_method : null} {tx.journal_entry_id && ( Öppna verifikat )} {tx.reconciliation_method && ( )}
)}
)} {/* Empty state */} {unmatchedTx.length === 0 && glLines.length === 0 && matchedTx.length === 0 && ignoredTx.length === 0 && !loading && ( )} {duplicateWarning && ( setDuplicateWarning(null)} matchTransaction={{ id: duplicateWarning.transactionId, // The view is scoped to one ledger account; resolve its cash // account so the match links on the account being reconciled. cash_account_id: cashAccounts.find((a) => a.ledger_account === accountNumber)?.id ?? null, currency: unmatchedTx.find((t) => t.id === duplicateWarning.transactionId)?.currency ?? accountCurrency, }} onMatched={async () => { setDuplicateWarning(null) toast({ variant: 'success', title: 'Transaktionen matchades mot verifikatet' }) await fetchAll({ silent: true }) }} onIgnored={async () => { setDuplicateWarning(null) toast({ variant: 'success', title: 'Transaktionen ignorerad' }) await fetchAll({ silent: true }) }} onBookAnyway={async () => { const retry = duplicateWarning?.retry setDuplicateProcessing(true) try { setDuplicateWarning(null) if (retry) await retry() } finally { setDuplicateProcessing(false) } }} /> )}
) }