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
@@ -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({