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
@@ -14,3 +14,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-07-05] Salary run "Ångra godkännande" transitions approved → review (not straight to draft) and hard-deletes generated-but-unfiled AGI declarations — symmetric with the approve step for a clean audit trail, and stale AGI XML must not stay exportable. Blocked with 409 once the AGI is pending_signature/submitted/accepted: the lawful path is then a correction AGI with the same specifikationsnummer. Payment-file tracking is cleared; whether the file reached the bank is outside app knowledge, so the UI confirm makes the user own that check.
|
||||
[2026-07-05] PR #894 bot triage: accepted the delete-after-update reorder (destructive op last) and the manual-filing warning in confirm_unapprove_agi; declined soft-cancel status for unfiled AGI drafts and preserving approved_by on recall — a never-filed generated AGI is regenerable working data derived entirely from retained run data (not räkenskapsinformation; unapprove 409s once anything is filed), and the approval with legal weight is the one in force at booking, which unapprove can never touch (paid/booked runs are locked out).
|
||||
[2026-07-06] Migration 20260706100000 adds profiles.deleted_at/anonymized_at (ADD COLUMN IF NOT EXISTS) alongside committing anonymize_user_account verbatim: the prod function writes those columns but no repo migration ever created them, so without the columns the drift capture would ship a function that fails on every from-scratch database (CI replay, self-hosted). No-op on prod.
|
||||
[2026-07-06] v1 reconciliation run: confidence_threshold has NO server-side default when omitted (existing API consumers keep current behavior; only the unattended enable-banking sync callers pass DEFAULT_UNATTENDED_CONFIDENCE_THRESHOLD=0.9); registry pitfalls recommend 0.9 to integrators. Revisit if telemetry shows API callers auto-applying fuzzy matches.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -10,7 +10,10 @@ import {
|
||||
type ASPSP,
|
||||
} from './lib/api-client'
|
||||
import { syncAccountTransactions } from './lib/sync'
|
||||
import { runReconciliation } from '@/lib/reconciliation/bank-reconciliation'
|
||||
import {
|
||||
runReconciliation,
|
||||
DEFAULT_UNATTENDED_CONFIDENCE_THRESHOLD,
|
||||
} from '@/lib/reconciliation/bank-reconciliation'
|
||||
import { checkRateLimit } from '@/lib/auth/rate-limit-http'
|
||||
import type { StoredAccount } from './types'
|
||||
import type { Transaction } from '@/types'
|
||||
@@ -523,10 +526,14 @@ export const enableBankingExtension: Extension = {
|
||||
const reconResult = await runReconciliation(supabase, companyId, user.id, {
|
||||
dateFrom: fromDate,
|
||||
dateTo: toDate,
|
||||
// This sweep applies without a human reviewing a dry-run, so
|
||||
// never commit low-confidence (fuzzy / date-range) matches.
|
||||
confidenceThreshold: DEFAULT_UNATTENDED_CONFIDENCE_THRESHOLD,
|
||||
})
|
||||
if (reconResult.applied > 0) {
|
||||
if (reconResult.applied > 0 || reconResult.skippedBelowThreshold > 0) {
|
||||
log.info('Post-sync batch reconciliation matched additional transactions', {
|
||||
applied: reconResult.applied,
|
||||
skippedBelowThreshold: reconResult.skippedBelowThreshold,
|
||||
total: reconResult.matches.length,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
|
||||
Reference in New Issue
Block a user