Files
accounted/lib/invoices/__tests__/duplicate-payment-guard.test.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

152 lines
6.6 KiB
TypeScript

import { describe, it, expect } from 'vitest'
import {
COUNTERPARTY_NEEDLE_SHAPE,
counterpartyNeedle,
counterpartySearchTerms,
counterpartySweepLogic,
escapeLikePattern,
normalizeOcrReference,
} from '../duplicate-payment-guard'
describe('escapeLikePattern', () => {
// These cases lock in that a user-supplied needle reaches an ILIKE pattern with
// its LIKE metacharacters neutralised: each of `%`, `_`, `\` must match only
// itself and never expand as a wildcard (compliance A.8.28 / ASVS V1.2.5).
it('escapes a literal percent so it matches only itself', () => {
expect(escapeLikePattern('50% rabatt')).toBe('50\\% rabatt')
})
it('escapes a literal underscore so it is not a single-char wildcard', () => {
expect(escapeLikePattern('konto_1930')).toBe('konto\\_1930')
})
it('escapes a literal backslash so it does not consume the next char', () => {
expect(escapeLikePattern('a\\b')).toBe('a\\\\b')
})
it('escapes backslash, percent and underscore together without double-escaping', () => {
// Backslash is escaped FIRST, so the escapes added for % and _ are not
// themselves re-escaped. Each special char maps to exactly "\\" + itself.
expect(escapeLikePattern('a\\b%c_d')).toBe('a\\\\b\\%c\\_d')
})
it('leaves ordinary text untouched', () => {
expect(escapeLikePattern('Faktura 2026-0042')).toBe('Faktura 2026-0042')
})
it('caps the needle at 200 characters to bound DB work on oversized input', () => {
const escaped = escapeLikePattern('a'.repeat(250))
expect(escaped).toBe('a'.repeat(200))
expect(escaped.length).toBe(200)
})
it('truncates BEFORE escaping, so the source length is the bound', () => {
// 250 percent signs → truncated to 200 source chars, each escaped to "\%".
expect(escapeLikePattern('%'.repeat(250))).toBe('\\%'.repeat(200))
})
})
describe('normalizeOcrReference', () => {
it('keeps only digits regardless of separators', () => {
expect(normalizeOcrReference('2026-0042')).toBe('20260042')
expect(normalizeOcrReference('2026 / 0042')).toBe('20260042')
})
it('returns an empty string for nullish or empty input', () => {
expect(normalizeOcrReference(null)).toBe('')
expect(normalizeOcrReference(undefined)).toBe('')
expect(normalizeOcrReference('')).toBe('')
})
})
describe('counterpartyNeedle', () => {
// Issue #2299: the bank feed wrote "HI3G" (merchant_name empty) for the row
// that paid Hi3G Access AB, and a full-name needle can never hit that. The
// needle is the first distinctive word, as a bank abbreviates.
it('takes the first distinctive token: Hi3G Access AB -> hi3g', () => {
expect(counterpartyNeedle('Hi3G Access AB')).toBe('hi3g')
})
it('skips a leading legal form: AB Volvo -> volvo, Aktiebolaget Elektro -> elektro', () => {
expect(counterpartyNeedle('AB Volvo')).toBe('volvo')
expect(counterpartyNeedle('Aktiebolaget Elektro')).toBe('elektro')
expect(counterpartyNeedle('Handelsbolaget Bröderna Ek')).toBe('bröderna')
})
it('keeps Swedish letters so "Leverantör AB" probes on leverantör, not leverantr', () => {
expect(counterpartyNeedle('Leverantör AB')).toBe('leverantör')
expect(counterpartyNeedle('Åkeriet i Örebro AB')).toBe('åkeriet')
})
it('keeps two-letter initialisms a bank writes verbatim: SJ AB -> sj, 3M Svenska AB -> 3m', () => {
expect(counterpartyNeedle('SJ AB')).toBe('sj')
expect(counterpartyNeedle('3M Svenska AB')).toBe('3m')
expect(counterpartyNeedle('DB Schenker')).toBe('db')
})
it('strips punctuation inside a token: "Acme, Inc." -> acme, "H&M Hennes & Mauritz" -> hm', () => {
expect(counterpartyNeedle('Acme, Inc.')).toBe('acme')
expect(counterpartyNeedle('H&M Hennes & Mauritz AB')).toBe('hm')
})
it('returns null when nothing usable remains: legal form only, one-char tokens, empty', () => {
expect(counterpartyNeedle('AB')).toBeNull()
expect(counterpartyNeedle('3 AB')).toBeNull()
expect(counterpartyNeedle('')).toBeNull()
expect(counterpartyNeedle(null)).toBeNull()
expect(counterpartyNeedle(undefined)).toBeNull()
expect(counterpartyNeedle(' ')).toBeNull()
})
it('never yields PostgREST filter-DSL or LIKE metacharacters, whatever the name contains', () => {
// The needle is interpolated into `.or('merchant_name.ilike.%x%,description.ilike.%x%')`,
// where `,` `.` `(` `)` would inject a clause and `%` `_` `\` would widen the match.
const hostile = ['Acme,fake.eq.true', '50% Off_AB', 'a\\b(c)', 'x.ilike.%', 'Kalle & Co']
for (const name of hostile) {
const needle = counterpartyNeedle(name)
expect(needle).not.toBeNull()
expect(needle).toMatch(COUNTERPARTY_NEEDLE_SHAPE)
expect(needle).not.toMatch(/[,.()%_\\]/)
}
expect(counterpartyNeedle('Acme,fake.eq.true')).toBe('acmefakeeqtrue')
})
it('caps the needle so an oversized token still yields a bounded prefix probe', () => {
expect(counterpartyNeedle('x'.repeat(300))).toBe('x'.repeat(40))
})
})
describe('counterpartySearchTerms', () => {
it('normalises every token of three or more characters and drops legal forms', () => {
expect(counterpartySearchTerms('Hi3G Access AB')).toEqual(['hi3g', 'access'])
expect(counterpartySearchTerms('Acme, Inc.')).toEqual(['acme'])
expect(counterpartySearchTerms('SJ AB')).toEqual([])
expect(counterpartySearchTerms(null)).toEqual([])
})
})
describe('counterpartySweepLogic', () => {
// The currency predicate and the name probe must travel in ONE logic
// expression: two `.or()` calls would send `or=` twice and lean on how
// PostgREST treats a repeated key. PostgREST nests logic operators, and an
// `or` with a single `and` child is valid grammar (proven against a real
// PostgREST in duplicate-payment-candidates.tool.test.ts).
it('nests the kronor clause and both name columns under one and()', () => {
expect(counterpartySweepLogic('currency.is.null,currency.eq.SEK', 'hi3g')).toBe(
'and(or(currency.is.null,currency.eq.SEK),or(merchant_name.ilike.*hi3g*,description.ilike.*hi3g*))',
)
})
it('wraps a single-currency clause in its own or() so the shape is the same for every currency', () => {
expect(counterpartySweepLogic('currency.eq.EUR', 'volvo')).toBe(
'and(or(currency.eq.EUR),or(merchant_name.ilike.*volvo*,description.ilike.*volvo*))',
)
})
it('refuses a needle that could carry DSL or LIKE metacharacters', () => {
expect(() => counterpartySweepLogic('currency.eq.SEK', 'a,b')).toThrow(/letters and digits/)
expect(() => counterpartySweepLogic('currency.eq.SEK', 'x.ilike.%')).toThrow(/letters and digits/)
expect(() => counterpartySweepLogic('currency.eq.SEK', '')).toThrow(/letters and digits/)
})
})