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
@@ -516,6 +516,123 @@ describe('runReconciliation', () => {
expect(result.errors).toBe(0)
})
it('skips matches below confidenceThreshold in apply mode but still reports them', async () => {
const { supabase, enqueue } = createQueueMockSupabase()
// auto_exact (0.95): above the 0.9 floor, must apply.
const txExact = makeTransaction({ id: 'tx-exact', amount: 1000, date: '2024-06-15', currency: 'SEK' })
const lineExact = makeGLLine({
line_id: 'line-exact',
journal_entry_id: 'je-exact',
debit_amount: 1000,
entry_date: '2024-06-15',
})
// auto_fuzzy (0.75): below the 0.9 floor, must be skipped, not applied.
const txFuzzy = makeTransaction({ id: 'tx-fuzzy', amount: -999.99, date: '2024-06-15', currency: 'SEK' })
const lineFuzzy = makeGLLine({
line_id: 'line-fuzzy',
journal_entry_id: 'je-fuzzy',
credit_amount: 1000,
entry_date: '2024-06-15',
})
enqueue({ data: [lineExact, lineFuzzy] })
enqueue({ data: [txExact, txFuzzy] })
// Exactly ONE update runs: the exact match. If the fuzzy match were
// applied too, the queue would be short and this enqueue insufficient.
enqueue({ data: [{ id: 'tx-exact' }] })
const result = await runReconciliation(supabase as never, 'company-1', 'user-1', {
dryRun: false,
confidenceThreshold: 0.9,
})
// Both matches are REPORTED (skipped is not silently dropped) ...
expect(result.matches).toHaveLength(2)
// ... but only the high-confidence one is applied.
expect(result.applied).toBe(1)
expect(result.errors).toBe(0)
expect(result.skippedBelowThreshold).toBe(1)
const skipped = result.matches.find((m) => m.transaction.id === 'tx-fuzzy')
expect(skipped?.method).toBe('auto_fuzzy')
})
it('applies a match exactly at the threshold (floor is inclusive)', async () => {
const { supabase, enqueue } = createQueueMockSupabase()
// auto_date_range scores exactly 0.85: with threshold 0.85 it must apply.
const tx = makeTransaction({ id: 'tx-1', amount: 750, date: '2024-06-17', currency: 'SEK' })
const line = makeGLLine({
line_id: 'line-1',
journal_entry_id: 'je-1',
debit_amount: 750,
entry_date: '2024-06-15',
})
enqueue({ data: [line] })
enqueue({ data: [tx] })
enqueue({ data: [{ id: 'tx-1' }] })
const result = await runReconciliation(supabase as never, 'company-1', 'user-1', {
dryRun: false,
confidenceThreshold: 0.85,
})
expect(result.applied).toBe(1)
expect(result.skippedBelowThreshold).toBe(0)
})
it('dry run is unaffected by confidenceThreshold: every proposal is returned', async () => {
const { supabase, enqueue } = createQueueMockSupabase()
const txFuzzy = makeTransaction({ id: 'tx-fuzzy', amount: -999.99, date: '2024-06-15', currency: 'SEK' })
const lineFuzzy = makeGLLine({
line_id: 'line-fuzzy',
journal_entry_id: 'je-fuzzy',
credit_amount: 1000,
entry_date: '2024-06-15',
})
enqueue({ data: [lineFuzzy] })
enqueue({ data: [txFuzzy] })
const result = await runReconciliation(supabase as never, 'company-1', 'user-1', {
dryRun: true,
confidenceThreshold: 0.9,
})
// The preview always shows the full proposal set: filtering happens only
// on apply, so the user can still review + tick fuzzy matches manually.
expect(result.matches).toHaveLength(1)
expect(result.matches[0].method).toBe('auto_fuzzy')
expect(result.applied).toBe(0)
expect(result.skippedBelowThreshold).toBe(0)
})
it('applies every match, including fuzzy, when no threshold is given (legacy behavior)', async () => {
const { supabase, enqueue } = createQueueMockSupabase()
const txFuzzy = makeTransaction({ id: 'tx-fuzzy', amount: -999.99, date: '2024-06-15', currency: 'SEK' })
const lineFuzzy = makeGLLine({
line_id: 'line-fuzzy',
journal_entry_id: 'je-fuzzy',
credit_amount: 1000,
entry_date: '2024-06-15',
})
enqueue({ data: [lineFuzzy] })
enqueue({ data: [txFuzzy] })
enqueue({ data: [{ id: 'tx-fuzzy' }] })
const result = await runReconciliation(supabase as never, 'company-1', 'user-1', {
dryRun: false,
})
expect(result.applied).toBe(1)
expect(result.errors).toBe(0)
expect(result.skippedBelowThreshold).toBe(0)
})
it('ignores applyOnly on dry runs and returns the full match set', async () => {
const { supabase, enqueue } = createQueueMockSupabase()
+45 -4
View File
@@ -37,8 +37,24 @@ export interface ReconciliationRunResult {
matches: ReconciliationMatch[]
applied: number
errors: number
/**
* Matches the matcher proposed but the apply loop skipped because their
* confidence fell below the caller's confidenceThreshold. They stay in
* `matches` (reported, not silently dropped) so the caller can surface them
* for human review. Always 0 on dry runs and when no threshold was given.
*/
skippedBelowThreshold: number
}
/**
* Confidence floor for UNATTENDED auto-apply (nightly enable-banking sync,
* cron). Mirrors the default of the gnubok_auto_match_period MCP tool (0.9):
* high enough that auto_fuzzy (0.75) and auto_date_range (0.85) matches are
* never committed without human review, while auto_exact (0.95) and
* auto_reference (0.90) still apply.
*/
export const DEFAULT_UNATTENDED_CONFIDENCE_THRESHOLD = 0.9
export interface ReconciliationStatus {
bank_transaction_total: number
/**
@@ -112,6 +128,17 @@ export interface ReconciliationOptions {
* match set rather than trusting the client's pairs blindly.
*/
applyOnly?: Array<{ transactionId: string; journalEntryId: string }>
/**
* Server-side confidence floor for the apply path (0..1; out-of-range values
* are clamped, mirroring gnubok_auto_match_period). Matches below it are NOT
* applied: they stay in `matches` and are counted in skippedBelowThreshold.
* Ignored on dry runs (the preview always returns every proposal). Omit for
* the legacy behavior: apply every proposed match, including auto_fuzzy at
* 0.75. Unattended callers must pass a floor (see
* DEFAULT_UNATTENDED_CONFIDENCE_THRESHOLD) so fuzzy matches are never
* committed without human review.
*/
confidenceThreshold?: number
}
/**
@@ -262,6 +289,7 @@ export async function runReconciliation(
cashAccountId,
includeUnassigned = true,
applyOnly,
confidenceThreshold,
} = options
// Fetch unlinked GL lines via RPC
@@ -285,14 +313,14 @@ export async function runReconciliation(
})
if (transactions.length === 0 || glLines.length === 0) {
return { matches: [], applied: 0, errors: 0 }
return { matches: [], applied: 0, errors: 0, skippedBelowThreshold: 0 }
}
// Run greedy matching, highest confidence first
let matches = greedyMatch(transactions, glLines, currency)
if (dryRun) {
return { matches, applied: 0, errors: 0 }
return { matches, applied: 0, errors: 0, skippedBelowThreshold: 0 }
}
// When the caller reviewed a dry-run and ticked a subset, apply ONLY pairs
@@ -305,11 +333,24 @@ export async function runReconciliation(
)
}
// Confidence floor: never auto-apply a match below the caller's threshold.
// Skipped matches are not errors: they remain in `matches` (with their
// confidence) so the caller can report them for human review, and are
// counted separately. This is the server-side guardrail for unattended
// callers (nightly sync / cron), where nobody reviews a dry-run first.
let toApply = matches
let skippedBelowThreshold = 0
if (confidenceThreshold !== undefined) {
const floor = Math.max(0, Math.min(1, confidenceThreshold))
toApply = matches.filter((m) => m.confidence >= floor)
skippedBelowThreshold = matches.length - toApply.length
}
// Apply matches
let applied = 0
let errors = 0
for (const match of matches) {
for (const match of toApply) {
try {
// .is('journal_entry_id', null) is an optimistic-lock guard: if a
// concurrent user (or another surface) linked this transaction between
@@ -352,7 +393,7 @@ export async function runReconciliation(
}
}
return { matches, applied, errors }
return { matches, applied, errors, skippedBelowThreshold }
}
// ============================================================