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 })}
+ )}
-
)}
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",