Files
accounted/lib/invoices/duplicate-payment-guard.ts
T
272d19b287 fix(supplier-invoices): duplicate-payment guard matches abbreviated bank text and shares one detector with the customer side (#2299) (#2345)
* fix(supplier-invoices): duplicate-payment guard matches abbreviated bank text and shares one detector with the customer side

The mark-paid guard probed merchant_name for the FULL supplier name, so the
row that paid Hi3G Access AB (bank text "HI3G", merchant_name empty) never
matched and the payment was booked twice (#2299).

- counterpartyNeedle(): first distinctive token of the name (alnum, legal
  forms dropped, >= 2 chars so initialisms like SJ and 3M survive), probed on
  merchant_name OR description in one .or() per currency sweep; the alnum
  shape is what makes the DSL interpolation safe.
- findDuplicatePaymentCandidatesForSupplierInvoice() beside the customer
  detector; both share the sweep and the scorer. The dashboard route's inline
  copy is deleted; the v1 supplier mark-paid door gets the guard it lacked.
- New match_reason already_booked (row already carries a verifikat, booked
  straight from the bank side): ranked first, carries journal_entry_id, and
  the dialogs, MCP path and pending-operation commit word the remedy as a
  rattelse rather than "link it".
- Customer side gets the same token prefilter and classification.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019SaJfqNi4VmsG8FMKq99G6

* test(invoices): align customer mark-paid queued mocks with the one-probe duplicate guard

The customer detector now issues one .or() counterparty probe per currency
sweep instead of two ILIKE queries, so every queued answer after the guard
was consumed one step early: the aggregate-sweep [] became company_settings,
the settings row hit the entry builder, and two tests saw 500 / the wrong
voucher id. Each guard block now enqueues one probe plus the aggregate sweep;
the 409 tests drop the second-probe entry that is no longer read.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019SaJfqNi4VmsG8FMKq99G6

* fix(invoices): one logic expression per duplicate-payment sweep, never two or= params

The sweep chain carried two .or() calls (currency clause, then name probe).
postgrest-js appends a query parameter per call, so the client sent or=
twice, and whether PostgREST ANDs a repeated key was never proven in this
repo; had it kept one, the currency predicate would be gone and foreign rows
banded against a kronor figure.

counterpartySweepLogic() now nests both groups under one and() inside a
single top-level or(): and(or(<currency>),or(merchant_name.ilike.*x*,
description.ilike.*x*)). The sweep issues exactly one .or() per currency.

Proof at three levels: unit tests pin the helper's string; a fake-fetch test
runs the real postgrest-js builder and asserts exactly one or= search param
per request; a tool-pg test seeds right-currency+hit, wrong-currency+hit
(with an amount_sek that would pass every JS check) and right-currency+miss
rows against a real PostgREST and asserts, for both detectors and both
sweeps, that only the first comes back, from PostgREST's own response.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019SaJfqNi4VmsG8FMKq99G6

* fix(invoices): name storno as the already_booked remedy, never "makulera"

A posted verifikat is never deleted; it is corrected by a storno entry
(BFL 5 kap 5 §). The already_booked remedy text in the error catalogue, the
MCP and pending-operation messages and both UI descriptions now say so:
"vänd en av verifikationerna med storno och koppla underlaget till den som
blir kvar" / "reverse one of the two vouchers with a storno entry and attach
the underlag to the remaining one".

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 18:53:34 +02:00

142 lines
6.3 KiB
TypeScript

/**
* Shared constants and helpers for the duplicate-payment / SI-match guards
* used by `/api/supplier-invoices/[id]/mark-paid` and
* `/api/transactions/[id]/categorize`. Both guards look for a likely-matching
* counterparty within a fuzzy amount + date window; keeping the thresholds in
* one place makes them tunable as we learn from real false-positive rates.
*/
/** Acceptable amount drift (±) when matching a bank tx to an invoice amount. */
export const DUPLICATE_AMOUNT_TOLERANCE_PCT = 0.02
/** Date window (±days) around the payment / invoice date. */
export const DUPLICATE_DATE_WINDOW_DAYS = 60
/** Cap on supplier / merchant names before they enter an ILIKE pattern, to
* bound query work and avoid pathological inputs degrading the index scan. */
const MAX_LIKE_NEEDLE_LENGTH = 200
/**
* Escape LIKE/ILIKE wildcards (`%`, `_`, `\`) and truncate to a safe length
* before embedding the value in an ILIKE pattern. SQL-injection is already
* handled by Supabase's parameterization; this purely prevents silent
* over-matching on names like "50% Off AB" and bounds DB work on long inputs.
*/
export function escapeLikePattern(value: string): string {
const truncated = value.length > MAX_LIKE_NEEDLE_LENGTH
? value.slice(0, MAX_LIKE_NEEDLE_LENGTH)
: value
return truncated.replace(/\\/g, '\\\\').replace(/%/g, '\\%').replace(/_/g, '\\_')
}
/**
* Normalize a Swedish payment reference (OCR / fakturanummer) for equality
* comparison. Banks emit references with varying separators ("2026-0042",
* "2026 0042", "2026/0042"); the OCR-spec equality is over the digits only.
* Returns "" for nullish/empty so callers can short-circuit without
* branching.
*/
export function normalizeOcrReference(value: string | null | undefined): string {
if (!value) return ''
return value.replace(/\D/g, '')
}
/**
* Legal-form words carry no identity: "AB" sits on half the supplier
* register, and a bank feed never abbreviates a counterparty to its legal
* form. Skipped when picking the needle so "AB Volvo" yields "volvo".
*/
const LEGAL_FORM_TOKENS = new Set([
'ab', 'aktiebolag', 'aktiebolaget', 'publ',
'hb', 'handelsbolag', 'handelsbolaget',
'kb', 'kommanditbolag', 'kommanditbolaget',
'ef', 'ek', 'ekonomisk', 'förening', 'föreningen',
'ltd', 'limited', 'llc', 'inc', 'corp', 'co', 'plc',
'gmbh', 'ag', 'ug', 'oy', 'oyj', 'as', 'asa', 'aps', 'bv', 'nv', 'sa', 'sarl', 'srl', 'spa',
'the',
])
/** Everything that is not a letter or digit, Latin letters incl. åäö and accents (no `u` flag: ES2017 target). */
const NON_NAME_CHARS = /[^a-z0-9\u00C0-\u024F]/g
/**
* The only shape a needle can have: letters and digits. That is what makes it
* safe to embed in a PostgREST filter-DSL string (`.or('col.ilike.%x%,...')`),
* where `,` `.` `(` `)` would otherwise inject a clause, and in an ILIKE
* pattern, where `%` `_` `\` would otherwise widen the match.
*/
export const COUNTERPARTY_NEEDLE_SHAPE = /^[a-z0-9\u00C0-\u024F]+$/
/** A prefix of a token is still a valid `%needle%` probe; bounds index work. */
const MAX_NEEDLE_LENGTH = 40
/**
* The search needle for a counterparty name as a bank feed writes it.
*
* WHY. Bank text abbreviates: the row that paid the Hi3G Access AB invoice
* reads "HI3G" with merchant_name empty, and a `%Hi3G Access AB%` needle can
* never hit it (issue #2299). What survives abbreviation is the FIRST
* distinctive word ("Hi3G", "Telia", "Volvo"), so that is the SQL prefilter;
* the full name is still scored in JS afterwards.
*
* RULE. Lower-case, split on whitespace, strip every non-letter/digit, drop
* legal forms, take the first token of at least two characters. Two rather
* than three because two-letter first tokens are initialisms a bank keeps
* verbatim ("SJ", "3M", "DB Schenker"); skipping past them lands on a generic
* second word ("Svenska"). Returns null when nothing usable remains ("AB",
* "3 AB"): the caller logs the skipped guard rather than probing on nothing.
*/
export function counterpartyNeedle(name: string | null | undefined): string | null {
if (!name) return null
const tokens = name
.toLowerCase()
.split(/\s+/)
.map((token) => token.replace(NON_NAME_CHARS, ''))
.filter((token) => token.length > 0 && !LEGAL_FORM_TOKENS.has(token))
const needle = tokens.find((token) => token.length >= 2)
if (!needle) return null
const capped = needle.slice(0, MAX_NEEDLE_LENGTH)
return COUNTERPARTY_NEEDLE_SHAPE.test(capped) ? capped : null
}
/**
* The tokens of a counterparty name used for the in-JS ranking of a candidate
* row (same normalisation as the needle, every token of three or more chars).
*/
export function counterpartySearchTerms(name: string | null | undefined): string[] {
if (!name) return []
return name
.toLowerCase()
.split(/\s+/)
.map((token) => token.replace(NON_NAME_CHARS, ''))
.filter((token) => token.length > 2 && !LEGAL_FORM_TOKENS.has(token))
}
/**
* ONE logic expression per currency sweep: the currency predicate AND the
* counterparty probe, nested so the whole thing rides a single `or=` query
* parameter.
*
* WHY ONE EXPRESSION. postgrest-js `.or()` appends a query parameter; calling
* it twice on one chain sends `or=` twice, and whether PostgREST ANDs a
* repeated key is a grammar this repo does not otherwise rely on. If it ever
* kept only one, the currency clause would be gone and a foreign row would be
* banded against a kronor figure. Nesting the two groups under one `and()`
* inside one top-level `or()` (PostgREST nests logic operators; `or` with a
* single child is valid) makes the guard independent of duplicate-key
* semantics. Proven against a real PostgREST in
* lib/invoices/__tests__/duplicate-payment-candidates.tool.test.ts.
*
* WHY IT IS SAFE TO INTERPOLATE. The needle is letters and digits only
* (`COUNTERPARTY_NEEDLE_SHAPE`, re-checked here), so it cannot carry the DSL
* characters `,` `.` `(` `)` or the LIKE wildcards. `currencyFilter` comes from
* `currencyRowFilter()` over an ISO 4217 code validated by `planAmountSweeps`.
* `*` is PostgREST's URL form of the LIKE `%` wildcard.
*/
export function counterpartySweepLogic(currencyFilter: string, needle: string): string {
if (!COUNTERPARTY_NEEDLE_SHAPE.test(needle)) {
throw new Error('counterpartySweepLogic: needle must be letters and digits only')
}
return `and(or(${currencyFilter}),or(merchant_name.ilike.*${needle}*,description.ilike.*${needle}*))`
}