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:
co-authored by
Claude Fable 5
Jakob Wennberg
parent
1eebb75269
commit
1b829883ae
@@ -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'}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user