From 1b829883aee72ef0e804a17a4758aea3f36b3035 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Thu, 13 Aug 2026 15:21:27 +0200 Subject: [PATCH] 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 * 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 --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 --- app/(dashboard)/reports/[slug]/page.tsx | 7 +- app/(dashboard)/transactions/page.tsx | 16 ++++ .../bank/run/__tests__/route.test.ts | 77 +++++++++++++++++++ app/api/reconciliation/bank/run/route.ts | 6 +- components/reports/BankReconciliationView.tsx | 66 +++++++++++++++- components/reports/FocusedReport.tsx | 12 ++- lib/api/schemas.ts | 6 ++ messages/en.json | 6 +- messages/sv.json | 6 +- 9 files changed, 194 insertions(+), 8 deletions(-) diff --git a/app/(dashboard)/reports/[slug]/page.tsx b/app/(dashboard)/reports/[slug]/page.tsx index 00464d26..656c8a6d 100644 --- a/app/(dashboard)/reports/[slug]/page.tsx +++ b/app/(dashboard)/reports/[slug]/page.tsx @@ -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'} /> ) } diff --git a/app/(dashboard)/transactions/page.tsx b/app/(dashboard)/transactions/page.tsx index ce4f208d..ae93c8f0 100644 --- a/app/(dashboard)/transactions/page.tsx +++ b/app/(dashboard)/transactions/page.tsx @@ -3017,6 +3017,22 @@ export default function TransactionsPage() { ) ) : (
+ {/* 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 && ( + + {t('recon_attn', { count: selectableInboxIds.length })} + + )} {/* 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. */} diff --git a/app/api/reconciliation/bank/run/__tests__/route.test.ts b/app/api/reconciliation/bank/run/__tests__/route.test.ts index 526ff36a..6fe98c93 100644 --- a/app/api/reconciliation/bank/run/__tests__/route.test.ts +++ b/app/api/reconciliation/bank/run/__tests__/route.test.ts @@ -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 }) diff --git a/app/api/reconciliation/bank/run/route.ts b/app/api/reconciliation/bank/run/route.ts index 8af01ba1..fa1f81dc 100644 --- a/app/api/reconciliation/bank/run/route.ts +++ b/app/api/reconciliation/bank/run/route.ts @@ -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({ diff --git a/components/reports/BankReconciliationView.tsx b/components/reports/BankReconciliationView.tsx index 26951525..b312b174 100644 --- a/components/reports/BankReconciliationView.tsx +++ b/components/reports/BankReconciliationView.tsx @@ -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(null) const [unmatchedTx, setUnmatchedTx] = useState([]) const [glLines, setGlLines] = useState([]) @@ -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) */}
+ {/* 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 })} + )}
- @@ -1042,7 +1100,9 @@ export function BankReconciliationView({ periodId, periodBounds }: BankReconcili {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'}`} )}
diff --git a/components/reports/FocusedReport.tsx b/components/reports/FocusedReport.tsx index 898edcc6..0b65c1e7 100644 --- a/components/reports/FocusedReport.tsx +++ b/components/reports/FocusedReport.tsx @@ -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} /> ) : ( void + autoRun?: boolean }) { switch (slug) { case 'resultatrapport': @@ -252,7 +258,7 @@ function FocusedView({ case 'supplier-ledger': return case 'bank-reconciliation': - return + return 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 ( }> @@ -273,6 +282,7 @@ export function FocusedReport({ slug={slug} initialPeriods={initialPeriods} initialCompanyId={initialCompanyId} + autoRun={autoRun} /> ) diff --git a/lib/api/schemas.ts b/lib/api/schemas.ts index 720695d4..4ad7bb7d 100644 --- a/lib/api/schemas.ts +++ b/lib/api/schemas.ts @@ -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(), }) // ============================================================ diff --git a/messages/en.json b/messages/en.json index 8fe68027..dbff306d 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/sv.json b/messages/sv.json index a0d4a772..4767e034 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -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",