feat(reconciliation): page-owned window, automatic matching, and a way out for unbookable rows (#1742)

Second half of the reconciliation redesign, on top of the bridge in #1737.

**Toolbar.** The view hosted its own "Datum från / Datum till" inputs behind a
Filtrera button: a second period control competing with the header's
räkenskapsår picker (convention 8), and the source of a "typed but not applied"
state that needed its own attention line to explain. The window is now owned by
the page, narrowed through the shared ReportDateRange like every other report,
and applied on change. The view holds no date state at all, which also removes
the ref-synchronisation dance and the off-by-one it existed to prevent (a year
switch fetching the previous year's window because the refs updated a commit
late).

Reconciliation opens on the FULL year, not the family default of YTD, and keeps
its own preset memory: a reconciliation runs over a whole räkenskapsår, and
inheriting a "Denna månad" last used on Resultatrapport would show an alarming
difference for a window nobody chose here. ReportDateRange gained defaultPreset
and storageKeyPrefix for that; every existing caller keeps its behaviour.

**Automatic matching.** "Förhandsgranska" told the user nothing about what it
did, and the ochre line above it existed only to point at it: people matched a
whole migration row by row next to a button they never found. The matcher now
runs by itself, once per window+account, whenever there is unmatched work. It is
a dry run, so nothing is written and Tillämpa still requires an explicit click.
The button stays as a re-run and is renamed to what it does. ?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.

**A way out for rows that cannot be paired.** An unmatched bank row that no
voucher on the account could settle is not reconciliation work, it is an unbooked
affärshändelse, and the match picker held nothing for it. Those rows now offer
"Bokför" into /transactions?highlight=<id>, with a bulk link in the section
header. The rule (direction-compatible and equal to the öre) is extracted to
lib/reconciliation/voucher-candidate.ts so it is testable and so the component
never imports the server-only reconciliation module. Deliberately strict: a false
negative offers booking on a row that could also have been paired, which is a
legitimate outcome, while a false positive sends the user into an empty picker.

11 new tests for the candidate rule, covering direction, öre equality, float
noise, PostgREST numeric strings and the foreign-account case where the
candidate RPC projects no FX amount and no match may be claimed.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-08-20 13:14:31 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Opus 5
parent b5e908f9ea
commit 5a8dd21931
9 changed files with 385 additions and 175 deletions
+4
View File
@@ -1111,3 +1111,7 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. 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=<id> 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.
+25 -6
View File
@@ -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<Preset>('ytd')
const [preset, setPreset] = useState<Preset>(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) => {
+184 -163
View File
@@ -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<ReconciliationStatus | null>(null)
const [unmatchedTx, setUnmatchedTx] = useState<UnmatchedTransaction[]>([])
@@ -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<CashAccount[]>([])
// 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<DryRunMatch[] | null>(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<string | null>(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:<id>` 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) */}
<div className="space-y-3">
{/* Promote the bulk flow before the first preview: many users never
found Förhandsgranska and matched a whole migration row by row. */}
{previewPromoted && !runLoading && (
<AttnLine>{t('recon_unmatched_attn', { count: unmatchedTx.length })}</AttnLine>
)}
<div className="flex flex-wrap items-end gap-4">
{/* 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 && <AttnLine>{t('recon_matching_attn')}</AttnLine>}
<div className="flex flex-wrap items-center gap-3">
<CashAccountSelector
value={accountNumber}
onChange={setAccountNumber}
/>
<div>
<Label>Datum från</Label>
<Input
type="date"
value={dateFrom}
onChange={(e) => setDateFrom(e.target.value)}
className="mt-1"
/>
</div>
<div>
<Label>Datum till</Label>
<Input
type="date"
value={dateTo}
onChange={(e) => setDateTo(e.target.value)}
className="mt-1"
/>
</div>
<Button onClick={() => fetchAll()} variant={datesDirty ? 'default' : 'outline'}>
Filtrera
</Button>
<div className="flex-1" />
<Button
onClick={handleDryRun}
disabled={runLoading || datesDirty}
variant={previewPromoted ? 'default' : 'outline'}
disabled={runLoading}
variant="outline"
>
<Eye className="h-4 w-4 mr-2" />
{runLoading ? 'Analyserar...' : 'Förhandsgranska'}
<Link2 className="h-4 w-4 mr-2" />
{runLoading ? t('recon_matching') : t('recon_match_automatically')}
</Button>
{dryRunResults && dryRunResults.length > 0 && (
<Button onClick={handleApply} disabled={applyLoading || selectedPairs.size === 0}>
@@ -1327,12 +1313,6 @@ export function BankReconciliationView({ periodId, periodBounds, autoRun }: Bank
</Button>
)}
</div>
{datesDirty && (
<AttnLine>
Datumfiltret är ändrat men inte tillämpat: klicka Filtrera för att uppdatera
listorna innan du förhandsgranskar.
</AttnLine>
)}
</div>
{/* Dry Run Preview */}
@@ -1413,6 +1393,17 @@ export function BankReconciliationView({ periodId, periodBounds, autoRun }: Bank
Omatchade transaktioner ({unmatchedTx.length})
</h2>
<div className="flex items-center gap-3">
{/* 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 && (
<Button asChild size="sm" variant="ghost" className="h-8 text-xs">
<Link href={`/transactions?source=${bookOnlySource}`}>
{t('recon_book_rest', { count: bookOnlyCount })}
</Link>
</Button>
)}
{unmatchedGlLines.length > 0 && (
<p className="text-xs text-muted-foreground">
{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)}
</td>
<td className={TD_CLASS}>
<button
type="button"
onClick={() => toggleExpandedTx(tx.id)}
aria-expanded={isOpen}
className="flex max-w-full items-center gap-1.5 text-left transition-colors duration-150 hover:text-foreground"
>
{isOpen ? (
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
) : (
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
)}
<span className="truncate">{tx.description}</span>
{/* Inline, not a sub-row: convention 4 keeps list
rows one line high. */}
{tx.reference && (
<span className="shrink-0 text-xs text-muted-foreground">
· {tx.reference}
</span>
)}
</button>
{/* 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 ? (
<button
type="button"
onClick={() => toggleExpandedTx(tx.id)}
aria-expanded={isOpen}
className="flex max-w-full items-center gap-1.5 text-left transition-colors duration-150 hover:text-foreground"
>
{isOpen ? (
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
) : (
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
)}
<span className="truncate">{tx.description}</span>
{tx.reference && (
<span className="shrink-0 text-xs text-muted-foreground">
· {tx.reference}
</span>
)}
</button>
) : (
<span className="flex max-w-full items-center gap-1.5 pl-5">
<span className="truncate">{tx.description}</span>
{tx.reference && (
<span className="shrink-0 text-xs text-muted-foreground">
· {tx.reference}
</span>
)}
</span>
)}
</td>
<td
className={`${TD_CLASS} text-right tabular-nums ${
@@ -1513,17 +1521,30 @@ export function BankReconciliationView({ periodId, periodBounds, autoRun }: Bank
{formatCurrency(tx.amount, tx.currency)}
</td>
<td className={`${TD_CLASS} text-right`}>
{/* Opens the picker; the button INSIDE it performs the
match. Two controls labelled "Matcha" in one row
would read as the same action twice. */}
<Button
size="sm"
variant="ghost"
className="h-8 text-xs"
onClick={() => toggleExpandedTx(tx.id)}
>
{isOpen ? 'Stäng' : 'Välj verifikat'}
</Button>
{/* 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.
<Button
size="sm"
variant="ghost"
className="h-8 text-xs"
onClick={() => toggleExpandedTx(tx.id)}
>
{isOpen ? 'Stäng' : 'Välj verifikat'}
</Button>
) : (
<Button asChild size="sm" variant="ghost" className="h-8 text-xs">
<Link href={`/transactions?highlight=${tx.id}`}>
{t('recon_book_row')}
</Link>
</Button>
)}
</td>
<td className={`${TD_CLASS} text-right`}>
<DropdownMenu>
@@ -1611,7 +1632,7 @@ export function BankReconciliationView({ periodId, periodBounds, autoRun }: Bank
</DropdownMenu>
</td>
</tr>
{isOpen && (
{isOpen && hasCandidate && (
<tr className="bg-secondary/20">
<td colSpan={5} className="px-3 pb-4 pt-1 align-top">
<div className="space-y-2">
+20 -1
View File
@@ -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 (
<Card>
@@ -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 <SupplierLedgerView periodId={periodId} />
case 'bank-reconciliation':
return <BankReconciliationView periodId={periodId} periodBounds={periodBounds} autoRun={autoRun} />
return (
<BankReconciliationView
periodId={periodId}
periodBounds={periodBounds}
dateRange={dateRange}
autoRun={autoRun}
/>
)
default:
return null
}
@@ -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)
})
})
+56
View File
@@ -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
})
}
+7 -1
View File
@@ -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 ---
+6 -2
View File
@@ -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}}",
+6 -2
View File
@@ -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}}",