diff --git a/DECISIONS.md b/DECISIONS.md index 0ffd14b3..f1928f84 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1111,3 +1111,7 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-20] `unmatched_gl_line_total` is null (not 0) on a foreign cash account: get_account_gl_lines_for_matching projects neither currency nor amount_in_currency, so its rows carry no amount in the account's currency. The card falls back to the flat figures there rather than showing a bridge whose middle row would silently be a SEK number in a EUR column. [2026-08-20] getReconciliationStatus now passes effectiveFrom (the IB floor) to fetchGLLinesForMatching instead of the caller's raw dateFrom. countedLines and countedTx were already clamped to that floor, so a window opening before the account's opening balance (the v1 endpoint's default, or any multi-year range) counted vouchers from a period whose movements the reconciliation deliberately drops. [2026-08-20] A non-zero unexplained difference is stated factually, never in destructive red. Measured on prod over the 206 single-1930-account companies with >=10 transactions: 136 are exactly 0,00 and 63 are >=100 kr out, and the dominant cause is ledger lines the candidate RPC hides (posted/storno on 127 companies, reversed/bank_transaction on 66, posted/correction on 49) rather than user error. Surfacing those hidden lines is tracked as follow-up work, not fixed here. +[2026-08-20] Bank reconciliation moved to the shared ReportDateRange (catalog params 'fiscal-range') instead of teaching FyPicker a custom range: the view's own "Datum fran/till" inputs plus a Filtrera button were a second period control competing with the header's rakenskapsar picker (convention 8), and FyPicker's value model is a period id, so a range does not fit its contract. ReportDateRange gained defaultPreset and storageKeyPrefix for this. +[2026-08-20] Reconciliation opens on the FULL year and keeps its own range-preset memory, separate from the shared report-family key. Inheriting a "Denna manad" preset last used on Resultatrapport would show an alarming difference for a window the user never chose on this page, and a part-year reconciliation window answers a question nobody asked. +[2026-08-20] The matcher now runs automatically once per window+account when there is unmatched work, instead of waiting for a button many users never found. It is a dry run: nothing is written, and Tillampa still requires an explicit click. ?autorun=1 keeps a distinct meaning (run even on a clean window) so the transactions-inbox deep link still produces a result rather than silence. +[2026-08-20] Unmatched bank rows that no voucher on the account could settle (direction-compatible and equal to the ore) get "Bokfor" linking to /transactions?highlight= instead of a match picker. They are unbooked affarshandelser, not reconciliation work, and the picker held nothing for them. The rule is deliberately strict: a false negative offers booking on a pairable row (a legitimate outcome), a false positive sends the user into an empty picker. diff --git a/components/common/ReportDateRange.tsx b/components/common/ReportDateRange.tsx index 127bc1a3..c1793e0f 100644 --- a/components/common/ReportDateRange.tsx +++ b/components/common/ReportDateRange.tsx @@ -22,6 +22,23 @@ interface Props { periodEnd: string value: DateRangeValue onChange: (next: DateRangeValue) => void + /** + * Preset to open on when this company has no stored choice yet. Defaults to + * YTD, which matches Fortnox/Visma for the resultat-/balansrapport family. + * Bank reconciliation passes 'full_year': a reconciliation is carried out + * over a whole räkenskapsår, and a part-year window makes its difference + * describe a period the user did not ask about. + */ + defaultPreset?: Preset + /** + * localStorage prefix for the remembered preset (companyId is appended). + * Defaults to the range shared by the report family. Pass a page-specific + * prefix where inheriting another report's preset would be wrong rather than + * merely surprising: bank reconciliation opened on a "Denna månad" carried + * over from Resultatrapport would show an alarming difference for a window + * nobody chose. + */ + storageKeyPrefix?: string className?: string } @@ -121,31 +138,33 @@ export function ReportDateRange({ periodEnd, value, onChange, + defaultPreset = 'ytd', + storageKeyPrefix = STORAGE_KEY_PREFIX, className, }: Props) { const t = useTranslations('reports') const { company } = useCompany() - const [preset, setPreset] = useState('ytd') + const [preset, setPreset] = useState(defaultPreset) // Restore last-used preset per company, then resolve it against the // current fiscal period. The period selector lives upstream: when it // changes, we re-resolve so the dates always sit inside the visible year. useEffect(() => { if (!company?.id || typeof window === 'undefined') return - const stored = window.localStorage.getItem(STORAGE_KEY_PREFIX + company.id) as Preset | null - const initial: Preset = stored && PRESETS.includes(stored) ? stored : 'ytd' + const stored = window.localStorage.getItem(storageKeyPrefix + company.id) as Preset | null + const initial: Preset = stored && PRESETS.includes(stored) ? stored : defaultPreset setPreset(initial) if (initial !== 'custom') { onChange(resolvePreset(initial, periodStart, periodEnd, todayIso())) } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [company?.id, periodStart, periodEnd]) + }, [company?.id, periodStart, periodEnd, storageKeyPrefix, defaultPreset]) const handlePreset = useCallback( (next: Preset) => { setPreset(next) if (company?.id && typeof window !== 'undefined') { - window.localStorage.setItem(STORAGE_KEY_PREFIX + company.id, next) + window.localStorage.setItem(storageKeyPrefix + company.id, next) } if (next === 'custom') { // Seed the custom inputs with whatever is currently active so the @@ -157,7 +176,7 @@ export function ReportDateRange({ } onChange(resolvePreset(next, periodStart, periodEnd, todayIso())) }, - [company?.id, onChange, periodEnd, periodStart, value.fromDate, value.toDate], + [company?.id, onChange, periodEnd, periodStart, storageKeyPrefix, value.fromDate, value.toDate], ) const handleFromChange = (raw: string) => { diff --git a/components/reports/BankReconciliationView.tsx b/components/reports/BankReconciliationView.tsx index 14aa8ede..81809ca6 100644 --- a/components/reports/BankReconciliationView.tsx +++ b/components/reports/BankReconciliationView.tsx @@ -1,12 +1,11 @@ 'use client' import Link from 'next/link' -import { Fragment, useState, useEffect, useCallback, useRef } from 'react' +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 { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Badge } from '@/components/ui/badge' import { Switch } from '@/components/ui/switch' @@ -17,10 +16,14 @@ 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 { 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' @@ -211,6 +214,12 @@ interface BankReconciliationViewProps { 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 @@ -220,7 +229,12 @@ interface BankReconciliationViewProps { autoRun?: boolean } -export function BankReconciliationView({ periodId, periodBounds, autoRun }: BankReconciliationViewProps) { +export function BankReconciliationView({ + periodId, + periodBounds, + dateRange, + autoRun, +}: BankReconciliationViewProps) { const t = useTranslations('reports') const [status, setStatus] = useState(null) const [unmatchedTx, setUnmatchedTx] = useState([]) @@ -238,29 +252,29 @@ export function BankReconciliationView({ periodId, periodBounds, autoRun }: Bank // 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 - }) + // 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([]) - // 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]) + // 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 @@ -340,17 +354,7 @@ export function BankReconciliationView({ periodId, periodBounds, autoRun }: Bank // 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 @@ -409,8 +413,8 @@ export function BankReconciliationView({ periodId, periodBounds, autoRun }: Bank rankedFetchInFlight.current.clear() rankedGenerationRef.current++ try { - const fromValue = dateFromRef.current - const toValue = dateToRef.current + const fromValue = windowFrom + const toValue = windowTo const params = new URLSearchParams() if (fromValue) params.set('date_from', fromValue) if (toValue) params.set('date_to', toValue) @@ -481,48 +485,27 @@ export function BankReconciliationView({ periodId, periodBounds, autoRun }: Bank // 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 + // 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 - }, [periodId]) + }, [accountNumber, accountCurrency, includeMatched, windowFrom, windowTo, 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). + // 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, periodId]) + }, [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 @@ -578,22 +561,31 @@ export function BankReconciliationView({ periodId, periodBounds, autoRun }: Bank } } - // 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) + // 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 (!autoRun || autoRunConsumedRef.current) return - if (loading || !appliedDates || datesDirty) return - autoRunConsumedRef.current = true - if (unmatchedTx.length > 0) void handleDryRun() + 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, datesDirty, unmatchedTx.length]) + }, [autoRun, loading, appliedDates, accountNumber, unmatchedTx.length]) const toggleMatchSelection = (key: string) => { setSelectedPairs((prev) => { @@ -709,12 +701,12 @@ export function BankReconciliationView({ periodId, periodBounds, autoRun }: Bank 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 + // 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) @@ -733,7 +725,7 @@ export function BankReconciliationView({ periodId, periodBounds, autoRun }: Bank rankedFetchInFlight.current.delete(transactionId) } }, - [accountNumber, includeMatched, rankedCandidates, appliedDates], + [accountNumber, includeMatched, rankedCandidates, appliedDates, windowFrom, windowTo], ) /** Open one row's match picker (closing any other) and fetch its ranked @@ -1072,6 +1064,20 @@ export function BankReconciliationView({ periodId, periodBounds, autoRun }: Bank // 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) { @@ -1276,45 +1282,25 @@ export function BankReconciliationView({ periodId, periodBounds, autoRun }: Bank {/* 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 })} - )} -
+ {/* 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')}} +
-
- - 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 */} @@ -1413,6 +1393,17 @@ export function BankReconciliationView({ periodId, periodBounds, autoRun }: Bank 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 @@ -1456,6 +1447,7 @@ export function BankReconciliationView({ periodId, periodBounds, autoRun }: Bank {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 @@ -1483,26 +1475,42 @@ export function BankReconciliationView({ periodId, periodBounds, autoRun }: Bank {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} + + )} + + )} - {/* Opens the picker; the button INSIDE it performs the - match. Two controls labelled "Matcha" in one row - would read as the same action twice. */} - + {/* 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. + + ) : ( + + )} @@ -1611,7 +1632,7 @@ export function BankReconciliationView({ periodId, periodBounds, autoRun }: Bank - {isOpen && ( + {isOpen && hasCandidate && (

diff --git a/components/reports/FocusedReport.tsx b/components/reports/FocusedReport.tsx index 0b65c1e7..90691def 100644 --- a/components/reports/FocusedReport.tsx +++ b/components/reports/FocusedReport.tsx @@ -18,6 +18,10 @@ 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 ( @@ -163,6 +167,14 @@ function FocusedReportInner({ periodEnd={selectedPeriodBounds.end} value={dateRange} onChange={setDateRange} + // A reconciliation is carried out over a whole räkenskapsår, so it + // opens on the full year and keeps its own preset memory: inheriting + // a "Denna månad" left over from Resultatrapport would show an + // alarming difference for a window the user never chose here. + defaultPreset={slug === 'bank-reconciliation' ? 'full_year' : undefined} + storageKeyPrefix={ + slug === 'bank-reconciliation' ? RECONCILIATION_RANGE_KEY_PREFIX : undefined + } /> )} @@ -258,7 +270,14 @@ function FocusedView({ case 'supplier-ledger': return case 'bank-reconciliation': - return + return ( + + ) default: return null } diff --git a/lib/reconciliation/__tests__/voucher-candidate.test.ts b/lib/reconciliation/__tests__/voucher-candidate.test.ts new file mode 100644 index 00000000..61c8b5d0 --- /dev/null +++ b/lib/reconciliation/__tests__/voucher-candidate.test.ts @@ -0,0 +1,77 @@ +import { describe, it, expect } from 'vitest' +import { hasVoucherCandidate, type CandidateLine } from '../voucher-candidate' + +/** A ledger line as the candidate RPCs project it: SEK columns, no FX. */ +const line = (debit: number, credit: number): CandidateLine => ({ + debit_amount: debit, + credit_amount: credit, +}) + +describe('hasVoucherCandidate', () => { + it('matches money out against a credit of the same amount', () => { + expect(hasVoucherCandidate(-1250, [line(0, 1250)], 'SEK')).toBe(true) + }) + + it('matches money in against a debit of the same amount', () => { + expect(hasVoucherCandidate(1250, [line(1250, 0)], 'SEK')).toBe(true) + }) + + it('rejects a same-amount line on the wrong side', () => { + // A credit cannot settle an incoming payment: the bank account is debited + // when money arrives. Matching on amount alone would offer every voucher of + // that size regardless of direction. + expect(hasVoucherCandidate(1250, [line(0, 1250)], 'SEK')).toBe(false) + expect(hasVoucherCandidate(-1250, [line(1250, 0)], 'SEK')).toBe(false) + }) + + it('requires equality to the öre', () => { + expect(hasVoucherCandidate(-1250.5, [line(0, 1250.5)], 'SEK')).toBe(true) + expect(hasVoucherCandidate(-1250.5, [line(0, 1250.49)], 'SEK')).toBe(false) + expect(hasVoucherCandidate(-1250, [line(0, 1249.99)], 'SEK')).toBe(false) + }) + + it('compares as integer öre, so float noise cannot decide it', () => { + // 0.1 + 0.2 is 0.30000000000000004; a raw === would say these differ. + expect(hasVoucherCandidate(-(0.1 + 0.2), [line(0, 0.3)], 'SEK')).toBe(true) + }) + + it('handles PostgREST numeric strings on the ledger side', () => { + expect( + hasVoucherCandidate(-1250, [{ debit_amount: '0', credit_amount: '1250.00' }], 'SEK'), + ).toBe(true) + }) + + it('finds a candidate anywhere in the list', () => { + const lines = [line(0, 99), line(500, 0), line(0, 1250)] + expect(hasVoucherCandidate(-1250, lines, 'SEK')).toBe(true) + }) + + it('returns false for an empty candidate list', () => { + expect(hasVoucherCandidate(-1250, [], 'SEK')).toBe(false) + }) + + it('never claims a candidate on a foreign account whose lines carry no FX amount', () => { + // get_account_gl_lines_for_matching projects neither currency nor + // amount_in_currency, so on a EUR account no row can be expressed in EUR. + // Reading the raw SEK columns would offer a 1 250 SEK leg as the settlement + // for a 1 250 EUR bank line. + expect(hasVoucherCandidate(-1250, [line(0, 1250)], 'EUR')).toBe(false) + }) + + it('matches a foreign line that DOES carry the amount in that currency', () => { + expect( + hasVoucherCandidate( + -1250, + [{ debit_amount: 0, credit_amount: 14375, currency: 'EUR', amount_in_currency: 1250 }], + 'EUR', + ), + ).toBe(true) + }) + + it('ignores a zero-amount row and zero-amount lines', () => { + // A zero bank row is not a settlement question, and a zero ledger line + // settles nothing: neither may produce a match on "amounts are equal". + expect(hasVoucherCandidate(0, [line(0, 0)], 'SEK')).toBe(false) + expect(hasVoucherCandidate(-1250, [line(0, 0)], 'SEK')).toBe(false) + }) +}) diff --git a/lib/reconciliation/voucher-candidate.ts b/lib/reconciliation/voucher-candidate.ts new file mode 100644 index 00000000..0d53487b --- /dev/null +++ b/lib/reconciliation/voucher-candidate.ts @@ -0,0 +1,56 @@ +/** + * "Could this bank row be settled by one of these vouchers?" + * + * Split out of the reconciliation view so it can be unit-tested and so the + * component never has to import `lib/reconciliation/bank-reconciliation`, which + * pulls in the event bus and the match log and must not reach the client + * bundle. Only `ledgerLineAmountIn` is needed, and that module is pure. + * + * This is the coarse first pass the server matcher opens with, not the matcher + * itself: it answers whether the reconciliation surface has anything to offer + * for a row at all. A row it says no to is not reconciliation work, it is an + * unbooked affärshändelse, and the UI sends it to the bookkeeping surface + * instead of a picker that holds nothing for it. + */ +import { ledgerLineAmountIn, type LedgerLineAmount } from '@/lib/bookkeeping/ledger-line-amount' + +/** The minimum a ledger line has to expose to be considered here. */ +export type CandidateLine = LedgerLineAmount + +/** + * True when at least one line could settle `amount` on an account reconciled in + * `currency`. + * + * Two rules, both the server matcher's: + * - **Direction.** Money in (`amount > 0`) is settled by a debit on the bank + * account; money out by a credit. `ledgerLineAmountIn` already returns the + * line signed like a bank movement, so this is a sign comparison. + * - **Amount.** Equal to the öre. Compared as integer öre rather than with a + * float epsilon, the same way every other money comparison in this codebase + * settles the question. + * + * Lines that carry no amount in `currency` (a foreign account, whose candidate + * rows hold no FX figure) can never be shown to settle anything: there is no + * honest comparison to make, and claiming one would offer a 1 150 SEK ledger + * leg as the settlement for a 1 150 EUR bank line. + * + * Deliberately strict, because the two errors are not symmetric. A false + * NEGATIVE offers "Bokför" on a row that could also have been paired, and + * booking it is a legitimate outcome. A false POSITIVE sends the user into a + * picker with nothing in it, which is the state the whole page was in before. + */ +export function hasVoucherCandidate( + amount: number, + lines: readonly CandidateLine[], + currency: string, +): boolean { + if (!Number.isFinite(amount) || amount === 0) return false + const targetOre = Math.round(Math.abs(amount) * 100) + return lines.some((line) => { + const lineAmount = ledgerLineAmountIn(line, currency) + if (lineAmount === null) return false + // Opposite sign, or zero: cannot settle this row. + if (amount > 0 ? lineAmount <= 0 : lineAmount >= 0) return false + return Math.round(Math.abs(lineAmount) * 100) === targetOre + }) +} diff --git a/lib/reports/catalog.ts b/lib/reports/catalog.ts index ad43ed7e..16827b7b 100644 --- a/lib/reports/catalog.ts +++ b/lib/reports/catalog.ts @@ -298,7 +298,13 @@ export const REPORT_CATALOG: ReportDescriptor[] = [ // which left the view to host its OWN fiscal-year selector inside a // loading-gated action bar: a render deadlock that hung the page on a // permanent skeleton (#771). - params: 'fiscal', + // + // 'fiscal-range' since 2026-08-20: the view used to host its own "Datum + // från / Datum till" inputs plus a Filtrera button, a second period control + // competing with the header's räkenskapsår picker (convention 8). It now + // uses the shared ReportDateRange like every other report, mounted with a + // full-year default and its own preset memory (see FocusedReport). + params: 'fiscal-range', }, // --- Export & arkiv: library-only --- diff --git a/messages/en.json b/messages/en.json index a508ab7c..05750b3e 100644 --- a/messages/en.json +++ b/messages/en.json @@ -6525,10 +6525,14 @@ "recent_heading": "Recently opened", "back_to_library": "Reports", "help_bank_reconciliation_scope": "The reconciliation runs against one bank account at a time (e.g. 1930). Other bank accounts, such as Plusgiro 1920, credit card 1940 or currency accounts, are reconciled separately: pick the account in the toolbar.", - "help_bank_reconciliation_preview": "The preview pre-selects strong matches. Approximate matches must be ticked manually after you have reviewed the voucher.", + "help_bank_reconciliation_preview": "Matching runs automatically when there are unmatched transactions and pre-selects strong matches. Nothing is posted until you click Apply; approximate matches must be ticked manually after you have reviewed the voucher.", "help_bank_reconciliation_ib": "Is a manually booked or imported voucher actually an opening balance? Mark it as IB and it is excluded from the reconciliation and shown separately.", "help_bank_reconciliation_ignored": "Ignored transactions are hidden from the reconciliation without being booked. They do not affect the balance and can be restored at any time.", - "recon_unmatched_attn": "{count, plural, one {1 unmatched transaction: Preview finds automatic matches.} other {# unmatched transactions: Preview finds automatic matches.}}", + "recon_match_automatically": "Match automatically", + "recon_matching": "Matching…", + "recon_matching_attn": "Looking for automatic matches against your vouchers…", + "recon_book_row": "Book", + "recon_book_rest": "{count, plural, one {Book 1 row in Transactions} other {Book # rows in Transactions}}", "recon_progress": "{matched} of {total} bank transactions matched", "recon_open_items": "{count, plural, =0 {Nothing left to explain} one {1 item left to explain} other {# items left to explain}}", "recon_bridge_unmatched_tx": "{count, plural, one {1 bank transaction awaiting a voucher} other {# bank transactions awaiting a voucher}}", diff --git a/messages/sv.json b/messages/sv.json index b1e42d35..ce589525 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -6525,10 +6525,14 @@ "recent_heading": "Senast öppnade", "back_to_library": "Rapporter", "help_bank_reconciliation_scope": "Avstämningen körs mot ett bankkonto i taget (t.ex. 1930). Övriga bankkonton, som Plusgiro 1920, kreditkort 1940 eller valutakonton, stäms av separat: välj konto i verktygsraden.", - "help_bank_reconciliation_preview": "Förhandsgranskningen förvaljer starka träffar. Ungefärliga träffar bockar du i själv efter att du granskat verifikationen.", + "help_bank_reconciliation_preview": "Matchningen körs automatiskt när det finns omatchade transaktioner och förvaljer starka träffar. Inget bokförs förrän du klickar Tillämpa; ungefärliga träffar bockar du i själv efter att du granskat verifikationen.", "help_bank_reconciliation_ib": "Är en manuellt bokförd eller importerad verifikation egentligen en ingående balans? Märk den som IB så räknas den inte med i avstämningen utan visas separat.", "help_bank_reconciliation_ignored": "Ignorerade transaktioner döljs från avstämningen utan att bokföras. De påverkar inte saldot och kan återställas när som helst.", - "recon_unmatched_attn": "{count, plural, one {1 omatchad transaktion: Förhandsgranska hittar automatiska träffar.} other {# omatchade transaktioner: Förhandsgranska hittar automatiska träffar.}}", + "recon_match_automatically": "Matcha automatiskt", + "recon_matching": "Matchar…", + "recon_matching_attn": "Söker automatiska träffar mot dina verifikationer…", + "recon_book_row": "Bokför", + "recon_book_rest": "{count, plural, one {Bokför 1 rad i Transaktioner} other {Bokför # rader i Transaktioner}}", "recon_progress": "{matched} av {total} banktransaktioner matchade", "recon_open_items": "{count, plural, =0 {Inget kvar att förklara} one {1 post kvar att förklara} other {# poster kvar att förklara}}", "recon_bridge_unmatched_tx": "{count, plural, one {1 banktransaktion väntar på verifikat} other {# banktransaktioner väntar på verifikat}}",