* feat(reconciliation): propose the explaining voucher set for a bank row before offering Bokför (#2293) The bridge table said "ej matchad" and steered to Bokför when a Bankgirot aggregate was already booked as two or three unlinked vouchers. The booking doors have refused that double booking since #2300 and #2346 with detectExplainingVoucherSet; the view never ran it. - duplicate-payment-detection: split the set detector into fetch and pure steps and add detectExplainingVoucherSets, the batch form (one ledger scan, one anchor lookup, per-row verdict identical to the single detector; a voucher explains at most one row per call). ExplainingVoucher now also carries voucher_series and voucher_number. - reconciliation/covering-set-candidate (new): maps sets to proposals (0.95 same date, 0.85 within seven days), SEK accounts only, fails open. - items: open bank rows nothing explains 1:1 are searched before they land in unmatched_external; a hit lands in proposed with proposal.vouchers. - schemas: ReconciliationProposal.vouchers (optional, set proposals only). - AccountOverview: "= A57 + A58" with the legs' amounts, one Koppla that posts every voucher as a 1:N pair to the existing links route. - i18n: reconciliation.proposal_set_title and proposal_set_same_day (sv, en). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LvMaHcTnwAfxzgYD1fGYX1 * chore(skill): regenerate accounted-api for ReconciliationProposal.vouchers (#2293) The set proposal field added to the reconciliation items response shape flows into the generated agent skill; regenerated with `npm run apiskill:generate`, which changes one line of references/banking.md. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LvMaHcTnwAfxzgYD1fGYX1 --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
114 lines
4.7 KiB
TypeScript
114 lines
4.7 KiB
TypeScript
import type { SupabaseClient } from '@supabase/supabase-js'
|
|
import {
|
|
detectExplainingVoucherSets,
|
|
type ExplainingVoucherSet,
|
|
} from '@/lib/invoices/duplicate-payment-detection'
|
|
import { createLogger } from '@/lib/logger'
|
|
import type { ReconciliationProposal } from './schemas'
|
|
|
|
const log = createLogger('reconciliation.covering-set-candidate')
|
|
|
|
/**
|
|
* Covering-set proposals for the bridge table (#2293): a bank row nothing
|
|
* matches 1:1 is searched for the unlinked verifikat on its account whose
|
|
* bank legs sum exactly to it, BEFORE the row is offered as unmatched.
|
|
*
|
|
* Why: a Bankgirot daily aggregate arrives as one row for several
|
|
* affärshändelser, each often already booked on its own ("Markera som
|
|
* betald" per invoice, one salary voucher per employee). The 1:1 matcher
|
|
* sees no voucher of the row's amount, the row reads "ej matchad", and the
|
|
* next door (Bokför) books the money a second time. The booking doors have
|
|
* refused that since #2300 and #2346 by running detectExplainingVoucherSet;
|
|
* this runs the SAME detector (its batch form) in the read path so the view
|
|
* stops steering users to the door the guard slams. One definition of
|
|
* "explained by the ledger", at the doors and in the view.
|
|
*
|
|
* Read-time only, never persisted: the pool is the account's open rows
|
|
* against its unlinked legs, and a stored hint would add a stale-pointer
|
|
* class for no saving (the utlägg pairing precedent, DECISIONS 2026-09-05).
|
|
* Never auto-applied: the unattended sweep persists and applies 1:1 matches
|
|
* only, and use_proposals reads the persisted column. A set proposal is
|
|
* confirmed per row through the ordinary 1:N link (linkTransactionToVouchers),
|
|
* which revalidates every slice at click time.
|
|
*
|
|
* SEK accounts only. The detector sums in SEK; the 1:N link that confirms
|
|
* the proposal compares in the account's own currency (ledgerLineAmountIn),
|
|
* so on a EUR account a set that sums in SEK could be refused at confirm. A
|
|
* proposal that cannot be confirmed is worse than none.
|
|
*/
|
|
|
|
/** Exact öre sum, every voucher dated on the row's date: as strong as auto_exact. */
|
|
export const COVERING_SET_SAME_DATE_CONFIDENCE = 0.95
|
|
/** Exact öre sum within ±7 days: as strong as auto_date_range, below the 0.9 unattended floor. */
|
|
export const COVERING_SET_WINDOW_CONFIDENCE = 0.85
|
|
|
|
/** The columns of an open bank row the search needs. */
|
|
export interface CoveringSetRow {
|
|
id: string
|
|
date: string
|
|
amount: number
|
|
currency: string | null
|
|
}
|
|
|
|
/** Pure: the proposal the bridge table renders for an explaining set. */
|
|
export function coveringSetProposal(set: ExplainingVoucherSet): ReconciliationProposal {
|
|
const [first] = set.vouchers
|
|
const descriptions = set.vouchers
|
|
.map((v) => (v.description ?? '').trim())
|
|
.filter((d) => d.length > 0)
|
|
return {
|
|
journal_entry_id: first.journal_entry_id,
|
|
voucher_number: first.voucher_number,
|
|
voucher_series: first.voucher_series,
|
|
entry_date: first.entry_date,
|
|
description: descriptions.join(' + '),
|
|
entry_status: 'posted',
|
|
confidence: set.same_date ? COVERING_SET_SAME_DATE_CONFIDENCE : COVERING_SET_WINDOW_CONFIDENCE,
|
|
reasons: [set.same_date ? 'exact_sum_same_date' : 'exact_sum_within_window'],
|
|
vouchers: set.vouchers.map((v) => ({
|
|
journal_entry_id: v.journal_entry_id,
|
|
voucher_number: v.voucher_number,
|
|
voucher_series: v.voucher_series,
|
|
entry_date: v.entry_date,
|
|
description: v.description ?? '',
|
|
amount: v.amount,
|
|
})),
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Proposals keyed by transaction id for the open rows of one bank account.
|
|
* Rows the detector cannot explain are simply absent. Advisory: a detector
|
|
* failure logs and returns nothing rather than taking the bridge table down;
|
|
* the row stays unmatched and the booking doors keep their own guard.
|
|
*/
|
|
export async function proposeCoveringSets(
|
|
supabase: SupabaseClient,
|
|
companyId: string,
|
|
account: { ledger_account: string; currency: string | null },
|
|
rows: CoveringSetRow[],
|
|
): Promise<Map<string, ReconciliationProposal>> {
|
|
const proposals = new Map<string, ReconciliationProposal>()
|
|
if (rows.length === 0 || (account.currency ?? 'SEK') !== 'SEK') return proposals
|
|
try {
|
|
const sets = await detectExplainingVoucherSets(supabase, {
|
|
companyId,
|
|
bankAccountNumber: account.ledger_account,
|
|
transactions: rows.map((r) => ({ id: r.id, date: r.date, amount: r.amount, currency: r.currency })),
|
|
})
|
|
for (const [transactionId, set] of sets) {
|
|
proposals.set(transactionId, coveringSetProposal(set))
|
|
}
|
|
} catch (err) {
|
|
log.warn('covering-set proposals skipped', {
|
|
companyId,
|
|
entityType: 'cash_account',
|
|
details: {
|
|
account: account.ledger_account,
|
|
message: err instanceof Error ? err.message : String(err),
|
|
},
|
|
})
|
|
}
|
|
return proposals
|
|
}
|