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:
co-authored by
Claude Fable 5
parent
b700108107
commit
9b126d22b9
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user