feat(reconciliation): promote bulk matching and bridge it from the inbox (#1571)

* feat(reconciliation): accept confidence_threshold on the bank run route

Mirror the v1 route: RunReconciliationSchema gains an optional
confidence_threshold (0..1) that passes through to runReconciliation as
the server-side floor on the apply path. The UI sends 0.85 with a
strong-only apply so a pair the fresh re-run scores lower is skipped
instead of committed; omitting it keeps the legacy behavior where every
selected pair applies.

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

* feat(reconciliation): promote the bulk match flow and bridge it from the inbox

The dry-run preview with pre-ticked strong matches existed but was never
found: users matched whole migrations row by row. Three discoverability
changes, no engine changes:

- Bankavstamning: an attention line above the toolbar while unmatched
  transactions exist and no preview has run, with Forhandsgranska
  promoted to the filled variant. When every ticked preview pair is a
  strong match (>= 0.85) the apply button relabels to 'Matcha X starka
  traffar' and the apply sends confidence_threshold 0.85; mixed
  selections keep the plain label and omit the floor so manually ticked
  weaker pairs still apply.
- Autorun bridge: ?autorun=1 on /reports/bank-reconciliation runs the
  preview once, only after appliedDates is set and not while datesDirty,
  so it can never cover a different window than the on-screen lists.
- Transactions inbox: with >= 5 unbooked bank rows visible, an attention
  line links to the reconciliation with autorun (static text + count, no
  probe; the preview is the honest source of how many actually match).

The review step stays: autorun lands on the preview table, one click
from apply, and the server intersection guard is untouched.

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-13 15:21:27 +02:00
committed by GitHub
co-authored by Claude Fable 5 Jakob Wennberg
parent 1eebb75269
commit 1b829883ae
9 changed files with 194 additions and 8 deletions
+6 -1
View File
@@ -11,10 +11,12 @@ import type { FiscalPeriod } from '@/types'
*/
export default async function ReportSlugPage({
params,
searchParams,
}: {
params: Promise<{ slug: string }>
searchParams: Promise<{ [key: string]: string | string[] | undefined }>
}) {
const { slug } = await params
const [{ slug }, query] = await Promise.all([params, searchParams])
const report = getReport(slug)
if (!report) notFound()
if (report.route) redirect(report.route)
@@ -37,6 +39,9 @@ export default async function ReportSlugPage({
slug={slug}
initialPeriods={(periods ?? []) as FiscalPeriod[]}
initialCompanyId={companyId}
// ?autorun=1 deep-links (e.g. the transactions inbox banner) ask the
// bank-reconciliation view to run its dry-run preview once on load.
autoRun={query.autorun === '1'}
/>
)
}
+16
View File
@@ -3017,6 +3017,22 @@ export default function TransactionsPage() {
)
) : (
<div>
{/* Bridge to the bulk-match flow: a backlog of unbooked bank rows
is usually a migration/import whose counterpart vouchers
already exist, and the only match affordance here is per-row.
Static text + count (no probe): the reconciliation preview is
the honest source of how many actually match. */}
{selectableInboxIds.length >= 5 && (
<AttnLine
className="px-1 pb-3"
action={{
label: t('recon_attn_action'),
href: '/reports/bank-reconciliation?autorun=1',
}}
>
{t('recon_attn', { count: selectableInboxIds.length })}
</AttnLine>
)}
{/* Bulkbar (concept): hidden until at least one transaction is
selected via the hover checkboxes, then it pops in with the
count and the batch actions. */}
@@ -77,6 +77,83 @@ describe('POST /api/reconciliation/bank/run', () => {
expect(response.status).toBe(403)
})
it('rejects an out-of-range confidence_threshold with 400', async () => {
const request = createMockRequest('/api/reconciliation/bank/run', {
method: 'POST',
body: { dry_run: false, confidence_threshold: 1.5 },
})
const response = await POST(request, emptyParams)
expect(response.status).toBe(400)
expect(runReconciliationMock).not.toHaveBeenCalled()
})
it('rejects a negative confidence_threshold with 400', async () => {
const request = createMockRequest('/api/reconciliation/bank/run', {
method: 'POST',
body: { dry_run: false, confidence_threshold: -0.1 },
})
const response = await POST(request, emptyParams)
expect(response.status).toBe(400)
expect(runReconciliationMock).not.toHaveBeenCalled()
})
it('passes confidence_threshold and selected_matches through to runReconciliation', async () => {
// cash_accounts lookup: no row, '1930' default is exempt.
enqueue({ data: null })
const request = createMockRequest('/api/reconciliation/bank/run', {
method: 'POST',
body: {
dry_run: false,
confidence_threshold: 0.85,
selected_matches: [
{
transaction_id: '11111111-1111-4111-8111-111111111111',
journal_entry_id: '22222222-2222-4222-8222-222222222222',
},
],
},
})
const response = await POST(request, emptyParams)
expect(response.status).toBe(200)
expect(runReconciliationMock).toHaveBeenCalledWith(
supabase,
'company-1',
'user-1',
expect.objectContaining({
confidenceThreshold: 0.85,
applyOnly: [
{
transactionId: '11111111-1111-4111-8111-111111111111',
journalEntryId: '22222222-2222-4222-8222-222222222222',
},
],
}),
)
})
it('omits the confidence threshold when the client does not send one', async () => {
// cash_accounts lookup: no row, '1930' default is exempt.
enqueue({ data: null })
const request = createMockRequest('/api/reconciliation/bank/run', {
method: 'POST',
body: { dry_run: false },
})
const response = await POST(request, emptyParams)
expect(response.status).toBe(200)
expect(runReconciliationMock).toHaveBeenCalledWith(
supabase,
'company-1',
'user-1',
expect.objectContaining({ confidenceThreshold: undefined }),
)
})
it('rejects a non-default account with no cash_accounts row', async () => {
// cash_accounts lookup finds nothing for 1932.
enqueue({ data: null })
+5 -1
View File
@@ -12,7 +12,8 @@ export const POST = withRouteContext(
async (request, { supabase, user, companyId }) => {
const validation = await validateBody(request, RunReconciliationSchema)
if (!validation.success) return validation.response
const { date_from, date_to, account_number, dry_run, selected_matches } = validation.data
const { date_from, date_to, account_number, dry_run, selected_matches, confidence_threshold } =
validation.data
const accountNumber = account_number ?? '1930'
@@ -51,6 +52,9 @@ export const POST = withRouteContext(
transactionId: m.transaction_id,
journalEntryId: m.journal_entry_id,
})),
// Server-side floor on the apply path (mirrors the v1 route): pairs the
// fresh re-run scores below it are skipped, not applied.
confidenceThreshold: confidence_threshold,
})
return NextResponse.json({
+63 -3
View File
@@ -2,6 +2,7 @@
import Link from 'next/link'
import { useState, useEffect, useCallback, useRef } from 'react'
import { useTranslations } from 'next-intl'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
@@ -173,9 +174,17 @@ interface BankReconciliationViewProps {
periodId: string
/** period_start / period_end of that period; seeds the date window (#751). */
periodBounds: { start: string; end: string } | null
/**
* Deep-link bridge (?autorun=1, e.g. from the transactions inbox banner):
* runs the dry-run preview automatically ONCE, only after the first load has
* recorded appliedDates and while the typed dates still match it, so the
* preview can never cover a different window than the on-screen lists.
*/
autoRun?: boolean
}
export function BankReconciliationView({ periodId, periodBounds }: BankReconciliationViewProps) {
export function BankReconciliationView({ periodId, periodBounds, autoRun }: BankReconciliationViewProps) {
const t = useTranslations('reports')
const [status, setStatus] = useState<ReconciliationStatus | null>(null)
const [unmatchedTx, setUnmatchedTx] = useState<UnmatchedTransaction[]>([])
const [glLines, setGlLines] = useState<UnlinkedGLLine[]>([])
@@ -287,6 +296,24 @@ export function BankReconciliationView({ periodId, periodBounds }: BankReconcili
const datesDirty =
appliedDates !== null && (appliedDates.from !== dateFrom || appliedDates.to !== dateTo)
// Promote the preview flow while there is unmatched work and no preview has
// run yet: an attention line above the toolbar plus the Förhandsgranska
// button in the default (filled) variant. Suppressed while datesDirty: that
// state owns the page's single attention line and disables the button anyway.
const previewPromoted = unmatchedTx.length > 0 && dryRunResults === null && !datesDirty
// Every ticked preview pair is a strong match (>= the Stark badge floor):
// the apply button relabels to "Matcha X starka träffar" and the apply
// request carries confidence_threshold so the server re-run enforces the
// same floor. Manually ticked weaker pairs drop back to the plain label and
// an unthresholded apply.
const allSelectedStrong =
dryRunResults !== null &&
selectedPairs.size > 0 &&
dryRunResults
.filter((m) => selectedPairs.has(matchKey(m.transaction_id, m.journal_entry_id)))
.every((m) => m.confidence >= PRESELECT_CONFIDENCE)
useEffect(() => {
let cancelled = false
fetch('/api/cash-accounts')
@@ -501,6 +528,23 @@ export function BankReconciliationView({ periodId, periodBounds }: BankReconcili
}
}
// One-shot autorun bridge (?autorun=1): trigger the same dry-run the
// Förhandsgranska button runs, exactly once, and only once the first load
// has recorded appliedDates with the typed dates still matching it
// (datesDirty false). Firing earlier could preview a different window than
// the on-screen lists. Consumed even when there is nothing to preview, so a
// later data refresh never surprises the user with an unprompted run.
const autoRunConsumedRef = useRef(false)
useEffect(() => {
if (!autoRun || autoRunConsumedRef.current) return
if (loading || !appliedDates || datesDirty) return
autoRunConsumedRef.current = true
if (unmatchedTx.length > 0) void handleDryRun()
// handleDryRun is recreated every render; the consumed-ref guarantees the
// single run, so depending on it would only add noise.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [autoRun, loading, appliedDates, datesDirty, unmatchedTx.length])
const toggleMatchSelection = (key: string) => {
setSelectedPairs((prev) => {
const next = new Set(prev)
@@ -543,6 +587,11 @@ export function BankReconciliationView({ periodId, periodBounds }: BankReconcili
transaction_id: m.transaction_id,
journal_entry_id: m.journal_entry_id,
})),
// Strong-only apply: when every ticked pair is >= the Stark floor,
// ask the server to enforce that floor on its fresh re-run too. A
// mixed selection omits it so manually ticked weaker pairs still
// apply (the intersection guard still protects them).
...(allSelectedStrong ? { confidence_threshold: PRESELECT_CONFIDENCE } : {}),
}),
})
const result = await res.json()
@@ -1006,6 +1055,11 @@ export function BankReconciliationView({ periodId, periodBounds }: BankReconcili
{/* 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">
<CashAccountSelector
value={accountNumber}
@@ -1033,7 +1087,11 @@ export function BankReconciliationView({ periodId, periodBounds }: BankReconcili
Filtrera
</Button>
<div className="flex-1" />
<Button onClick={handleDryRun} disabled={runLoading || datesDirty} variant="outline">
<Button
onClick={handleDryRun}
disabled={runLoading || datesDirty}
variant={previewPromoted ? 'default' : 'outline'}
>
<Eye className="h-4 w-4 mr-2" />
{runLoading ? 'Analyserar...' : 'Förhandsgranska'}
</Button>
@@ -1042,7 +1100,9 @@ export function BankReconciliationView({ periodId, periodBounds }: BankReconcili
<Play className="h-4 w-4 mr-2" />
{applyLoading
? 'Tillämpar...'
: `Tillämpa ${selectedPairs.size} ${selectedPairs.size === 1 ? 'matchning' : 'matchningar'}`}
: allSelectedStrong
? t('recon_apply_strong', { count: selectedPairs.size })
: `Tillämpa ${selectedPairs.size} ${selectedPairs.size === 1 ? 'matchning' : 'matchningar'}`}
</Button>
)}
</div>
+11 -1
View File
@@ -68,10 +68,13 @@ function FocusedReportInner({
slug,
initialPeriods,
initialCompanyId,
autoRun,
}: {
slug: string
initialPeriods: FiscalPeriod[]
initialCompanyId: string | null
/** ?autorun=1: the bank-reconciliation view runs its preview once on load. */
autoRun?: boolean
}) {
const router = useRouter()
const searchParams = useSearchParams()
@@ -186,6 +189,7 @@ function FocusedReportInner({
isEnskildFirma={isEnskildFirma}
isAktiebolag={isAktiebolag}
onNavigateToAccount={navigateToAccount}
autoRun={autoRun}
/>
) : (
<EmptyState
@@ -210,6 +214,7 @@ function FocusedView({
isEnskildFirma,
isAktiebolag,
onNavigateToAccount,
autoRun,
}: {
slug: string
reportName: string
@@ -221,6 +226,7 @@ function FocusedView({
isEnskildFirma: boolean
isAktiebolag: boolean
onNavigateToAccount: (account: string) => void
autoRun?: boolean
}) {
switch (slug) {
case 'resultatrapport':
@@ -252,7 +258,7 @@ function FocusedView({
case 'supplier-ledger':
return <SupplierLedgerView periodId={periodId} />
case 'bank-reconciliation':
return <BankReconciliationView periodId={periodId} periodBounds={periodBounds} />
return <BankReconciliationView periodId={periodId} periodBounds={periodBounds} autoRun={autoRun} />
default:
return null
}
@@ -262,10 +268,13 @@ export function FocusedReport({
slug,
initialPeriods,
initialCompanyId,
autoRun,
}: {
slug: string
initialPeriods: FiscalPeriod[]
initialCompanyId: string | null
/** ?autorun=1: the bank-reconciliation view runs its preview once on load. */
autoRun?: boolean
}) {
return (
<Suspense fallback={<div className="space-y-8" />}>
@@ -273,6 +282,7 @@ export function FocusedReport({
slug={slug}
initialPeriods={initialPeriods}
initialCompanyId={initialCompanyId}
autoRun={autoRun}
/>
</Suspense>
)
+6
View File
@@ -2185,6 +2185,12 @@ export const RunReconciliationSchema = z.object({
)
.max(500)
.optional(),
// Server-side confidence floor for the apply path (0..1), mirroring the v1
// route. The UI sends 0.85 with a strong-only apply so a pair that scored
// lower on the fresh server re-run is never committed, even if a stale
// client still has it ticked. Omitted = legacy behavior: every selected
// match applies, including manually ticked fuzzy ones at 0.75.
confidence_threshold: z.number().min(0).max(1).optional(),
})
// ============================================================
+5 -1
View File
@@ -5400,7 +5400,9 @@
"skv_err_commit_failed": "draft created, not posted",
"skv_err_other": "failed",
"mode_all": "All",
"footer_to_handle": "{count, plural, =0 {Nothing to handle} =1 {1 to handle} other {# to handle}}"
"footer_to_handle": "{count, plural, =0 {Nothing to handle} =1 {1 to handle} other {# to handle}}",
"recon_attn": "{count, plural, one {1 unbooked bank transaction: the bank reconciliation can find automatic matches against existing vouchers.} other {# unbooked bank transactions: the bank reconciliation can find automatic matches against existing vouchers.}}",
"recon_attn_action": "Preview matches"
},
"bookkeeping": {
"toast_post_failed": "Could not post",
@@ -6100,6 +6102,8 @@
"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_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_apply_strong": "{count, plural, one {Match 1 strong match} other {Match # strong matches}}",
"switch_report": "Switch report",
"calendar_badge": "Calendar",
"group_payroll": "Payroll",
+5 -1
View File
@@ -5400,7 +5400,9 @@
"skv_err_commit_failed": "utkast skapat, ej bokfört",
"skv_err_other": "misslyckades",
"mode_all": "Alla",
"footer_to_handle": "{count, plural, =0 {Inget att hantera} =1 {1 att hantera} other {# att hantera}}"
"footer_to_handle": "{count, plural, =0 {Inget att hantera} =1 {1 att hantera} other {# att hantera}}",
"recon_attn": "{count, plural, one {1 obokförd banktransaktion: bankavstämningen kan hitta automatiska träffar mot befintliga verifikationer.} other {# obokförda banktransaktioner: bankavstämningen kan hitta automatiska träffar mot befintliga verifikationer.}}",
"recon_attn_action": "Förhandsgranska träffar"
},
"bookkeeping": {
"toast_post_failed": "Kunde inte bokföra",
@@ -6100,6 +6102,8 @@
"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_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_apply_strong": "{count, plural, one {Matcha 1 stark träff} other {Matcha # starka träffar}}",
"switch_report": "Byt rapport",
"calendar_badge": "Kalender",
"group_payroll": "Lön",