fix(reconciliation): server-side confidence floor for unattended auto-apply (#903)

* fix(reconciliation): server-side confidence floor for unattended auto-apply

runReconciliation applied every greedy match, including auto_fuzzy at
confidence 0.75, with no server-side threshold. The UI is checkbox-gated,
but the unattended callers (enable-banking nightly sync cron, the
extension's post-sync sweep, and the v1 run endpoint) had no guardrail.

- Add ReconciliationOptions.confidenceThreshold (0..1, clamped): the
  apply loop skips matches below it. Skipped matches stay in the result's
  matches array and are counted in the new skippedBelowThreshold field,
  so they are reported for review rather than silently dropped. Dry runs
  are unaffected; omitting the threshold preserves current behavior.
- Both enable-banking sync callers now pass
  DEFAULT_UNATTENDED_CONFIDENCE_THRESHOLD (0.9, mirroring the
  gnubok_auto_match_period MCP default), so unattended runs never commit
  fuzzy (0.75) or date-range (0.85) matches.
- v1 POST /reconciliation/bank/run accepts confidence_threshold
  (optional, 0..1, mirroring the MCP tool naming) and returns
  skipped_below_threshold; registry docs/pitfalls updated.

Closes #880

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

* fix(reconciliation): surface skippedBelowThreshold in unattended sync logs

Review finding on the first pass: the floor's 'reported, not silently
dropped' guarantee never reached the two unattended callers, which
discarded or under-logged the result. Also logs the DECISIONS.md line
for the deliberate no-default choice on the v1 route.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-06 11:30:46 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent b700108107
commit 9b126d22b9
7 changed files with 263 additions and 12 deletions
@@ -1,7 +1,10 @@
import { createClient, type SupabaseClient } from '@supabase/supabase-js'
import { NextResponse } from 'next/server'
import { syncAccountTransactions } from '@/extensions/general/enable-banking/lib/sync'
import { runReconciliation } from '@/lib/reconciliation/bank-reconciliation'
import {
runReconciliation,
DEFAULT_UNATTENDED_CONFIDENCE_THRESHOLD,
} from '@/lib/reconciliation/bank-reconciliation'
import { isConsentExpiringSoon, getDaysUntilExpiry, SessionExpiredError } from '@/extensions/general/enable-banking/lib/api-client'
import { getEmailService } from '@/lib/email/service'
import {
@@ -217,10 +220,21 @@ export const GET = withCronContext('cron.bank_sync', async (_request, ctx) => {
// Batch reconciliation sweep when SIE overlap detected
if (sieOverlap && totalImported > 0) {
try {
await runReconciliation(supabase, connection.company_id, connection.user_id, {
const reconResult = await runReconciliation(supabase, connection.company_id, connection.user_id, {
dateFrom: fromDate,
dateTo: toDate,
// Unattended run: nobody reviews a dry-run first, so never commit
// low-confidence (fuzzy / date-range) matches automatically.
confidenceThreshold: DEFAULT_UNATTENDED_CONFIDENCE_THRESHOLD,
})
if (reconResult.applied > 0 || reconResult.skippedBelowThreshold > 0) {
ctx.log.info('batch reconciliation after sync', {
companyId: connection.company_id,
applied: reconResult.applied,
skippedBelowThreshold: reconResult.skippedBelowThreshold,
total: reconResult.matches.length,
})
}
} catch {
// Non-critical
}
@@ -42,6 +42,7 @@ const { runRecMock, statusMock } = vi.hoisted(() => ({
],
applied: 1,
errors: 0,
skippedBelowThreshold: 0,
}),
// The REAL ReconciliationStatus shape from lib/reconciliation. The mock used
// to return the registry's invented shape (matched_transactions, bank_balance,
@@ -149,14 +150,71 @@ describe('POST /reconciliation/bank/run', () => {
const body = await res.json()
expect(body.data.matches).toHaveLength(1)
expect(body.data.applied).toBe(1)
expect(body.data.skipped_below_threshold).toBe(0)
expect(runRecMock).toHaveBeenCalledWith(
expect.anything(),
COMPANY_ID,
'user-1',
expect.objectContaining({ dryRun: false, accountNumber: '1930', cashAccountId: 'ca-1930' }),
// No confidence_threshold in the body: the lib gets undefined (legacy
// apply-everything behavior), never a silent server-invented default.
expect.objectContaining({
dryRun: false,
accountNumber: '1930',
cashAccountId: 'ca-1930',
confidenceThreshold: undefined,
}),
)
})
it('passes confidence_threshold through to the matcher and surfaces skipped matches', async () => {
runRecMock.mockResolvedValueOnce({
matches: [],
applied: 0,
errors: 0,
skippedBelowThreshold: 2,
})
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
cash_accounts: { data: { id: 'ca-1930', currency: 'SEK' }, error: null },
}),
)
const res = await runPOST(
postRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/reconciliation/bank/run`, {
confidence_threshold: 0.9,
}),
{ params: Promise.resolve({ companyId: COMPANY_ID }) },
)
expect(res.status).toBe(200)
const body = await res.json()
expect(body.data.skipped_below_threshold).toBe(2)
expect(runRecMock).toHaveBeenCalledWith(
expect.anything(),
COMPANY_ID,
'user-1',
expect.objectContaining({ confidenceThreshold: 0.9 }),
)
})
it('rejects an out-of-range confidence_threshold with 400', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
cash_accounts: { data: { id: 'ca-1930', currency: 'SEK' }, error: null },
}),
)
for (const bad of [-0.1, 1.5]) {
const res = await runPOST(
postRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/reconciliation/bank/run`, {
confidence_threshold: bad,
}),
{ params: Promise.resolve({ companyId: COMPANY_ID }) },
)
expect(res.status).toBe(400)
}
expect(runRecMock).not.toHaveBeenCalled()
})
it('rejects an unknown settlement account', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
@@ -25,6 +25,13 @@ const RunRequest = z
// '1932' (EUR). Defaults to '1930'. Required for multi-account companies to
// reconcile anything other than their primary SEK account.
account_number: z.string().regex(/^\d{4}$/).optional(),
// Server-side confidence floor for a non-dry run (0..1), mirroring the
// gnubok_auto_match_period MCP tool's parameter (default 0.9 there).
// Matches below it are returned but NOT applied (counted in
// skipped_below_threshold). Omitted = legacy behavior: every proposed
// match applies, including auto_fuzzy at 0.75. Unattended callers should
// pass 0.9.
confidence_threshold: z.number().min(0).max(1).optional(),
})
// Bound the window so a key with no explicit range can't trigger an
// unbounded join across years. 366 days covers a full räkenskapsår + a
@@ -59,6 +66,10 @@ const RunResponse = z.object({
// race). Documented as z.array(z.string()) until 2026-07: the lib has always
// returned a number.
errors: z.number().int(),
// Matches proposed but not applied because their confidence fell below the
// request's confidence_threshold. They still appear in `matches` (with their
// confidence) for review. Always 0 on dry runs and when no threshold is set.
skipped_below_threshold: z.number().int(),
})
registerEndpoint({
@@ -76,13 +87,13 @@ registerEndpoint({
'date_from / date_to default to the company\'s full bank history if omitted. Specify a window for predictable performance.',
'account_number defaults to 1930. Multi-account companies must pass the BAS code of the account they are reconciling (e.g. 1932 for a EUR account), or it silently reconciles 1930.',
'Idempotency-Key is mandatory.',
'A non-dry run applies EVERY match found, including fuzzy ones at confidence 0.75: there is no internal confidence threshold. Dry-run first and review matches.confidence before applying.',
'Without confidence_threshold, a non-dry run applies EVERY match found, including fuzzy ones at confidence 0.75. Pass confidence_threshold (0.9 recommended, matching gnubok_auto_match_period) for unattended runs, or dry-run first and review matches.confidence before applying. Matches below the threshold are returned but not applied (skipped_below_threshold counts them).',
'The 366-day window bound only applies when BOTH date_from and date_to are set; a single-sided or absent window scans full history.',
],
example: {
request: { date_from: '2026-05-01', date_to: '2026-05-31' },
request: { date_from: '2026-05-01', date_to: '2026-05-31', confidence_threshold: 0.9 },
response: {
data: { matches: [], applied: 0, errors: 0 },
data: { matches: [], applied: 0, errors: 0, skipped_below_threshold: 0 },
meta: { request_id: 'req_…', api_version: '2026-05-12' },
},
},
@@ -151,6 +162,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>(
// Only the primary account claims unassigned (NULL cash_account_id) rows.
includeUnassigned: Boolean(cashAccount?.is_primary),
dryRun: ctx.dryRun,
confidenceThreshold: body.confidence_threshold,
})
} catch (err) {
ctx.log.error('reconciliation.bank.run: pipeline failed', err as Error)
@@ -175,6 +187,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>(
matches,
applied: result.applied,
errors: result.errors,
skipped_below_threshold: result.skippedBelowThreshold,
}
if (ctx.dryRun) {