* fix(payments): refuse to book a bank row that unlinked vouchers already explain A bank feed can deliver several affarshandelser as one row (a Bankgirot daily aggregate: two customers' invoices, one "BGGIRERING" row with no payer). When each invoice was already marked paid by hand, nothing on the account equals the row, the 1:1 duplicate check passes, and "Dela betalning" books the money a second time against whatever open invoices the user picks (the next period's identical ones, in the reported case). - lib/reconciliation/covering-set.ts: exact ore subset sum over a capped candidate list, smallest set first, closest in date second. - detectExplainingVoucherSet(+ForTransaction): the vouchers whose bank legs on the row's settlement account, in the row's direction, within 7 days, add up exactly to the row; linked through any of the three anchors drops a voucher, a payment row without a bank transaction keeps it. - POST match-batch refuses with BATCH_TX_POSSIBLE_DUPLICATE and returns the set; force=true must echo expected_journal_entry_ids (same binding as the single door). Fails open on a detection error. - GET duplicate-payment-check returns candidate_set next to candidate. - MatchAllocationDialog: pre-flight panel with the vouchers, one click links the row to them through the existing 1:1 or 1:N bank link (no new voucher), "Bokfor anda" acknowledges the set; confirm is disabled until then. Invoices dated after the bank row get a hint badge. - Mark-paid guard: aggregate sweep (row = this invoice + an exact subset of other open invoices, 7 days, kronor) when the name sweeps found nothing; PaymentBookingDialog shows the covered invoice numbers and points to the split under Transaktioner. Follow-ups: #2293 (1:N proposals in the auto-matcher), #2294 (MCP staging guard), #2299 (supplier-side text guard). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NyjeEi1U8vnuPT4QXgayXu * test(invoices): account for the aggregate sweep in the mark-paid route queue The sweep issues one more transactions query whenever the name probes come back empty, so every queued-mock sequence that reaches it gains a slot. The sweep itself now fails open on odd client shapes (a single object for a list query) and on errors: an advisory guard must never block "Markera som betald". Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NyjeEi1U8vnuPT4QXgayXu * fix(payments): fail open on resolved query errors; aggregate sweep without a payer name Review follow-ups on #2300. A PostgREST failure resolves with { data: null, error } instead of throwing, so the set detector read a failed link lookup as "no links" and a failed cash-account lookup as "scan every 19xx account"; both now return null (the booking RPC keeps the last word). The aggregate sweep never needed a customer name (a Bankgirot row names nobody), so a nameless invoice goes straight to it instead of skipping the guard. The already-booked panel is announced as a live region, and the "also covers" string is plural-aware. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NyjeEi1U8vnuPT4QXgayXu --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
445 lines
16 KiB
TypeScript
445 lines
16 KiB
TypeScript
import type { SupabaseClient } from '@supabase/supabase-js'
|
|
import {
|
|
DUPLICATE_AMOUNT_TOLERANCE_PCT,
|
|
DUPLICATE_DATE_WINDOW_DAYS,
|
|
escapeLikePattern,
|
|
normalizeOcrReference,
|
|
} from './duplicate-payment-guard'
|
|
import {
|
|
invoiceAmountSek,
|
|
magnitudesWithinTolerance,
|
|
normalizeCurrencyCode,
|
|
planAmountSweeps,
|
|
type ComparableAmount,
|
|
} from './duplicate-guard-currency'
|
|
import { resolveTransactionAmountSek } from '@/lib/transactions/booking-duplicate-detection'
|
|
import { findExactCoveringSet } from '@/lib/reconciliation/covering-set'
|
|
import { roundOre } from '@/lib/money'
|
|
import { createLogger } from '@/lib/logger'
|
|
|
|
const log = createLogger('invoices/duplicate-payment-candidates')
|
|
|
|
export type DuplicatePaymentMatchReason =
|
|
| 'ocr_exact'
|
|
| 'name_amount_fuzzy'
|
|
| 'amount_only'
|
|
/**
|
|
* The bank row is larger than this payment and the difference is exactly
|
|
* (to the öre) the remaining amount of one to three OTHER open invoices:
|
|
* a Bankgirot daily aggregate that settled this invoice together with
|
|
* them. No counterparty text is consulted; such rows carry none.
|
|
*/
|
|
| 'aggregate_exact'
|
|
|
|
export interface DuplicatePaymentCandidate {
|
|
id: string
|
|
date: string
|
|
amount: number
|
|
description: string | null
|
|
merchant_name: string | null
|
|
reference: string | null
|
|
match_reason: DuplicatePaymentMatchReason
|
|
match_confidence: number
|
|
/** For aggregate_exact: the other open invoices the row also covers. */
|
|
aggregate_invoice_numbers?: string[]
|
|
}
|
|
|
|
const MATCH_REASON_RANK: Record<DuplicatePaymentMatchReason, number> = {
|
|
ocr_exact: 0,
|
|
aggregate_exact: 1,
|
|
name_amount_fuzzy: 2,
|
|
amount_only: 3,
|
|
}
|
|
|
|
const MATCH_REASON_CONFIDENCE: Record<DuplicatePaymentMatchReason, number> = {
|
|
ocr_exact: 0.99,
|
|
aggregate_exact: 0.9,
|
|
name_amount_fuzzy: 0.7,
|
|
amount_only: 0.5,
|
|
}
|
|
|
|
/** ± days around the payment date an aggregate row is looked for: a Bankgirot
|
|
* aggregate lands on the payment day, so the wide name-sweep window would only
|
|
* add coincidental sums. */
|
|
const AGGREGATE_DATE_WINDOW_DAYS = 7
|
|
/** Other open invoices an aggregate row may cover besides this one. */
|
|
const AGGREGATE_MAX_OTHER_INVOICES = 3
|
|
const AGGREGATE_MAX_ROWS = 40
|
|
const AGGREGATE_MAX_OPEN_INVOICES = 200
|
|
|
|
interface CustomerInvoice {
|
|
invoice_number: string | null
|
|
customer_name: string | null | undefined
|
|
/**
|
|
* `invoices.currency`; null means SEK (the column default). REQUIRED rather
|
|
* than optional on purpose: an optional field silently reads as SEK for any
|
|
* caller that forgets it, which is exactly how a 1 000 EUR payment came to be
|
|
* banded against a kronor column. TypeScript now refuses the call instead.
|
|
*/
|
|
currency: string | null
|
|
/** `invoices.total`, in `currency`. Pro-rates `total_sek` down to the payment. */
|
|
total: number | null
|
|
/** `invoices.total_sek`: the stored SEK view of `total`. */
|
|
total_sek: number | null
|
|
/** `invoices.exchange_rate`: SEK per unit of `currency`. */
|
|
exchange_rate: number | null
|
|
}
|
|
|
|
type Row = {
|
|
id: string
|
|
date: string
|
|
amount: number
|
|
description: string | null
|
|
merchant_name: string | null
|
|
reference: string | null
|
|
currency: string | null
|
|
amount_sek: number | null
|
|
exchange_rate: number | null
|
|
}
|
|
|
|
/**
|
|
* Scan unlinked positive (inbound) business bank transactions that could be
|
|
* the payment for this customer invoice. Used by the mark-paid duplicate
|
|
* guard: callers route the user to "link existing" instead of double-booking.
|
|
*
|
|
* Customer-side adaptations vs the supplier guard:
|
|
* - amount > 0 (inbound) instead of < 0
|
|
* - matches BOTH `merchant_name` AND `description` (banks often describe an
|
|
* inbound payment by payer name without populating merchant_name)
|
|
* - per-candidate scoring with OCR (invoice_number normalized) as the
|
|
* strongest signal
|
|
*
|
|
* Units: `paymentAmount` is denominated in the INVOICE's currency (that is what
|
|
* `invoices.remaining_amount` and `total` are stored in), while
|
|
* `transactions.amount` is denominated in the bank row's own currency. The
|
|
* plus-minus tolerance band is therefore planned per currency by
|
|
* `planAmountSweeps` and re-checked per row by `magnitudesWithinTolerance`:
|
|
* band and column always share a unit, and a candidate that cannot be brought
|
|
* into a shared unit is excluded rather than compared as a raw number. A SEK
|
|
* invoice produces exactly one sweep with the same band as before.
|
|
*
|
|
* The merchant_name and description searches are issued as two separate
|
|
* parameterised `.ilike()` queries and deduplicated by id. We deliberately
|
|
* avoid `.or('merchant_name.ilike.%X%,description.ilike.%X%')` because that
|
|
* interpolates the customer name into PostgREST's filter-DSL string, where
|
|
* `escapeLikePattern` only neutralises the LIKE wildcards (`%_\\`) and not
|
|
* the DSL chars (`,`, `.`, `(`, `)`). A name like `Acme,fake.eq.true` would
|
|
* otherwise inject a synthetic filter clause.
|
|
*/
|
|
export async function findDuplicatePaymentCandidatesForInvoice(
|
|
supabase: SupabaseClient,
|
|
params: {
|
|
companyId: string
|
|
invoice: CustomerInvoice
|
|
/** The payment being booked, in `invoice.currency`. */
|
|
paymentAmount: number
|
|
paymentDate: string
|
|
},
|
|
): Promise<DuplicatePaymentCandidate[]> {
|
|
const { companyId, invoice, paymentAmount, paymentDate } = params
|
|
const customerName = invoice.customer_name
|
|
const paymentCurrency = normalizeCurrencyCode(invoice.currency)
|
|
|
|
// The name sweeps need a payer to look for; the aggregate sweep does not
|
|
// (a Bankgirot row names nobody), so a nameless invoice skips straight to it.
|
|
if (!customerName) {
|
|
if (paymentCurrency !== 'SEK') return []
|
|
return runAggregateSweep(supabase, { companyId, invoice, paymentAmount, paymentDate })
|
|
}
|
|
const reference: ComparableAmount = {
|
|
amount: paymentAmount,
|
|
currency: paymentCurrency,
|
|
sek: invoiceAmountSek({
|
|
amount: paymentAmount,
|
|
currency: paymentCurrency,
|
|
total: invoice.total,
|
|
totalSek: invoice.total_sek,
|
|
exchangeRate: invoice.exchange_rate,
|
|
}),
|
|
}
|
|
const { sweeps, crossCurrencyUnverifiable } = planAmountSweeps(
|
|
reference,
|
|
DUPLICATE_AMOUNT_TOLERANCE_PCT,
|
|
)
|
|
if (sweeps.length === 0) return []
|
|
if (crossCurrencyUnverifiable) {
|
|
// A foreign invoice with neither a usable total_sek nor an exchange_rate
|
|
// cannot be stated in kronor, so kronor bank rows are excluded rather than
|
|
// compared raw (a raw compare reads 1 000 kr as 1 000 EUR). Same-currency
|
|
// rows are still swept. Logged for the same reason the supplier-side twin
|
|
// logs it: an unevaluated candidate set is not a clean "no duplicate", and
|
|
// the gap must be visible in behandlingshistorik (BFNAR 2013:2 p. 9.16)
|
|
// rather than pass silently.
|
|
log.warn('duplicate-payment guard: cross-currency candidates not evaluated', {
|
|
reason: 'invoice_missing_sek_value',
|
|
companyId,
|
|
currency: paymentCurrency,
|
|
invoiceNumber: invoice.invoice_number,
|
|
})
|
|
}
|
|
|
|
const dateMs = new Date(paymentDate).getTime()
|
|
const dateLow = new Date(dateMs - DUPLICATE_DATE_WINDOW_DAYS * 24 * 3600 * 1000)
|
|
.toISOString()
|
|
.split('T')[0]
|
|
const dateHigh = new Date(dateMs + DUPLICATE_DATE_WINDOW_DAYS * 24 * 3600 * 1000)
|
|
.toISOString()
|
|
.split('T')[0]
|
|
const pattern = `%${escapeLikePattern(customerName)}%`
|
|
|
|
const base = (sweepIndex: number) => {
|
|
const sweep = sweeps[sweepIndex]
|
|
return supabase
|
|
.from('transactions')
|
|
.select(
|
|
'id, date, amount, description, merchant_name, reference, currency, amount_sek, exchange_rate',
|
|
)
|
|
.eq('company_id', companyId)
|
|
.eq('is_business', true)
|
|
.is('invoice_id', null)
|
|
.is('supplier_invoice_id', null)
|
|
.gt('amount', 0)
|
|
.or(sweep.currencyFilter)
|
|
.gte('amount', sweep.low)
|
|
.lte('amount', sweep.high)
|
|
.gte('date', dateLow)
|
|
.lte('date', dateHigh)
|
|
}
|
|
|
|
const responses = await Promise.all(
|
|
sweeps.flatMap((_sweep, i) => [
|
|
base(i).ilike('merchant_name', pattern).order('date', { ascending: false }).limit(5),
|
|
base(i).ilike('description', pattern).order('date', { ascending: false }).limit(5),
|
|
]),
|
|
)
|
|
|
|
const merged = new Map<string, Row>()
|
|
for (const res of responses) {
|
|
for (const row of (res.data ?? []) as Row[]) {
|
|
if (!merged.has(row.id)) merged.set(row.id, row)
|
|
}
|
|
}
|
|
|
|
const data = Array.from(merged.values())
|
|
.filter((row) =>
|
|
magnitudesWithinTolerance(reference, rowAmount(row), DUPLICATE_AMOUNT_TOLERANCE_PCT),
|
|
)
|
|
.sort((a, b) => (a.date < b.date ? 1 : a.date > b.date ? -1 : 0))
|
|
.slice(0, 5)
|
|
|
|
// Nothing of this invoice's own size: look for the row that paid it TOGETHER
|
|
// with other invoices. One warning is enough, so the sweep only runs when
|
|
// the name sweeps came back empty. Kronor only: the sum is taken over
|
|
// remaining amounts stored in invoice currency.
|
|
if (data.length === 0) {
|
|
if (paymentCurrency !== 'SEK') return []
|
|
return runAggregateSweep(supabase, { companyId, invoice, paymentAmount, paymentDate })
|
|
}
|
|
|
|
const invoiceOcr = normalizeOcrReference(invoice.invoice_number)
|
|
const searchTerms = customerName
|
|
.toLowerCase()
|
|
.split(/\s+/)
|
|
.filter((term) => term.length > 2)
|
|
|
|
const candidates: DuplicatePaymentCandidate[] = data.map((row) => {
|
|
const reason = scoreCandidate({
|
|
row,
|
|
invoiceOcr,
|
|
searchTerms,
|
|
})
|
|
return {
|
|
id: row.id,
|
|
date: row.date,
|
|
amount: row.amount,
|
|
description: row.description,
|
|
merchant_name: row.merchant_name,
|
|
reference: row.reference,
|
|
match_reason: reason,
|
|
match_confidence: MATCH_REASON_CONFIDENCE[reason],
|
|
}
|
|
})
|
|
|
|
candidates.sort((a, b) => MATCH_REASON_RANK[a.match_reason] - MATCH_REASON_RANK[b.match_reason])
|
|
return candidates
|
|
}
|
|
|
|
async function runAggregateSweep(
|
|
supabase: SupabaseClient,
|
|
params: {
|
|
companyId: string
|
|
invoice: Pick<CustomerInvoice, 'invoice_number'>
|
|
paymentAmount: number
|
|
paymentDate: string
|
|
},
|
|
): Promise<DuplicatePaymentCandidate[]> {
|
|
const { companyId, invoice, paymentAmount, paymentDate } = params
|
|
try {
|
|
return await findAggregateCandidates(supabase, {
|
|
companyId,
|
|
invoiceNumber: invoice.invoice_number,
|
|
paymentAmount,
|
|
paymentDate,
|
|
})
|
|
} catch (err) {
|
|
// Advisory guard: a failed sweep must never block "Markera som betald".
|
|
// Logged so the blind spot is visible rather than passing silently.
|
|
log.warn('duplicate-payment guard: aggregate sweep failed', {
|
|
companyId,
|
|
invoiceNumber: invoice.invoice_number,
|
|
error: err instanceof Error ? err.message : String(err),
|
|
})
|
|
return []
|
|
}
|
|
}
|
|
|
|
type OpenInvoiceRow = {
|
|
id: string
|
|
invoice_number: string | null
|
|
remaining_amount: number | string | null
|
|
total: number | string | null
|
|
due_date: string | null
|
|
}
|
|
|
|
type AggregateRow = Pick<Row, 'id' | 'date' | 'amount' | 'description' | 'merchant_name' | 'reference'>
|
|
|
|
/**
|
|
* Unlinked inbound kronor rows around the payment date that are LARGER than
|
|
* the payment, where the excess is exactly the remaining amount of one to
|
|
* three other open invoices. That is what a Bankgirot daily aggregate looks
|
|
* like from the invoice side: "BGGIRERING", no payer, one sum for two
|
|
* customers' invoices. Same exact-sum search the bank-side guard uses
|
|
* (lib/reconciliation/covering-set.ts), so the two doors agree on what
|
|
* "already paid" means. The remedy is the split under Transaktioner, which
|
|
* books ONE samlingsverifikation and links the row; marking the invoices
|
|
* paid one by one is what books the money twice.
|
|
*/
|
|
async function findAggregateCandidates(
|
|
supabase: SupabaseClient,
|
|
params: {
|
|
companyId: string
|
|
invoiceNumber: string | null
|
|
paymentAmount: number
|
|
paymentDate: string
|
|
},
|
|
): Promise<DuplicatePaymentCandidate[]> {
|
|
const { companyId, invoiceNumber, paymentAmount, paymentDate } = params
|
|
const payment = roundOre(paymentAmount)
|
|
if (!(payment > 0)) return []
|
|
const dateMs = new Date(paymentDate).getTime()
|
|
if (Number.isNaN(dateMs)) return []
|
|
const dayMs = 24 * 3600 * 1000
|
|
const dateLow = new Date(dateMs - AGGREGATE_DATE_WINDOW_DAYS * dayMs).toISOString().split('T')[0]
|
|
const dateHigh = new Date(dateMs + AGGREGATE_DATE_WINDOW_DAYS * dayMs).toISOString().split('T')[0]
|
|
|
|
const { data: rowsData } = await supabase
|
|
.from('transactions')
|
|
.select('id, date, amount, description, merchant_name, reference')
|
|
.eq('company_id', companyId)
|
|
.eq('is_business', true)
|
|
.is('invoice_id', null)
|
|
.is('supplier_invoice_id', null)
|
|
.is('journal_entry_id', null)
|
|
.or('currency.is.null,currency.eq.SEK')
|
|
.gt('amount', payment)
|
|
.gte('date', dateLow)
|
|
.lte('date', dateHigh)
|
|
.order('date', { ascending: false })
|
|
.limit(AGGREGATE_MAX_ROWS)
|
|
// Defensive shape check: a client that answers a list query with a single
|
|
// object (older test doubles do) must read as "no rows", not throw.
|
|
const rows = (Array.isArray(rowsData) ? rowsData : []) as AggregateRow[]
|
|
if (rows.length === 0) return []
|
|
|
|
let othersQuery = supabase
|
|
.from('invoices')
|
|
.select('id, invoice_number, remaining_amount, total, due_date')
|
|
.eq('company_id', companyId)
|
|
.eq('document_type', 'invoice')
|
|
.is('credited_invoice_id', null)
|
|
.in('status', ['sent', 'overdue', 'partially_paid'])
|
|
.gt('remaining_amount', 0)
|
|
.or('currency.is.null,currency.eq.SEK')
|
|
if (invoiceNumber) othersQuery = othersQuery.neq('invoice_number', invoiceNumber)
|
|
const { data: othersData } = await othersQuery
|
|
.order('due_date', { ascending: true })
|
|
.limit(AGGREGATE_MAX_OPEN_INVOICES)
|
|
const others = ((Array.isArray(othersData) ? othersData : []) as OpenInvoiceRow[]).filter(
|
|
(inv) => inv.invoice_number && Number(inv.remaining_amount ?? inv.total ?? 0) > 0,
|
|
)
|
|
if (others.length === 0) return []
|
|
|
|
const candidates: DuplicatePaymentCandidate[] = []
|
|
for (const row of rows) {
|
|
const residual = roundOre(Number(row.amount) - payment)
|
|
if (!(residual > 0)) continue
|
|
const rowMs = new Date(row.date).getTime()
|
|
const set = findExactCoveringSet(
|
|
residual,
|
|
others.map((inv) => ({
|
|
id: inv.id,
|
|
amount: Number(inv.remaining_amount ?? inv.total ?? 0),
|
|
dateDistanceDays:
|
|
inv.due_date && !Number.isNaN(rowMs)
|
|
? Math.round(Math.abs(new Date(inv.due_date).getTime() - rowMs) / dayMs)
|
|
: AGGREGATE_DATE_WINDOW_DAYS,
|
|
invoiceNumber: inv.invoice_number as string,
|
|
})),
|
|
{ maxSize: AGGREGATE_MAX_OTHER_INVOICES },
|
|
)
|
|
if (!set) continue
|
|
candidates.push({
|
|
id: row.id,
|
|
date: row.date,
|
|
amount: Number(row.amount),
|
|
description: row.description,
|
|
merchant_name: row.merchant_name,
|
|
reference: row.reference,
|
|
match_reason: 'aggregate_exact',
|
|
match_confidence: MATCH_REASON_CONFIDENCE.aggregate_exact,
|
|
aggregate_invoice_numbers: set.map((s) => s.invoiceNumber),
|
|
})
|
|
if (candidates.length >= 5) break
|
|
}
|
|
return candidates
|
|
}
|
|
|
|
/**
|
|
* A bank row as a comparable amount. `resolveTransactionAmountSek` is the one
|
|
* definition of "this bank line in kronor" (shared with the booking-time
|
|
* duplicate guard) and returns null rather than falling back to the raw foreign
|
|
* number.
|
|
*/
|
|
function rowAmount(row: Row): ComparableAmount {
|
|
return {
|
|
amount: Number(row.amount),
|
|
currency: normalizeCurrencyCode(row.currency),
|
|
sek: resolveTransactionAmountSek({
|
|
amount: row.amount,
|
|
currency: row.currency,
|
|
amount_sek: row.amount_sek,
|
|
exchange_rate: row.exchange_rate,
|
|
}),
|
|
}
|
|
}
|
|
|
|
function scoreCandidate(args: {
|
|
row: { reference: string | null; description: string | null; merchant_name: string | null }
|
|
invoiceOcr: string
|
|
searchTerms: string[]
|
|
}): DuplicatePaymentMatchReason {
|
|
const { row, invoiceOcr, searchTerms } = args
|
|
if (invoiceOcr && row.reference) {
|
|
if (normalizeOcrReference(row.reference) === invoiceOcr) {
|
|
return 'ocr_exact'
|
|
}
|
|
}
|
|
if (searchTerms.length > 0) {
|
|
const haystack = `${row.description ?? ''} ${row.merchant_name ?? ''}`.toLowerCase()
|
|
if (searchTerms.some((term) => haystack.includes(term))) {
|
|
return 'name_amount_fuzzy'
|
|
}
|
|
}
|
|
return 'amount_only'
|
|
}
|