diff --git a/DECISIONS.md b/DECISIONS.md index 3f01c42b..65fb59e9 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -14,3 +14,4 @@ One line per decision: `[YYYY-MM-DD] : `. 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. diff --git a/app/api/extensions/enable-banking/sync/cron/route.ts b/app/api/extensions/enable-banking/sync/cron/route.ts index 1c0af0a3..842ef23c 100644 --- a/app/api/extensions/enable-banking/sync/cron/route.ts +++ b/app/api/extensions/enable-banking/sync/cron/route.ts @@ -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 } diff --git a/app/api/v1/companies/[companyId]/reconciliation/bank/__tests__/route.test.ts b/app/api/v1/companies/[companyId]/reconciliation/bank/__tests__/route.test.ts index 6bb7797d..a541e423 100644 --- a/app/api/v1/companies/[companyId]/reconciliation/bank/__tests__/route.test.ts +++ b/app/api/v1/companies/[companyId]/reconciliation/bank/__tests__/route.test.ts @@ -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({ diff --git a/app/api/v1/companies/[companyId]/reconciliation/bank/run/route.ts b/app/api/v1/companies/[companyId]/reconciliation/bank/run/route.ts index ab396493..bfacb60d 100644 --- a/app/api/v1/companies/[companyId]/reconciliation/bank/run/route.ts +++ b/app/api/v1/companies/[companyId]/reconciliation/bank/run/route.ts @@ -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) { diff --git a/extensions/general/enable-banking/index.ts b/extensions/general/enable-banking/index.ts index 0ee96260..b1c36b91 100644 --- a/extensions/general/enable-banking/index.ts +++ b/extensions/general/enable-banking/index.ts @@ -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, }) } diff --git a/lib/reconciliation/__tests__/bank-reconciliation.test.ts b/lib/reconciliation/__tests__/bank-reconciliation.test.ts index d537e02b..b17e3709 100644 --- a/lib/reconciliation/__tests__/bank-reconciliation.test.ts +++ b/lib/reconciliation/__tests__/bank-reconciliation.test.ts @@ -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() diff --git a/lib/reconciliation/bank-reconciliation.ts b/lib/reconciliation/bank-reconciliation.ts index d9e3c240..a899ab48 100644 --- a/lib/reconciliation/bank-reconciliation.ts +++ b/lib/reconciliation/bank-reconciliation.ts @@ -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 } } // ============================================================