feat(reconciliation): inline automatic matcher + Oförklarat tooltip (#1843)

* feat(reconciliation): run the automatic matcher inline on the Avstämning page

'Matcha automatiskt' no longer sends the user to the bank report: it
runs the dry-run on the page and renders the candidate pairs in the
page's own paired-row grammar (voucher, date, confidence chip), applied
per row or all strong ones at once (floor 0.85, re-enforced server-side
via the run endpoint's intersection guard, same as the old view). The
Oförklarat tile gets a '?' tooltip saying what the number means: 0 =
everything explained even if rows remain to book; anything else = an
inconsistency, look before trusting. First bite of absorbing the old
bank view (manual N:M + residual booking and the redirect follow in 6b).

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

* fix(reconciliation): add the col_proposal string the matcher preview references

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-08-24 15:18:56 +02:00
committed by GitHub
co-authored by Claude Fable 5 Jakob Wennberg
parent 0ed26c2eca
commit 1b54bcd4bd
5 changed files with 214 additions and 7 deletions
+1
View File
@@ -1182,5 +1182,6 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-08-23] Avstämning page (PR 3) ships without the period picker, the manual two-pane match mode and the sign-off button: the page renders the approved 'Vald riktning' layout (rail + tiles + bridge + actions + banded table) over the PR 2 dashboard routes only, so that it is verifiable on its own; period + sign-off arrive together in PR 4 (both are period-bound), manual N:M matching with residual booking in PR 5. Bank accounts get the same generic body plus links to the existing bank view for the matcher run rather than embedding the 1900-line BankReconciliationView: one body for every account kind is the point of the page, and embedding would have doubled the header.
[2026-08-23] Reconciliation sign-off (PR 4) is an append-only attestation table (account_reconciliations) with a reopen stamp, not a flag on the account: who signed what through which date, with the numbers as they stood, is the thing an auditor and the Hem row read, so it must survive a later change of mind. Sign-off is refused with an unexplained difference unless forced with a note (the note is what the next reader sees). Separate scope reconciliation:signoff (write is not enough): an integration that links rows should not be able to attest. The worklist category reconciliation_due is gated on adoption (zero until the company has signed anything off) so the nudge reaches the people who reconcile monthly without becoming a new chore for everyone. Webhook events added additively without bumping API_V1_VERSION: the dated version is reserved for breaking changes; a new event type breaks no existing subscriber.
[2026-08-23] Reconciliation agent surfaces (PR 5): the Hem notice for a skattekonto that disagrees with the ledger reads a summary the sync persists (extension_data skattekonto_reconciliation_latest) instead of recomputing the bridge on every render; the same persisted summary feeds nothing else yet. The attention resource's reconciliation_due category and the Hem row share lib/worklist countReconciliationDue (one predicate). Manual N:M matching with residual booking, dropping the local MatchDialog on /skattekonto, restyling the bank view and eval scenarios are deferred to PR 6: they need the two-pane UI and a visual pass, and none of the agent surfaces depend on them. The skattekonto sync cron now orders eligible companies by stalest sync before its 50-per-run cap (never-synced first) instead of raising the cap: a fixed order plus a cap starved the tail.
[2026-08-24] Inline matcher (PR 6a) applies through the existing /api/reconciliation/bank/run intersection guard rather than new endpoints: the page renders the dry-run pairs and applies per row or strong-only (floor 0.85, re-enforced server-side), so a stale preview can never link a pair the fresh run would not. The old bank view keeps living until 6b (manual N:M + residual booking) reaches parity; only its matcher trips became unnecessary today.
[2026-08-24] vat_amount (categorize/bulk_book) is transaction-currency, converted to SEK at booking: the validation bound already read it in transaction currency (the underlag's denomination), and the gross line already converts through resolveSekAmount, so converting the VAT the same way was the only coherent option. Documenting it as SEK instead (the reporter's first suggestion, feedback seq 254607) would force agents to pre-convert with a settlement rate they cannot see.
[2026-08-24] Declared currency/voucher_series nullable in three MCP listing schemas on column-nullability alone (no traced null producer): loosening an output schema can only stop false validation failures, never cause one, and legacy rows predate the columns' defaults. Declined (for now) a full Ajv execute-vs-schema round-trip harness in output-schema.test.ts: right long-term answer to this bug class, but a session-sized project of its own; the audit's seven confirmed sites are pinned by a targeted declaration test instead.
+66 -5
View File
@@ -21,6 +21,8 @@ import type {
} from '@/lib/reconciliation/schemas'
import type { SkattekontoBatchRowResult, SkattekontoTransactionWithSuggestion } from '@/types/skatteverket'
import { SignoffDialog } from './SignoffDialog'
import { MatcherPreview, type MatcherMatch } from './MatcherPreview'
import { InfoTooltip } from '@/components/ui/info-tooltip'
const SkattekontoBookDialog = dynamic(
() => import('@/components/skattekonto/SkattekontoBookDialog'),
@@ -84,6 +86,7 @@ export function AccountOverview({ account, rail, window, onChanged }: AccountOve
const [unfolded, setUnfolded] = useState<Set<ReconciliationItemBucket>>(new Set())
const [bookRow, setBookRow] = useState<ReconciliationItem | null>(null)
const [signoffOpen, setSignoffOpen] = useState(false)
const [matcher, setMatcher] = useState<MatcherMatch[] | null>(null)
const isSkv = account.kind === 'skattekonto'
const base = `/api/reconciliation/accounts/${encodeURIComponent(account.account_key)}`
@@ -269,6 +272,51 @@ export function AccountOverview({ account, rail, window, onChanged }: AccountOve
}
}
async function runMatcher() {
if (!status) return
setBusy('matcher')
try {
const data = await postJson('/api/reconciliation/bank/run', {
date_from: window.from,
date_to: window.to,
account_number: status.account_number,
dry_run: true,
})
if (data) setMatcher((data.matches ?? []) as MatcherMatch[])
} finally {
setBusy(null)
}
}
async function applyMatches(pairs: MatcherMatch[], strongOnly: boolean) {
if (!status || pairs.length === 0) return
setBusy('matcher')
try {
const data = await postJson('/api/reconciliation/bank/run', {
date_from: window.from,
date_to: window.to,
account_number: status.account_number,
dry_run: false,
selected_matches: pairs.map((m) => ({
transaction_id: m.transaction_id,
journal_entry_id: m.journal_entry_id,
})),
// Strong-only applies re-enforce the floor server-side, same as the
// old bank view; a single hand-picked weaker pair omits it.
...(strongOnly ? { confidence_threshold: 0.85 } : {}),
})
if (data) {
const applied = (data.applied as number) ?? 0
toast({ title: t('toast_matched', { applied }) })
const appliedKeys = new Set(pairs.map((m) => `${m.transaction_id}:${m.journal_entry_id}`))
setMatcher((prev) => (prev ? prev.filter((m) => !appliedKeys.has(`${m.transaction_id}:${m.journal_entry_id}`)) : prev))
await refresh()
}
} finally {
setBusy(null)
}
}
// ---- render -------------------------------------------------------------
if (loadError) {
@@ -310,7 +358,7 @@ export function AccountOverview({ account, rail, window, onChanged }: AccountOve
const fetchedAt = isSkv ? status.skattekonto?.fetched_at : account.source.synced_at
const sourceLabel = isSkv ? t('source_skv') : t('source_bank')
const tiles: Array<{ key: string; label: string; value: string; sub: string; tone?: 'ok' | 'attn' }> = [
const tiles: Array<{ key: string; label: string; value: string; sub: string; tone?: 'ok' | 'attn'; help?: string }> = [
{
key: 'external',
label: isSkv ? t('tile_external_skv') : t('tile_external_bank'),
@@ -336,6 +384,7 @@ export function AccountOverview({ account, rail, window, onChanged }: AccountOve
{
key: 'unexplained',
label: t('tile_unexplained'),
help: t('tile_unexplained_help'),
value: money(status.unexplained_difference),
sub: '',
tone: status.unexplained_difference == null ? undefined : status.is_reconciled ? 'ok' : 'attn',
@@ -371,7 +420,6 @@ export function AccountOverview({ account, rail, window, onChanged }: AccountOve
(byBucket.get('unmatched_external')?.length ?? 0) +
(byBucket.get('unmatched_ledger')?.length ?? 0)
const bankRunHref = '/reports/bank-reconciliation?autorun=1'
const bankViewHref = '/reports/bank-reconciliation'
// Default sign-off date: the window end, never past today nor past the
@@ -390,7 +438,10 @@ export function AccountOverview({ account, rail, window, onChanged }: AccountOve
<div className="grid grid-cols-2 gap-px overflow-hidden rounded-lg border border-border bg-border stagger-enter">
{tiles.map((tile) => (
<div key={tile.key} className="bg-background px-4 py-3.5">
<div className="text-[11px] font-medium uppercase tracking-[0.07em] text-muted-foreground">{tile.label}</div>
<div className="flex items-center gap-1 text-[11px] font-medium uppercase tracking-[0.07em] text-muted-foreground">
{tile.label}
{tile.help && <InfoTooltip content={tile.help} iconClassName="h-3 w-3" />}
</div>
<div
className={cn(
'mt-1 text-[22px] font-semibold leading-tight tabular-nums',
@@ -466,8 +517,8 @@ export function AccountOverview({ account, rail, window, onChanged }: AccountOve
</Button>
)}
{!isSkv && (
<Button size="sm" variant="outline" asChild>
<Link href={bankRunHref}>{t('action_run_bank_matcher')}</Link>
<Button size="sm" variant="outline" onClick={() => void runMatcher()} disabled={busy !== null} aria-busy={busy === 'matcher'}>
{t('action_run_bank_matcher')}
</Button>
)}
{signoffEnabled && (
@@ -495,6 +546,16 @@ export function AccountOverview({ account, rail, window, onChanged }: AccountOve
</div>
</div>
{matcher !== null && !isSkv && (
<MatcherPreview
matches={matcher}
currency={currency}
busy={busy !== null}
onApply={(pairs, strongOnly) => void applyMatches(pairs, strongOnly)}
onClose={() => setMatcher(null)}
/>
)}
{/* The table: full width, banded by bucket, paired proposal rows. */}
{items.items.length === 0 ? (
<p className="text-[13px] text-muted-foreground">{t('all_clear')}</p>
@@ -0,0 +1,131 @@
'use client'
import { useTranslations } from 'next-intl'
import { Button } from '@/components/ui/button'
import { TH_CLASS, TD_CLASS, QUIET_LINK_CLASS } from '@/components/ui/dry-table'
import { cn, formatCurrency, formatDate } from '@/lib/utils'
import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver'
/**
* The automatic matcher's dry-run preview, inline on the Avstämning page:
* candidate transaction↔verifikat pairs with confidence, applied per row or
* all strong ones at once. Same endpoint and same server-side intersection
* guard as the old bank view; this is only the surface. Pairs at or above
* STRONG_CONFIDENCE mirror the old view's preselect floor.
*/
export const STRONG_CONFIDENCE = 0.85
export interface MatcherMatch {
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
}
interface MatcherPreviewProps {
matches: MatcherMatch[]
currency: string
busy: boolean
onApply: (pairs: MatcherMatch[], strongOnly: boolean) => void
onClose: () => void
}
export function MatcherPreview({ matches, currency, busy, onApply, onClose }: MatcherPreviewProps) {
const t = useTranslations('reconciliation')
const strong = matches.filter((m) => m.confidence >= STRONG_CONFIDENCE)
if (matches.length === 0) {
return (
<p className="text-[13px] text-muted-foreground">
{t('matcher_none')}{' '}
<button type="button" onClick={onClose} className={QUIET_LINK_CLASS}>
{t('matcher_close')}
</button>
</p>
)
}
return (
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
<p className="text-[13px]">
{t('matcher_title', { count: matches.length })}
</p>
{strong.length > 0 && (
<Button size="sm" onClick={() => onApply(strong, true)} disabled={busy}>
{t('matcher_apply_strong', { count: strong.length })}
</Button>
)}
<button type="button" onClick={onClose} disabled={busy} className={cn(QUIET_LINK_CLASS, 'ml-auto')}>
{t('matcher_close')}
</button>
</div>
<div className="-mx-4 overflow-x-auto sm:mx-0">
<table className="w-full text-[13px]">
<thead>
<tr>
<th className={cn(TH_CLASS, 'w-[110px]')}>{t('col_date')}</th>
<th className={TH_CLASS}>{t('col_event')}</th>
<th className={cn(TH_CLASS, 'w-[140px] text-right')}>{t('col_amount')}</th>
<th className={cn(TH_CLASS, 'w-[34%]')}>{t('col_proposal')}</th>
<th className={cn(TH_CLASS, 'w-[120px]')} />
</tr>
</thead>
<tbody className="stagger-enter">
{matches.map((m) => (
<tr key={`${m.transaction_id}:${m.journal_entry_id}`} className="group">
<td className={cn(TD_CLASS, 'whitespace-nowrap tabular-nums text-muted-foreground')}>
{formatDate(m.transaction_date)}
</td>
<td className={cn(TD_CLASS, 'max-w-0')}>
<span className="block truncate" data-ph-mask title={m.transaction_description}>
{m.transaction_description}
</span>
</td>
<td className={cn(TD_CLASS, 'whitespace-nowrap text-right tabular-nums')} data-ph-mask>
{formatCurrency(m.transaction_amount, currency)}
</td>
<td className={cn(TD_CLASS, 'max-w-0')}>
<span className="flex items-center gap-2">
<span className="tabular-nums" data-ph-mask>
{formatVoucher({ voucher_series: m.voucher_series, voucher_number: m.voucher_number })}
</span>
<span className="text-[11.5px] tabular-nums text-muted-foreground">{formatDate(m.entry_date)}</span>
<span
className={cn(
'whitespace-nowrap rounded-full px-1.5 py-px text-[10.5px]',
m.confidence >= STRONG_CONFIDENCE
? 'bg-success/10 text-success'
: 'bg-muted text-muted-foreground',
)}
>
{m.confidence >= STRONG_CONFIDENCE
? t('matcher_strong')
: t('confidence', { percent: Math.round(m.confidence * 100) })}
</span>
</span>
<span className="block truncate text-[12px] text-muted-foreground" data-ph-mask>
{m.entry_description}
</span>
</td>
<td className={cn(TD_CLASS, 'text-right')}>
<Button size="sm" variant="outline" onClick={() => onApply([m], false)} disabled={busy}>
{t('row_match')}
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}
+8 -1
View File
@@ -7840,6 +7840,7 @@
"col_event": "Event",
"col_amount": "Amount",
"col_voucher": "Voucher",
"col_proposal": "Proposed voucher",
"bucket_proposed": "To link: proposed pairs",
"bucket_unmatched_external_skv": "At Skatteverket, missing in the ledger",
"bucket_unmatched_external_bank": "At the bank, missing in the ledger",
@@ -7889,7 +7890,13 @@
"signed_off_forced": "with a note",
"reopen": "Reopen",
"toast_signed_off": "Marked as reconciled through {date}",
"toast_reopened": "The sign-off is reopened"
"toast_reopened": "The sign-off is reopened",
"tile_unexplained_help": "The part of the difference not covered by the rows below. 0 means everything is explained, even if rows remain to book. Any other value means something is inconsistent, for example a link to a reversed voucher: do not trust the account until you have looked.",
"matcher_title": "{count} match suggestions from the automatic matcher",
"matcher_none": "No new matches found.",
"matcher_apply_strong": "Link {count} strong",
"matcher_strong": "Strong",
"matcher_close": "Close"
},
"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.",
+8 -1
View File
@@ -7840,6 +7840,7 @@
"col_event": "Händelse",
"col_amount": "Belopp",
"col_voucher": "Verifikat",
"col_proposal": "Föreslaget verifikat",
"bucket_proposed": "Att koppla: föreslagna par",
"bucket_unmatched_external_skv": "Hos Skatteverket, saknas i bokföringen",
"bucket_unmatched_external_bank": "På banken, saknas i bokföringen",
@@ -7889,7 +7890,13 @@
"signed_off_forced": "med notering",
"reopen": "Öppna igen",
"toast_signed_off": "Markerat som avstämt t.o.m. {date}",
"toast_reopened": "Signeringen är öppnad igen"
"toast_reopened": "Signeringen är öppnad igen",
"tile_unexplained_help": "Den del av differensen som inte täcks av raderna nedan. 0 betyder att allt är förklarat, även om rader återstår att bokföra. Ett annat värde betyder att något är inkonsekvent, till exempel en koppling till ett makulerat verifikat: lita inte på kontot förrän du har tittat.",
"matcher_title": "{count} matchningsförslag från automatiska matchningen",
"matcher_none": "Inga nya matchningar hittades.",
"matcher_apply_strong": "Koppla {count} starka",
"matcher_strong": "Stark",
"matcher_close": "Stäng"
},
"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.",