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

129 lines
5.1 KiB
TypeScript

/**
* The exact query string the duplicate-payment sweep puts on the wire.
*
* The recording stubs elsewhere in this directory see which builder methods
* were called; they cannot see what postgrest-js turns those calls into. This
* file runs the REAL supabase-js / postgrest-js builder over a fake fetch and
* reads the URL back, so that a future edit which reintroduces a second
* `.or()` (and with it a second `or=` parameter whose handling by PostgREST
* this repo never proved) fails here rather than in production.
*/
import { describe, it, expect } from 'vitest'
import { createClient } from '@supabase/supabase-js'
import {
findDuplicatePaymentCandidatesForInvoice,
findDuplicatePaymentCandidatesForSupplierInvoice,
} from '@/lib/invoices/duplicate-payment-candidates'
/**
* createClient eagerly resolves a WebSocket implementation for realtime, which
* Node 20 (CI) does not ship. Nothing here subscribes, so an inert class is
* enough; same trick as tests/tool-pg/client.ts.
*/
class UnusedRealtimeTransport {
constructor() {
throw new Error('realtime is not used by this test')
}
}
function createCapturingClient() {
const urls: URL[] = []
const fakeFetch = async (input: RequestInfo | URL) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url
urls.push(new URL(url))
return new Response('[]', { status: 200, headers: { 'content-type': 'application/json' } })
}
const client = createClient('http://postgrest.invalid', 'test-anon-key', {
auth: { persistSession: false, autoRefreshToken: false },
realtime: { transport: UnusedRealtimeTransport as never },
global: { fetch: fakeFetch as typeof fetch },
})
return { client, urls }
}
const SEK_HI3G =
'(and(or(currency.is.null,currency.eq.SEK),or(merchant_name.ilike.*hi3g*,description.ilike.*hi3g*)))'
const EUR_HI3G =
'(and(or(currency.eq.EUR),or(merchant_name.ilike.*hi3g*,description.ilike.*hi3g*)))'
describe('duplicate-payment sweep: the query string on the wire', () => {
it('supplier side, SEK invoice: one request, exactly one or= parameter carrying currency AND name probe', async () => {
const { client, urls } = createCapturingClient()
await findDuplicatePaymentCandidatesForSupplierInvoice(client, {
companyId: '11111111-1111-4111-8111-111111111111',
invoice: {
supplier_invoice_number: '4471023987',
supplier_name: 'Hi3G Access AB',
currency: 'SEK',
total: 12500,
total_sek: 12500,
exchange_rate: null,
},
paymentAmount: 12500,
paymentDate: '2026-09-01',
})
expect(urls).toHaveLength(1)
const params = urls[0].searchParams
expect(urls[0].pathname).toMatch(/\/transactions$/)
// The whole point: ONE `or` key. A second one is the repeated-key shape.
expect(params.getAll('or')).toEqual([SEK_HI3G])
// The band and the direction are ordinary ANDed filters on the same request.
expect(params.getAll('amount')).toEqual(['lt.0', 'gte.-12750', 'lte.-12250'])
expect(params.get('is_business')).toBe('eq.true')
expect(params.get('supplier_invoice_id')).toBe('is.null')
expect(params.get('invoice_id')).toBe('is.null')
expect(params.get('company_id')).toBe('eq.11111111-1111-4111-8111-111111111111')
})
it('customer side, EUR invoice with a rate: two requests (EUR, SEK), each with exactly one or= parameter', async () => {
const { client, urls } = createCapturingClient()
await findDuplicatePaymentCandidatesForInvoice(client, {
companyId: '11111111-1111-4111-8111-111111111111',
invoice: {
invoice_number: '2026-0042',
customer_name: 'Hi3G Access AB',
currency: 'EUR',
total: 1000,
total_sek: 11500,
exchange_rate: 11.5,
},
paymentAmount: 1000,
paymentDate: '2026-09-01',
})
// No aggregate sweep for a foreign-currency invoice: the two currency
// sweeps are the whole conversation.
expect(urls).toHaveLength(2)
expect(urls[0].searchParams.getAll('or')).toEqual([EUR_HI3G])
expect(urls[0].searchParams.getAll('amount')).toEqual(['gt.0', 'gte.980', 'lte.1020'])
expect(urls[1].searchParams.getAll('or')).toEqual([SEK_HI3G])
expect(urls[1].searchParams.getAll('amount')).toEqual(['gt.0', 'gte.11270', 'lte.11730'])
})
it('never sends the needle with LIKE or DSL metacharacters, whatever the counterparty is called', async () => {
const { client, urls } = createCapturingClient()
await findDuplicatePaymentCandidatesForSupplierInvoice(client, {
companyId: '11111111-1111-4111-8111-111111111111',
invoice: {
supplier_invoice_number: null,
supplier_name: 'Acme,fake.eq.true 50%_Off (AB)',
currency: 'SEK',
total: 100,
total_sek: 100,
exchange_rate: null,
},
paymentAmount: 100,
paymentDate: '2026-09-01',
})
expect(urls).toHaveLength(1)
expect(urls[0].searchParams.getAll('or')).toEqual([
'(and(or(currency.is.null,currency.eq.SEK),or(merchant_name.ilike.*acmefakeeqtrue*,description.ilike.*acmefakeeqtrue*)))',
])
})
})