From 9ebb2e518f5999a8d8ac69e6a0643fc61443a374 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Tue, 25 Aug 2026 09:03:17 +0200 Subject: [PATCH] feat(reconciliation): absorb the last bank-view tools and retire /reports/bank-reconciliation (#1871) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The old Bankavstämning report page was the only place a user could still tag a bank row as ingående balans or move it to another bank account, so the new /reconciliation page kept linking out to it and the reconciliation lived in two places. Both row tools now live on the account overview (hover-revealed, same endpoints), the ?autorun=1 deep link from the transactions inbox runs the matcher on the new page, and the report slug redirects: old links, ⌘K, the bokslut readiness wizard and the ignore toast all land on /reconciliation. BankReconciliationView and its FocusedReport branches (own range preset, help popover, autoRun plumbing) are removed; the source-grepping parity test for its quick-book path goes with it. Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 --- app/(dashboard)/reports/[slug]/page.tsx | 9 +- app/(dashboard)/transactions/page.tsx | 6 +- components/common/CommandPalette.tsx | 2 +- components/reconciliation/AccountOverview.tsx | 112 +- .../ReconciliationWorkspace.tsx | 1 + components/reports/BankReconciliationView.tsx | 1931 ----------------- components/reports/FocusedReport.tsx | 50 +- .../__tests__/booking-feedback-parity.test.ts | 21 +- lib/bokslut/readiness-aggregator.ts | 5 +- lib/reports/catalog.ts | 4 + messages/en.json | 6 +- messages/sv.json | 6 +- 12 files changed, 130 insertions(+), 2023 deletions(-) delete mode 100644 components/reports/BankReconciliationView.tsx diff --git a/app/(dashboard)/reports/[slug]/page.tsx b/app/(dashboard)/reports/[slug]/page.tsx index 656c8a6d..8068a570 100644 --- a/app/(dashboard)/reports/[slug]/page.tsx +++ b/app/(dashboard)/reports/[slug]/page.tsx @@ -19,7 +19,11 @@ export default async function ReportSlugPage({ const [{ slug }, query] = await Promise.all([params, searchParams]) const report = getReport(slug) if (!report) notFound() - if (report.route) redirect(report.route) + if (report.route) { + // The old bankavstämning deep link (?autorun=1 from the transactions + // inbox) keeps working on the page that absorbed it. + redirect(slug === 'bank-reconciliation' && query.autorun === '1' ? `${report.route}?autorun=1` : report.route) + } const [{ supabase }, companyId] = await Promise.all([ getDashboardAuthContext(), @@ -39,9 +43,6 @@ export default async function ReportSlugPage({ slug={slug} initialPeriods={(periods ?? []) as FiscalPeriod[]} initialCompanyId={companyId} - // ?autorun=1 deep-links (e.g. the transactions inbox banner) ask the - // bank-reconciliation view to run its dry-run preview once on load. - autoRun={query.autorun === '1'} /> ) } diff --git a/app/(dashboard)/transactions/page.tsx b/app/(dashboard)/transactions/page.tsx index f29b4e2d..de522dc5 100644 --- a/app/(dashboard)/transactions/page.tsx +++ b/app/(dashboard)/transactions/page.tsx @@ -3180,7 +3180,7 @@ export default function TransactionsPage() { description: `${successes} transaktioner ignorerade`, action: ( - Bankavstämning + Avstämning ), }) @@ -3852,7 +3852,7 @@ export default function TransactionsPage() { className="px-1 pt-3" action={{ label: t('recon_attn_action'), - href: '/reports/bank-reconciliation?autorun=1', + href: '/reconciliation?autorun=1', }} > {t('recon_attn', { count: selectableInboxIds.length })} @@ -3900,7 +3900,7 @@ export default function TransactionsPage() { Bankavstämning → diff --git a/components/common/CommandPalette.tsx b/components/common/CommandPalette.tsx index 39ff6651..359528b3 100644 --- a/components/common/CommandPalette.tsx +++ b/components/common/CommandPalette.tsx @@ -70,7 +70,7 @@ const PAGE_ENTRIES: Entry[] = [ { id: 'rapport-huvudbok', label: 'Visa rapport: Huvudbok', icon: BookOpen, href: '/reports/huvudbok', keywords: 'rapport huvudbok ledger general konto saldo transaktioner per konto verifikat verifikationer verifikationer per konto kontoutdrag kontoanalys kontokort kontohistorik balance account statement transactions vouchers' }, { id: 'rapport-kundreskontra', label: 'Visa rapport: Kundreskontra', icon: Users, href: '/reports/kundreskontra', keywords: 'rapport kundreskontra ar kundfordringar' }, { id: 'avstamning', label: 'Avstämning', hint: 'Stäm av bank och skattekonto', icon: Scale, href: '/reconciliation', keywords: 'avstämning stäm av bank skattekonto matcha reconcile reconciliation 1630 1930' }, - { id: 'rapport-bankavstamning', label: 'Bankavstämning', hint: 'Stäm av bank mot bokföring', icon: ArrowLeftRight, href: '/reports/bank-reconciliation', keywords: 'avstämning stäm av bank matcha banktransaktioner reconcile reconciliation 1930' }, + { id: 'rapport-bankavstamning', label: 'Bankavstämning', hint: 'Stäm av bank mot bokföring', icon: ArrowLeftRight, href: '/reconciliation', keywords: 'avstämning stäm av bank matcha banktransaktioner reconcile reconciliation 1930' }, { id: 'importera', label: 'Importera', icon: Upload, href: '/import' }, { id: 'granskning', label: 'Granskning', icon: ClipboardCheck, href: '/pending', keywords: 'pending review' }, { id: 'löner', label: 'Löner', icon: HandCoins, href: '/salary' }, diff --git a/components/reconciliation/AccountOverview.tsx b/components/reconciliation/AccountOverview.tsx index 4f5f9ec7..799e6eac 100644 --- a/components/reconciliation/AccountOverview.tsx +++ b/components/reconciliation/AccountOverview.tsx @@ -1,9 +1,10 @@ 'use client' -import { Fragment, useCallback, useEffect, useMemo, useState, type ReactNode } from 'react' +import { Fragment, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react' import dynamic from 'next/dynamic' import Link from 'next/link' import { useLocale, useTranslations } from 'next-intl' +import { useSearchParams } from 'next/navigation' import { Button } from '@/components/ui/button' import { AttnLine } from '@/components/ui/attn-line' import { Skeleton } from '@/components/ui/skeleton' @@ -67,6 +68,8 @@ export interface ReconciliationWindow { interface AccountOverviewProps { account: ReconciliationAccount + /** The other bank accounts in the rail: targets for "Flytta till konto". */ + otherBankAccounts?: ReconciliationAccount[] /** The account rail. Rendered inside the summary grid so the items table below can span the full page width (the approved layout). */ rail: ReactNode /** The selected period: scopes the bank bridge and the item windows; its end is the default sign-off date. */ @@ -75,7 +78,7 @@ interface AccountOverviewProps { onChanged: () => void } -export function AccountOverview({ account, rail, window, onChanged }: AccountOverviewProps) { +export function AccountOverview({ account, rail, otherBankAccounts = [], window, onChanged }: AccountOverviewProps) { const t = useTranslations('reconciliation') const locale = useLocale() const { toast } = useToast() @@ -87,6 +90,9 @@ export function AccountOverview({ account, rail, window, onChanged }: AccountOve const [bookRow, setBookRow] = useState(null) const [signoffOpen, setSignoffOpen] = useState(false) const [matcher, setMatcher] = useState(null) + const searchParams = useSearchParams() + const autorunRequested = searchParams.get('autorun') === '1' + const autorunDone = useRef(false) const isSkv = account.kind === 'skattekonto' const base = `/api/reconciliation/accounts/${encodeURIComponent(account.account_key)}` @@ -317,6 +323,48 @@ export function AccountOverview({ account, rail, window, onChanged }: AccountOve } } + async function markOpeningBalance(item: ReconciliationItem) { + setBusy(item.item_id) + try { + const data = await postJson('/api/reconciliation/bank/mark-opening-balance', { journal_entry_id: item.item_id }) + if (data !== null) { + toast({ title: t('toast_marked_ib') }) + await refresh() + } + } finally { + setBusy(null) + } + } + + async function moveToAccount(item: ReconciliationItem, target: ReconciliationAccount) { + setBusy(item.item_id) + try { + const res = await fetch(`/api/transactions/${item.item_id}/cash-account`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ account_number: target.account_number }), + }) + const json = await res.json().catch(() => ({})) + if (!res.ok) { + toast({ title: t('toast_failed'), description: getUserErrorMessage(json, { statusCode: res.status }), variant: 'destructive' }) + return + } + toast({ title: t('toast_moved', { account: `${target.name} (${target.account_number})` }) }) + await refresh() + } finally { + setBusy(null) + } + } + + // ?autorun=1 (the old bankavstämning deep link from the transactions inbox): + // run the matcher preview once the bridge is up, once per mount. + useEffect(() => { + if (!autorunRequested || autorunDone.current || isSkv || !status) return + autorunDone.current = true + void runMatcher() + // eslint-disable-next-line react-hooks/exhaustive-deps -- fire once when the status first loads + }, [autorunRequested, isSkv, status]) + // ---- render ------------------------------------------------------------- if (loadError) { @@ -431,7 +479,6 @@ export function AccountOverview({ account, rail, window, onChanged }: AccountOve (byBucket.get('unmatched_external')?.length ?? 0) + (byBucket.get('unmatched_ledger')?.length ?? 0) - const bankViewHref = '/reports/bank-reconciliation' // Default sign-off date: the window end, never past today nor past the // skattekonto snapshot. The button hides when that date is already signed. @@ -537,20 +584,26 @@ export function AccountOverview({ account, rail, window, onChanged }: AccountOve {t('signoff_button', { date: formatDate(signoffDefaultDate) })} )} - - - {isSkv ? t('action_open_skattekonto') : t('action_open_bank_view')} - - + {isSkv && ( + + + {t('action_open_skattekonto')} + + + )} {items.older_unmatched_count > 0 && (

{t('older_unmatched', { count: items.older_unmatched_count })} - {' · '} - - {t('older_show')} - + {isSkv && ( + <> + {' · '} + + {t('older_show')} + + + )}

)} @@ -635,6 +688,9 @@ export function AccountOverview({ account, rail, window, onChanged }: AccountOve onIgnore={() => void setIgnored(item, true)} onUnignore={() => void setIgnored(item, false)} onBook={() => setBookRow(item)} + onMarkIb={!isSkv && item.side === 'ledger' && item.bucket === 'unmatched_ledger' ? () => void markOpeningBalance(item) : undefined} + moveTargets={!isSkv && item.item_type === 'transaction' && item.bucket === 'unmatched_external' ? otherBankAccounts : []} + onMove={(target) => void moveToAccount(item, target)} /> ))} @@ -710,6 +766,11 @@ interface ItemRowProps { onIgnore: () => void onUnignore: () => void onBook: () => void + /** "Märk som IB" for a ledger row without a bank counterpart (bank accounts). */ + onMarkIb?: () => void + /** Other bank accounts a stray transaction can be moved to. */ + moveTargets: ReconciliationAccount[] + onMove: (target: ReconciliationAccount) => void } function ItemRow({ @@ -724,6 +785,9 @@ function ItemRow({ onIgnore, onUnignore, onBook, + onMarkIb, + moveTargets, + onMove, }: ItemRowProps) { const t = useTranslations('reconciliation') const can = (a: ReconciliationItem['actions'][number]) => item.actions.includes(a) @@ -837,6 +901,30 @@ function ItemRow({ {t('row_unignore')} )} + {onMarkIb && ( + + )} + {moveTargets.length > 0 && ( + + )} diff --git a/components/reconciliation/ReconciliationWorkspace.tsx b/components/reconciliation/ReconciliationWorkspace.tsx index 9c015936..21cbec3f 100644 --- a/components/reconciliation/ReconciliationWorkspace.tsx +++ b/components/reconciliation/ReconciliationWorkspace.tsx @@ -214,6 +214,7 @@ export function ReconciliationWorkspace({ initialPeriods, initialCompanyId }: Re key={selected.account_key} account={selected} rail={} + otherBankAccounts={accounts.filter((a) => a.kind === 'bank' && a.account_key !== selected.account_key && !a.superseded_by)} window={window} onChanged={() => void load()} /> diff --git a/components/reports/BankReconciliationView.tsx b/components/reports/BankReconciliationView.tsx deleted file mode 100644 index 81809ca6..00000000 --- a/components/reports/BankReconciliationView.tsx +++ /dev/null @@ -1,1931 +0,0 @@ -'use client' - -import Link from 'next/link' -import { Fragment, useState, useEffect, useCallback, useMemo, 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 { 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, EyeOff, PiggyBank, MoreHorizontal } from 'lucide-react' -import { formatCurrency, formatDate } from '@/lib/utils' -// Pure module, safe in the client bundle: lib/reconciliation/bank-reconciliation -// pulls in the event bus and the match log and must never be imported here. -import { hasVoucherCandidate } from '@/lib/reconciliation/voucher-candidate' -import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver' -import { CashAccountSelector } from '@/components/common/CashAccountSelector' -import type { DateRangeValue } from '@/components/common/ReportDateRange' -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 - /** - * Narrowing applied by the page-level ReportDateRange. Empty (`{}` or - * undefined) means the whole räkenskapsår, which is what a reconciliation - * normally runs over. - */ - dateRange?: DateRangeValue - /** - * 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, - dateRange, - 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). - // - // Narrowing inside the year is owned by the page too (ReportDateRange), so - // this view no longer holds date state at all. It used to render its own - // "Datum från / Datum till" inputs behind a "Filtrera" button: a second - // period control competing with the header's picker (convention 8), and the - // source of the "typed but not applied" state that had to be explained in an - // attention line. The window now changes only through a control that applies - // immediately, so there is nothing to be dirty. - const [accountNumber, setAccountNumber] = useState('1930') - const [cashAccounts, setCashAccounts] = useState([]) - // The window the whole surface runs on: the räkenskapsår, narrowed by the - // page's range control when it is set. `toDate` is clamped to today for a - // still-open year so the view never claims to reconcile into the future - // (the ledger can hold future-dated vouchers; the bank feed cannot). - const { windowFrom, windowTo } = useMemo(() => { - const today = new Date().toISOString().slice(0, 10) - const from = dateRange?.fromDate ?? periodBounds?.start ?? '' - const periodEnd = periodBounds?.end - const to = - dateRange?.toDate ?? (periodEnd && periodEnd < today ? periodEnd : today) - return { windowFrom: from, windowTo: to } - }, [dateRange?.fromDate, dateRange?.toDate, periodBounds?.start, periodBounds?.end]) - - 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)) - - - - // 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 = windowFrom - const toValue = windowTo - 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) - } - // The window is now a prop-derived value that only changes when the user - // picks a different year or range, so it belongs in the dependency list: - // there is no keystroke-level churn to protect against any more, and the - // lists must never lag behind the control that sets them. - // - // periodId is in here as belt-and-braces: the window is derived from - // periodBounds, so a year switch normally changes it, but a period with no - // bounds would derive the SAME window for every year and silently skip the - // refetch. Keying on the id too makes a year switch always reload. The lint - // rule cannot see that because the id is not read inside the callback. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [accountNumber, accountCurrency, includeMatched, windowFrom, windowTo, periodId]) - - // Load on mount and whenever fetchAll's identity changes: bank account, - // currency, the matched-toggle, or the window itself. The old off-by-one trap - // here (a year switch fetching the PREVIOUS year's window because the date - // refs updated a commit late) is gone with the refs: windowFrom/windowTo are - // derived during render, so the fetch below always sees the current window. - useEffect(() => { - fetchAll() - }, [fetchAll]) - - // 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) - } - } - - // Run the matcher automatically once per window, instead of waiting for a - // button many users never found: the old flow left people matching a whole - // migration row by row next to a control that said only "Förhandsgranska". - // It is a dry run, so nothing is written and nothing is applied without the - // explicit Tillämpa below. Gated on the first load having recorded - // appliedDates, so the preview can never cover a different window than the - // on-screen lists. - const autoRunConsumedRef = useRef(null) - useEffect(() => { - if (loading || !appliedDates) return - // Once per window+account, so a silent refetch or a matched-toggle flip on - // the same window does not re-fire it, while switching year or account does. - const runKey = `${accountNumber}:${appliedDates.from}:${appliedDates.to}` - if (autoRunConsumedRef.current === runKey) return - // Nothing to match: don't spend a server-side matching pass on a clean - // window. ?autorun=1 (the transactions-inbox deep link) is an explicit - // "run it" and overrides that, so the user who clicked it still gets a - // result rather than silence. - if (!autoRun && unmatchedTx.length === 0) return - autoRunConsumedRef.current = runKey - 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, accountNumber, 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, - // so a row can never be offered a candidate set the tables on screen - // were not built from. The derived window is the pre-first-load - // fallback (they agree except while a fresh load is in flight). - const from = appliedDates?.from ?? windowFrom - const to = appliedDates?.to ?? windowTo - 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, windowFrom, windowTo], - ) - - /** 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. - // Unmatched rows that no voucher on this account could settle: bookkeeping, - // not reconciliation. Counted once here rather than per row. - const bookOnlyCount = unmatchedTx.filter( - (tx) => !hasVoucherCandidate(tx.amount, unmatchedGlLines, accountCurrency), - ).length - // Land on the account being reconciled, not on every bank source: an - // `acct:` filter is exactly the scope this page is showing. Falls back to - // all bank rows when the cash account has not loaded yet, which is a wider - // list but never a wrong one. - const reconciledCashAccountId = cashAccounts.find( - (a) => a.ledger_account === accountNumber, - )?.id - const bookOnlySource = reconciledCashAccountId ? `acct:${reconciledCashAccountId}` : 'bank' - - 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) */} -
- {/* The page's one ochre sentence (convention 6) now reports a run in - progress. The old line counted unmatched rows and pointed at the - button to press; with the matcher running by itself that promotion - is obsolete by construction, and the count it carried is already on - the card ("N poster kvar att förklara") and the section header. */} - {runLoading && {t('recon_matching_attn')}} -
- -
- - {dryRunResults && dryRunResults.length > 0 && ( - - )} -
-
- - {/* 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 ( - - - - - - - - - - - - ) - })} - -
TransaktionDatumBelopp↔VerifikationDatumMetodTrä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}) -

-
- {/* The split that matters on this page: rows a voucher could - settle are reconciliation work and stay here; the rest are - unbooked affärshändelser and belong in Transaktioner, which - already does that job well. */} - {bookOnlyCount > 0 && ( - - )} - {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 - const hasCandidate = hasVoucherCandidate(tx.amount, unmatchedGlLines, accountCurrency) - // 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 && hasCandidate && ( - - - - )} - - ) - })} - -
DatumBeskrivningBelopp
- {formatDate(tx.date)} - - {/* Only a row that HAS something to expand into is a - toggle. On a row no voucher can settle, a chevron - and a click target that opens nothing is a dead - affordance: it renders as plain text and the action - cell offers Bokför instead. - The reference sits inline rather than on a second - line: convention 4 keeps list rows one line high. */} - {hasCandidate ? ( - - ) : ( - - {tx.description} - {tx.reference && ( - - · {tx.reference} - - )} - - )} - - {isPositive ? '+' : ''} - {formatCurrency(tx.amount, tx.currency)} - - {/* A row with no voucher that could possibly settle it - is not reconciliation work at all, it is bookkeeping: - send it to the surface that does that instead of - offering a picker that holds nothing for it. - ?highlight= opens the row's categorize panel. */} - {hasCandidate ? ( - // 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.nrDatumBeskrivningBeloppTyp
- - {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) => ( - - - - - - - - ))} - -
DatumBeskrivningValutaBelopp
{formatDate(tx.date)}{tx.description}{tx.currency} - {formatCurrency(tx.amount, tx.currency)} - - -
-
- )} -
- )} - - {/* Recently Matched */} - {matchedTx.length > 0 && ( -
- - {showMatched && ( -
- - - - - - - - - - - - - {matchedTx.map((tx) => ( - - - - - - - - - ))} - -
DatumBeskrivningBeloppMetodVerifikation
{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) - } - }} - /> - )} - - -
- ) -} diff --git a/components/reports/FocusedReport.tsx b/components/reports/FocusedReport.tsx index 73fb627a..7255804e 100644 --- a/components/reports/FocusedReport.tsx +++ b/components/reports/FocusedReport.tsx @@ -7,7 +7,6 @@ import { useRouter, useSearchParams } from 'next/navigation' import { useTranslations } from 'next-intl' import { ChevronLeft } from 'lucide-react' import { PageHeader } from '@/components/ui/page-header' -import { HelpPopover } from '@/components/ui/help-popover' import { EmptyState } from '@/components/ui/empty-state' import { Card, CardContent } from '@/components/ui/card' import { Skeleton } from '@/components/ui/skeleton' @@ -18,10 +17,6 @@ import { DimensionFilter, type DimensionFilterValue } from '@/components/reports import { DATE_RANGE_SLUGS, DIMENSION_FILTER_SLUGS, getReport } from '@/lib/reports/catalog' import type { FiscalPeriod } from '@/types' -/** Preset memory for the bank-reconciliation range, deliberately separate from - * the shared report-family key so the two cannot steer each other. */ -const RECONCILIATION_RANGE_KEY_PREFIX = 'Accounted:recon-range-preset:' - function ReportViewLoading() { return ( @@ -60,10 +55,6 @@ const BehandlingshistorikView = dynamic(() => import('./BehandlingshistorikView').then((module) => ({ default: module.BehandlingshistorikView })), { loading: ReportViewLoading }, ) -const BankReconciliationView = dynamic(() => - import('./BankReconciliationView').then((module) => ({ default: module.BankReconciliationView })), - { loading: ReportViewLoading }, -) /** * The focused single-report experience at /reports/[slug]. Carries one report: @@ -76,13 +67,10 @@ function FocusedReportInner({ slug, initialPeriods, initialCompanyId, - autoRun, }: { slug: string initialPeriods: FiscalPeriod[] initialCompanyId: string | null - /** ?autorun=1: the bank-reconciliation view runs its preview once on load. */ - autoRun?: boolean }) { const router = useRouter() const searchParams = useSearchParams() @@ -134,16 +122,7 @@ function FocusedReportInner({ // Page help behind a "?" (UI-migration convention 7): the report // bodies carry no instructional copy in the page flow. help={ - slug === 'bank-reconciliation' ? ( - -
-

{t('help_bank_reconciliation_scope')}

-

{t('help_bank_reconciliation_preview')}

-

{t('help_bank_reconciliation_ib')}

-

{t('help_bank_reconciliation_ignored')}

-
-
- ) : undefined + undefined } action={ )} @@ -198,14 +169,12 @@ function FocusedReportInner({ slug={slug} reportName={reportName} periodId={selectedPeriod} - periodBounds={selectedPeriodBounds} dateRange={dateRange} dimensionFilter={dimensionFilter} accountFilter={accountFilter} isEnskildFirma={isEnskildFirma} isAktiebolag={isAktiebolag} onNavigateToAccount={navigateToAccount} - autoRun={autoRun} /> ) : ( void - autoRun?: boolean }) { switch (slug) { case 'resultatrapport': @@ -275,15 +240,6 @@ function FocusedView({ return case 'behandlingshistorik': return - case 'bank-reconciliation': - return ( - - ) default: return null } @@ -293,13 +249,10 @@ export function FocusedReport({ slug, initialPeriods, initialCompanyId, - autoRun, }: { slug: string initialPeriods: FiscalPeriod[] initialCompanyId: string | null - /** ?autorun=1: the bank-reconciliation view runs its preview once on load. */ - autoRun?: boolean }) { return ( }> @@ -307,7 +260,6 @@ export function FocusedReport({ slug={slug} initialPeriods={initialPeriods} initialCompanyId={initialCompanyId} - autoRun={autoRun} /> ) diff --git a/components/transactions/__tests__/booking-feedback-parity.test.ts b/components/transactions/__tests__/booking-feedback-parity.test.ts index ca4c175a..dca76168 100644 --- a/components/transactions/__tests__/booking-feedback-parity.test.ts +++ b/components/transactions/__tests__/booking-feedback-parity.test.ts @@ -112,14 +112,11 @@ describe('transactions page booking feedback', () => { * Duplicate-guard feedback parity: every client of POST /categorize must route * a TRANSACTION_BOOK_POSSIBLE_DUPLICATE 409 into DuplicateBookingDialog (which * offers match / ignore / book-anyway), never into a destructive toast that - * names no way forward. Two clients used to dead-end: the counterparty- - * template branch of handleQuickReviewConfirm, and BankReconciliationView's - * quick-book. + * names no way forward. The counterparty-template branch of + * handleQuickReviewConfirm used to dead-end. (The old BankReconciliationView + * quick-book was retired with the view on 2026-08-25; /reconciliation books + * through the shared transactions inbox flow instead.) */ -const BANK_RECON_SRC = fs.readFileSync( - path.resolve(__dirname, '../../reports/BankReconciliationView.tsx'), - 'utf8', -) describe('duplicate-guard 409 routing parity', () => { it('handles the duplicate code on both booking paths of the transactions page', () => { @@ -136,14 +133,4 @@ describe('duplicate-guard 409 routing parity', () => { /TRANSACTION_BOOK_POSSIBLE_DUPLICATE'[\s\S]{0,600}cpCategorize\(\{\s*\n?\s*expectedDuplicateJournalEntryId: candidate\.journal_entry_id,/, ) }) - - it('routes the quick-book 409 on the reconciliation page into the shared dialog', () => { - expect(BANK_RECON_SRC).toContain("'TRANSACTION_BOOK_POSSIBLE_DUPLICATE'") - expect(BANK_RECON_SRC).toContain(' 0 ? `${reconciliation.unmatched_transaction_count} banktransaktioner är inte matchade. Avstäm banken innan bokslut.` : `Bankavstämningen visar en differens på ${reconciliation.difference.toFixed(2)} kr.`, - // Bankavstämning's real route: the earlier '/reconciliation/bank' href - // pointed at a page that has never existed, so the wizard's "Öppna" - // link 404ed. - href: '/reports/bank-reconciliation', + href: '/reconciliation', }) } diff --git a/lib/reports/catalog.ts b/lib/reports/catalog.ts index 4598ac47..430cea68 100644 --- a/lib/reports/catalog.ts +++ b/lib/reports/catalog.ts @@ -305,6 +305,10 @@ export const REPORT_CATALOG: ReportDescriptor[] = [ // uses the shared ReportDateRange like every other report, mounted with a // full-year default and its own preset memory (see FocusedReport). params: 'fiscal-range', + // 2026-08-25: the bank view was absorbed by /reconciliation (matcher, + // manual N:1 matching, residual booking, IB tag, move-to-account all live + // there). The slug stays for old links and the report library; it redirects. + route: '/reconciliation', }, // --- Export & arkiv: library-only --- diff --git a/messages/en.json b/messages/en.json index 45a8184e..6a109574 100644 --- a/messages/en.json +++ b/messages/en.json @@ -7922,7 +7922,11 @@ "residual_interest_expense": "Interest expense (8410)", "residual_interest_income": "Interest income (8310)", "residual_rounding": "Rounding (3740)", - "toast_residual_booked": "{amount} booked and linked" + "toast_residual_booked": "{amount} booked and linked", + "row_mark_ib": "Mark as opening balance", + "row_move": "Move to account", + "toast_marked_ib": "The voucher was marked as opening balance", + "toast_moved": "The transaction was moved to {account}" }, "skattekonto": { "help_text": "The balance and events are fetched from Skatteverket and synced automatically every night. Completed events are booked against 1630 Skattekonto, usually automatically; anything that cannot be matched is flagged in the list. Pay in via bankgiro 5050-1055 with your OCR number.", diff --git a/messages/sv.json b/messages/sv.json index 1bd3328a..6de1abbd 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -7922,7 +7922,11 @@ "residual_interest_expense": "Räntekostnad (8410)", "residual_interest_income": "Ränteintäkt (8310)", "residual_rounding": "Öresavrundning (3740)", - "toast_residual_booked": "{amount} bokfört och kopplat" + "toast_residual_booked": "{amount} bokfört och kopplat", + "row_mark_ib": "Märk som IB", + "row_move": "Flytta till konto", + "toast_marked_ib": "Verifikatet markerades som ingående balans", + "toast_moved": "Transaktionen flyttades till {account}" }, "skattekonto": { "help_text": "Saldot och händelserna hämtas från Skatteverket och synkas automatiskt varje natt. Genomförda händelser bokförs mot 1630 Skattekonto, oftast automatiskt; det som inte kan matchas flaggas i listan. Betala in via bankgiro 5050-1055 med ditt OCR-nummer.",