diff --git a/app/(dashboard)/supplier-invoices/[id]/page.tsx b/app/(dashboard)/supplier-invoices/[id]/page.tsx index 37012955..a2e3e403 100644 --- a/app/(dashboard)/supplier-invoices/[id]/page.tsx +++ b/app/(dashboard)/supplier-invoices/[id]/page.tsx @@ -127,6 +127,9 @@ export default function SupplierInvoiceDetailPage() { amount: number description: string | null merchant_name: string | null + /** `already_booked`: the row is already a verifikat; the remedy is a rättelse, not a link. */ + match_reason?: string + journal_entry_id?: string | null }> | null >(null) const [markPaidPreview, setMarkPaidPreview] = useState(null) @@ -1251,31 +1254,50 @@ export default function SupplierInvoiceDetailPage() {

- {duplicateCandidates?.length === 1 - ? t('duplicate_payment_description_one') - : t('duplicate_payment_description_many')} + {duplicateCandidates?.every((c) => c.match_reason === 'already_booked') + ? t('duplicate_payment_already_booked_description') + : duplicateCandidates?.length === 1 + ? t('duplicate_payment_description_one') + : t('duplicate_payment_description_many')}

- {duplicateCandidates?.map((c) => ( -
-
-
{formatDate(c.date)}
-
- {c.merchant_name || c.description || t('bank_transaction_fallback')} + {duplicateCandidates?.map((c) => { + // A row that is already a verifikat: "link it" is the wrong + // remedy (the money would be booked twice), so point at the + // existing voucher instead of the transaction list. + const alreadyBooked = c.match_reason === 'already_booked' && !!c.journal_entry_id + return ( +
+
+
{formatDate(c.date)}
+
+ {c.merchant_name || c.description || t('bank_transaction_fallback')} +
+ {alreadyBooked && ( +
+ {t('duplicate_payment_already_booked_hint')} +
+ )}
+
+ {formatCurrency(Math.abs(c.amount), invoice.currency)} +
+
-
- {formatCurrency(Math.abs(c.amount), invoice.currency)} -
- -
- ))} + ) + })}
    {duplicateCandidates.map((c) => { - const reasonVariant: 'success' | 'secondary' | 'outline' = - c.match_reason === 'ocr_exact' || c.match_reason === 'aggregate_exact' - ? 'success' - : c.match_reason === 'name_amount_fuzzy' - ? 'secondary' - : 'outline' + const reasonVariant: 'success' | 'secondary' | 'outline' | 'warning' = + c.match_reason === 'already_booked' + ? 'warning' + : c.match_reason === 'ocr_exact' || c.match_reason === 'aggregate_exact' + ? 'success' + : c.match_reason === 'name_amount_fuzzy' + ? 'secondary' + : 'outline' const isAggregate = c.match_reason === 'aggregate_exact' && (c.aggregate_invoice_numbers?.length ?? 0) > 0 + // Already a verifikat: linking would book the money twice, so + // the action is to open that voucher and correct, not to link. + const isAlreadyBooked = c.match_reason === 'already_booked' && !!c.journal_entry_id return (
  • )} + {isAlreadyBooked && ( +

    {t('already_booked_hint')}

    + )}
) diff --git a/extensions/general/mcp-server/server.ts b/extensions/general/mcp-server/server.ts index 7ded7280..613bef0f 100644 --- a/extensions/general/mcp-server/server.ts +++ b/extensions/general/mcp-server/server.ts @@ -8191,10 +8191,18 @@ export const tools: McpTool[] = [ paymentDate, }) if (candidates.length > 0) { + // Reason-aware wording: a row that is already a verifikat must not + // be "matched" (that books the money twice); it must be corrected. + const alreadyBooked = candidates.some((c) => c.match_reason === 'already_booked') throw new Error( - `Möjlig dubbelbetalning: en obokförd banktransaktion ser ut att vara betalningen för faktura ` + - `${invoice.invoice_number}. Matcha banktransaktionen mot fakturan med gnubok_match_transaction_to_invoice ` + - `i stället. Anropa igen med allow_duplicate=true om det verkligen är en separat betalning.`, + alreadyBooked + ? `Möjlig dubbelbokning: banktransaktionen som ser ut att vara betalningen för faktura ` + + `${invoice.invoice_number} är redan bokförd som en egen verifikation. Bokför inte betalningen igen: ` + + `rätta dubbelbokföringen i stället (vänd en av verifikationerna med storno och koppla underlaget till den ` + + `som blir kvar). Anropa igen med allow_duplicate=true bara om det verkligen är en separat betalning.` + : `Möjlig dubbelbetalning: en obokförd banktransaktion ser ut att vara betalningen för faktura ` + + `${invoice.invoice_number}. Matcha banktransaktionen mot fakturan med gnubok_match_transaction_to_invoice ` + + `i stället. Anropa igen med allow_duplicate=true om det verkligen är en separat betalning.`, ) } } diff --git a/lib/errors/structured-errors.ts b/lib/errors/structured-errors.ts index 189c7daf..51214084 100644 --- a/lib/errors/structured-errors.ts +++ b/lib/errors/structured-errors.ts @@ -3032,12 +3032,12 @@ const SUPPLIER_INVOICE_WAVE4: Record = { SI_PAID_LIKELY_DUPLICATE: { httpStatus: 409, message_sv: - 'Det finns redan en obokförd banktransaktion som kan vara denna betalning. Länka den istället, eller markera som betald ändå om du är säker.', + 'Det finns redan en banktransaktion som kan vara denna betalning. Länka den istället, eller markera som betald ändå om du är säker.', message_en: - 'A likely-matching unlinked bank transaction was found for this supplier. Suggest linking it instead of creating a new payment entry.', + 'A likely-matching bank transaction was found for this supplier. Suggest linking it instead of creating a new payment entry.', remediation: { description: - 'Match the candidate transaction via POST /api/transactions/{id}/match-supplier-invoice, or resend mark-paid with force: true to create the payment entry anyway.', + 'Inspect details.candidates[].match_reason. For an unlinked row, match it via POST /api/transactions/{id}/match-supplier-invoice. For `already_booked`, the row is already a posted verifikat (booked straight from the bank side): do NOT pay the invoice, correct the double booking instead (reverse one of the two vouchers with a storno entry and attach the underlag to the remaining one). Resend mark-paid with force: true only when the payment really is separate; on the v1 endpoint that retry needs a fresh Idempotency-Key.', }, }, SI_CREDIT_ALREADY_CREDITED: { diff --git a/lib/invoices/__tests__/duplicate-payment-candidates.test.ts b/lib/invoices/__tests__/duplicate-payment-candidates.test.ts index 97c56499..d36fd37c 100644 --- a/lib/invoices/__tests__/duplicate-payment-candidates.test.ts +++ b/lib/invoices/__tests__/duplicate-payment-candidates.test.ts @@ -1,6 +1,9 @@ import { describe, it, expect, vi, afterEach } from 'vitest' import type { SupabaseClient } from '@supabase/supabase-js' -import { findDuplicatePaymentCandidatesForInvoice } from '@/lib/invoices/duplicate-payment-candidates' +import { + findDuplicatePaymentCandidatesForInvoice, + findDuplicatePaymentCandidatesForSupplierInvoice, +} from '@/lib/invoices/duplicate-payment-candidates' type QueryRecord = Record @@ -8,7 +11,8 @@ type QueryRecord = Record * Chainable Supabase stub that RECORDS the filter arguments of each query and * serves one queued page per `.from()` call, in call order. The shared * `createQueuedMockSupabase` helper drops filter arguments, and the whole point - * here is which band was applied to which currency. + * here is which band was applied to which currency, and which needle probed + * which columns. */ function createRecordingSupabase(pages: Array>>) { const queries: QueryRecord[] = [] @@ -40,6 +44,12 @@ function createRecordingSupabase(pages: Array>>) { return { supabase, queries } } +const SEK_ROWS = 'currency.is.null,currency.eq.SEK' +/** The ONE logic expression a currency sweep sends: currency AND name probe. */ +const sweepLogic = (currency: 'SEK' | 'EUR', needle: string) => + `and(or(${currency === 'SEK' ? SEK_ROWS : 'currency.eq.EUR'}),` + + `or(merchant_name.ilike.*${needle}*,description.ilike.*${needle}*))` + const sekInvoice = { invoice_number: '2026-0042', customer_name: 'Acme AB', @@ -72,6 +82,7 @@ function bankRow(over: Partial> = {}) { description: 'Inbetalning Acme AB', merchant_name: 'Acme AB', reference: null, + journal_entry_id: null, currency: 'SEK', amount_sek: null, exchange_rate: null, @@ -79,24 +90,24 @@ function bankRow(over: Partial> = {}) { } } +/** + * `lib/logger` deliberately suppresses non-error console output when + * NODE_ENV === 'test', so a warn is unobservable unless the level policy is + * lifted for the duration of the assertion. + */ +function captureWarnings() { + vi.stubEnv('NODE_ENV', 'development') + return vi.spyOn(console, 'warn').mockImplementation(() => {}) +} + describe('findDuplicatePaymentCandidatesForInvoice', () => { afterEach(() => { vi.restoreAllMocks() vi.unstubAllEnvs() }) - /** - * `lib/logger` deliberately suppresses non-error console output when - * NODE_ENV === 'test', so a warn is unobservable unless the level policy is - * lifted for the duration of the assertion. - */ - function captureWarnings() { - vi.stubEnv('NODE_ENV', 'development') - return vi.spyOn(console, 'warn').mockImplementation(() => {}) - } - - it('SEK invoice: one sweep per name pattern, band unchanged, kronor rows only', async () => { - const { supabase, queries } = createRecordingSupabase([[bankRow()], []]) + it('SEK invoice: ONE kronor-banded query probing merchant_name OR description on the first token', async () => { + const { supabase, queries } = createRecordingSupabase([[bankRow()]]) const candidates = await findDuplicatePaymentCandidatesForInvoice(supabase, { companyId: 'company-1', @@ -105,22 +116,67 @@ describe('findDuplicatePaymentCandidatesForInvoice', () => { paymentDate: '2026-05-10', }) - // merchant_name sweep + description sweep: the same two queries as before. - expect(queries).toHaveLength(2) + // The merchant_name and description probes used to be two queries with the + // full name as needle; now one query, one alnum needle, both columns. + expect(queries).toHaveLength(1) expect(queries[0].gte).toContainEqual(['amount', 12250]) expect(queries[0].lte).toContainEqual(['amount', 12750]) - // Band is kronor, so the rows it is applied to must be kronor. - expect(queries[0].or).toEqual([['currency.is.null,currency.eq.SEK']]) + expect(queries[0].gt).toContainEqual(['amount', 0]) + // Band is kronor, so the rows it is applied to must be kronor, and the + // currency clause rides the SAME .or() as the name probe: one expression, + // one query parameter, no dependence on repeated-key semantics. + expect(queries[0].or).toEqual([[sweepLogic('SEK', 'acme')]]) + expect(queries[0].ilike).toBeUndefined() expect(queries[0].select?.[0][0]).toContain('currency') expect(queries[0].select?.[0][0]).toContain('amount_sek') expect(queries[0].select?.[0][0]).toContain('exchange_rate') + expect(queries[0].select?.[0][0]).toContain('journal_entry_id') expect(candidates).toHaveLength(1) expect(candidates[0].id).toBe('tx-1') + expect(candidates[0].match_reason).toBe('name_amount_fuzzy') + expect(candidates[0].journal_entry_id).toBeNull() + }) + + it('abbreviated bank text: "HI3G" in the description, merchant_name empty, IS a candidate (issue #2299)', async () => { + const { supabase, queries } = createRecordingSupabase([ + [bankRow({ id: 'tx-hi3g', description: 'HI3G', merchant_name: null })], + ]) + + const candidates = await findDuplicatePaymentCandidatesForInvoice(supabase, { + companyId: 'company-1', + invoice: { ...sekInvoice, customer_name: 'Hi3G Access AB' }, + paymentAmount: 12500, + paymentDate: '2026-05-10', + }) + + expect(queries[0].or).toEqual([[sweepLogic('SEK', 'hi3g')]]) + expect(candidates.map((c) => [c.id, c.match_reason])).toEqual([['tx-hi3g', 'name_amount_fuzzy']]) + }) + + it('a row that is already a verifikat comes back as already_booked, ranked first, with its journal_entry_id', async () => { + const { supabase } = createRecordingSupabase([ + [ + bankRow({ id: 'tx-unlinked', date: '2026-05-11' }), + bankRow({ id: 'tx-booked', date: '2026-05-10', journal_entry_id: 'je-61' }), + ], + ]) + + const candidates = await findDuplicatePaymentCandidatesForInvoice(supabase, { + companyId: 'company-1', + invoice: sekInvoice, + paymentAmount: 12500, + paymentDate: '2026-05-10', + }) + + expect(candidates.map((c) => [c.id, c.match_reason, c.journal_entry_id])).toEqual([ + ['tx-booked', 'already_booked', 'je-61'], + ['tx-unlinked', 'name_amount_fuzzy', null], + ]) }) it('EUR invoice with a rate: bands EUR rows in EUR and kronor rows in kronor', async () => { - const { supabase, queries } = createRecordingSupabase([[], [], [], []]) + const { supabase, queries } = createRecordingSupabase([[], []]) await findDuplicatePaymentCandidatesForInvoice(supabase, { companyId: 'company-1', @@ -129,14 +185,14 @@ describe('findDuplicatePaymentCandidatesForInvoice', () => { paymentDate: '2026-05-10', }) - // Two sweeps (EUR, SEK) x two name patterns. - expect(queries).toHaveLength(4) - expect(queries[0].or).toEqual([['currency.eq.EUR']]) + // One query per currency sweep (EUR, SEK), one .or() each. + expect(queries).toHaveLength(2) + expect(queries[0].or).toEqual([[sweepLogic('EUR', 'acme')]]) expect(queries[0].gte).toContainEqual(['amount', 980]) expect(queries[0].lte).toContainEqual(['amount', 1020]) - expect(queries[2].or).toEqual([['currency.is.null,currency.eq.SEK']]) - expect(queries[2].gte).toContainEqual(['amount', 11270]) - expect(queries[2].lte).toContainEqual(['amount', 11730]) + expect(queries[1].or).toEqual([[sweepLogic('SEK', 'acme')]]) + expect(queries[1].gte).toContainEqual(['amount', 11270]) + expect(queries[1].lte).toContainEqual(['amount', 11730]) }) it('EUR invoice: a 1 000 SEK bank row is NOT offered as the payment for 1 000 EUR', async () => { @@ -145,8 +201,6 @@ describe('findDuplicatePaymentCandidatesForInvoice', () => { const { supabase } = createRecordingSupabase([ [bankRow({ id: 'tx-sek-1000', amount: 1000, currency: 'SEK' })], [], - [], - [], ]) const candidates = await findDuplicatePaymentCandidatesForInvoice(supabase, { @@ -161,10 +215,8 @@ describe('findDuplicatePaymentCandidatesForInvoice', () => { it('EUR invoice with a rate: the 11 500 SEK bank row that actually paid it IS offered', async () => { const { supabase } = createRecordingSupabase([ - [], [], [bankRow({ id: 'tx-sek-11500', amount: 11500, currency: 'SEK' })], - [], ]) const candidates = await findDuplicatePaymentCandidatesForInvoice(supabase, { @@ -181,8 +233,6 @@ describe('findDuplicatePaymentCandidatesForInvoice', () => { const { supabase } = createRecordingSupabase([ [bankRow({ id: 'tx-eur-1000', amount: 1000, currency: 'EUR', amount_sek: 11500 })], [], - [], - [], ]) const candidates = await findDuplicatePaymentCandidatesForInvoice(supabase, { @@ -198,11 +248,9 @@ describe('findDuplicatePaymentCandidatesForInvoice', () => { it('EUR invoice with no stored rate: kronor rows are excluded, not compared raw', async () => { const { supabase, queries } = createRecordingSupabase([ [bankRow({ id: 'tx-sek-1000', amount: 1000, currency: 'SEK' })], - [], ]) // An unevaluated candidate set is not a clean "no duplicate": the blind - // spot must be visible in behandlingshistorik (BFNAR 2013:2 p. 9.16), the - // same way the supplier-side twin logs it. + // spot must be visible in behandlingshistorik (BFNAR 2013:2 p. 9.16). const warn = captureWarnings() const candidates = await findDuplicatePaymentCandidatesForInvoice(supabase, { @@ -213,8 +261,8 @@ describe('findDuplicatePaymentCandidatesForInvoice', () => { }) // No SEK sweep can be planned without a rate: only the EUR sweep runs. - expect(queries).toHaveLength(2) - expect(queries[0].or).toEqual([['currency.eq.EUR']]) + expect(queries).toHaveLength(1) + expect(queries[0].or).toEqual([[sweepLogic('EUR', 'acme')]]) expect(candidates).toEqual([]) expect(warn).toHaveBeenCalled() expect(JSON.stringify(warn.mock.calls)).toContain('invoice_missing_sek_value') @@ -245,13 +293,13 @@ describe('findDuplicatePaymentCandidatesForInvoice', () => { paymentAmount: 62500, paymentDate: '2026-07-31', }) - // No name sweeps at all: straight to the two aggregate queries. + // No name sweep at all: straight to the two aggregate queries. expect(queries).toHaveLength(2) expect(queries[0].gt).toContainEqual(['amount', 62500]) expect(candidates.map((c) => c.match_reason)).toEqual(['aggregate_exact']) }) - it('skips the name sweeps when the invoice has no customer name; only the aggregate row sweep runs', async () => { + it('skips the name sweep when the invoice has no customer name; only the aggregate row sweep runs', async () => { const { supabase, queries } = createRecordingSupabase([[]]) const candidates = await findDuplicatePaymentCandidatesForInvoice(supabase, { companyId: 'company-1', @@ -260,9 +308,21 @@ describe('findDuplicatePaymentCandidatesForInvoice', () => { paymentDate: '2026-05-10', }) expect(candidates).toEqual([]) - // No ILIKE probe without a name; the aggregate row sweep found nothing and stopped. + // No name probe without a name; the aggregate row sweep found nothing and stopped. expect(queries).toHaveLength(1) - expect(queries[0].ilike).toBeUndefined() + expect(queries[0].or).not.toContainEqual([expect.stringContaining('ilike')]) + }) + + it('treats a name with no usable token ("AB") like no name: aggregate sweep only', async () => { + const { supabase, queries } = createRecordingSupabase([[]]) + await findDuplicatePaymentCandidatesForInvoice(supabase, { + companyId: 'company-1', + invoice: { ...sekInvoice, customer_name: 'AB' }, + paymentAmount: 12500, + paymentDate: '2026-05-10', + }) + expect(queries).toHaveLength(1) + expect(queries[0].or).not.toContainEqual([expect.stringContaining('ilike')]) }) }) @@ -282,10 +342,9 @@ describe('findDuplicatePaymentCandidatesForInvoice: Bankgirot aggregate rows', ( } it('offers the aggregate row whose excess is exactly another open invoice', async () => { - // Name sweeps find nothing ("BGGIRERING" carries no payer), then the + // The name sweep finds nothing ("BGGIRERING" carries no payer), then the // aggregate sweep: 88 250 - 62 500 = 25 750 = invoice 064's remaining. const { supabase, queries } = createRecordingSupabase([ - [], [], [aggregateRow()], [ @@ -302,16 +361,16 @@ describe('findDuplicatePaymentCandidatesForInvoice: Bankgirot aggregate rows', ( paymentDate: '2026-07-31', }) - expect(queries).toHaveLength(4) + expect(queries).toHaveLength(3) // Rows larger than the payment, unbooked, kronor, on the payment day ± 7. - expect(queries[2].gt).toContainEqual(['amount', 62500]) - expect(queries[2].is).toContainEqual(['journal_entry_id', null]) - expect(queries[2].or).toEqual([['currency.is.null,currency.eq.SEK']]) - expect(queries[2].gte).toContainEqual(['date', '2026-07-24']) - expect(queries[2].lte).toContainEqual(['date', '2026-08-07']) + expect(queries[1].gt).toContainEqual(['amount', 62500]) + expect(queries[1].is).toContainEqual(['journal_entry_id', null]) + expect(queries[1].or).toEqual([[SEK_ROWS]]) + expect(queries[1].gte).toContainEqual(['date', '2026-07-24']) + expect(queries[1].lte).toContainEqual(['date', '2026-08-07']) // Other open invoices only: this one is excluded by number. - expect(queries[3].neq).toContainEqual(['invoice_number', '063']) - expect(queries[3].in).toContainEqual(['status', ['sent', 'overdue', 'partially_paid']]) + expect(queries[2].neq).toContainEqual(['invoice_number', '063']) + expect(queries[2].in).toContainEqual(['status', ['sent', 'overdue', 'partially_paid']]) expect(candidates).toHaveLength(1) expect(candidates[0]).toMatchObject({ @@ -319,26 +378,26 @@ describe('findDuplicatePaymentCandidatesForInvoice: Bankgirot aggregate rows', ( amount: 88250, match_reason: 'aggregate_exact', match_confidence: 0.9, + journal_entry_id: null, }) // The invoice due on the row's date wins over the identical one due later. expect(candidates[0].aggregate_invoice_numbers).toEqual(['064']) }) it('does not run the aggregate sweep when a 1:1 candidate already exists', async () => { - const { supabase, queries } = createRecordingSupabase([[bankRow()], []]) + const { supabase, queries } = createRecordingSupabase([[bankRow()]]) const candidates = await findDuplicatePaymentCandidatesForInvoice(supabase, { companyId: 'company-1', invoice: sekInvoice, paymentAmount: 12500, paymentDate: '2026-05-10', }) - expect(queries).toHaveLength(2) + expect(queries).toHaveLength(1) expect(candidates[0].match_reason).not.toBe('aggregate_exact') }) it('stays silent when the excess is not an exact sum of other open invoices', async () => { const { supabase } = createRecordingSupabase([ - [], [], [aggregateRow()], [{ id: 'inv-x', invoice_number: '099', remaining_amount: 25000, total: 25000, due_date: '2026-07-31' }], @@ -353,27 +412,171 @@ describe('findDuplicatePaymentCandidatesForInvoice: Bankgirot aggregate rows', ( }) it('stops after the row sweep when no larger unbooked row exists', async () => { - const { supabase, queries } = createRecordingSupabase([[], [], []]) + const { supabase, queries } = createRecordingSupabase([[], []]) const candidates = await findDuplicatePaymentCandidatesForInvoice(supabase, { companyId: 'company-1', invoice: invoice063, paymentAmount: 62500, paymentDate: '2026-07-31', }) - expect(queries).toHaveLength(3) + expect(queries).toHaveLength(2) expect(candidates).toEqual([]) }) it('never runs for a foreign-currency invoice', async () => { - const { supabase, queries } = createRecordingSupabase([[], [], [], []]) + const { supabase, queries } = createRecordingSupabase([[], []]) const candidates = await findDuplicatePaymentCandidatesForInvoice(supabase, { companyId: 'company-1', invoice: eurInvoiceWithRate, paymentAmount: 1000, paymentDate: '2026-05-10', }) - // The four name sweeps (two currencies x two patterns) and nothing more. - expect(queries).toHaveLength(4) + // The two name sweeps (one per currency) and nothing more. + expect(queries).toHaveLength(2) expect(candidates).toEqual([]) }) }) + +describe('findDuplicatePaymentCandidatesForSupplierInvoice', () => { + afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllEnvs() + }) + + const hi3gInvoice = { + supplier_invoice_number: '4471023987', + payment_reference: null as string | null, + supplier_name: 'Hi3G Access AB' as string | null | undefined, + currency: 'SEK' as string | null, + total: 12500 as number | null, + total_sek: 12500 as number | null, + exchange_rate: null as number | null, + } + + function outboundRow(over: Partial> = {}) { + return bankRow({ id: 'tx-out', amount: -12500, description: 'HI3G', merchant_name: null, ...over }) + } + + const run = (supabase: SupabaseClient, invoice = hi3gInvoice) => + findDuplicatePaymentCandidatesForSupplierInvoice(supabase, { + companyId: 'company-1', + invoice, + paymentAmount: 12500, + paymentDate: '2026-09-01', + }) + + it('the 2026-09-04 case: "HI3G" in the description, merchant_name empty, is flagged', async () => { + const { supabase, queries } = createRecordingSupabase([[outboundRow()]]) + + const candidates = await run(supabase) + + // Outbound, unlinked business rows, kronor-banded around -12 500 ± 2 %, + // probed on the first distinctive token of the supplier name. + expect(queries).toHaveLength(1) + const q = queries[0] + expect(q.lt).toContainEqual(['amount', 0]) + expect(q.gte).toContainEqual(['amount', -12750]) + expect(q.lte).toContainEqual(['amount', -12250]) + expect(q.gte).toContainEqual(['date', '2026-07-03']) + expect(q.lte).toContainEqual(['date', '2026-10-31']) + expect(q.is).toContainEqual(['supplier_invoice_id', null]) + expect(q.is).toContainEqual(['invoice_id', null]) + expect(q.eq).toContainEqual(['is_business', true]) + expect(q.or).toEqual([[sweepLogic('SEK', 'hi3g')]]) + expect(q.ilike).toBeUndefined() + + expect(candidates).toHaveLength(1) + expect(candidates[0]).toMatchObject({ + id: 'tx-out', + amount: -12500, + match_reason: 'name_amount_fuzzy', + journal_entry_id: null, + }) + }) + + it('a merchant_name hit scores the same as a description hit', async () => { + const { supabase } = createRecordingSupabase([ + [outboundRow({ description: 'Kortköp', merchant_name: 'HI3G ACCESS' })], + ]) + const candidates = await run(supabase) + expect(candidates.map((c) => c.match_reason)).toEqual(['name_amount_fuzzy']) + }) + + it('no hit: an empty sweep yields no candidates and no second query (no aggregate sweep on the supplier side)', async () => { + const { supabase, queries } = createRecordingSupabase([[]]) + expect(await run(supabase)).toEqual([]) + expect(queries).toHaveLength(1) + }) + + it('amount mismatch: a row the stub returns outside the band is dropped by the per-row re-check', async () => { + const { supabase } = createRecordingSupabase([[outboundRow({ amount: -9000 })]]) + expect(await run(supabase)).toEqual([]) + }) + + it('a row booked straight as an expense from the bank side is already_booked, with its verifikat, ranked first', async () => { + // A61 in the case: the July bill booked from the bank row (Dr 6212 / Cr + // 1930), then the invoice registered AND marked paid on top of it. + const { supabase } = createRecordingSupabase([ + [ + outboundRow({ id: 'tx-unlinked', date: '2026-09-02' }), + outboundRow({ id: 'tx-a61', date: '2026-08-28', journal_entry_id: 'je-a61' }), + ], + ]) + const candidates = await run(supabase) + expect(candidates.map((c) => [c.id, c.match_reason, c.journal_entry_id])).toEqual([ + ['tx-a61', 'already_booked', 'je-a61'], + ['tx-unlinked', 'name_amount_fuzzy', null], + ]) + expect(candidates[0].match_confidence).toBe(0.85) + }) + + it('the payment reference typed into the bank transfer is an exact OCR match', async () => { + const { supabase } = createRecordingSupabase([ + [outboundRow({ description: 'Betalning', reference: '1234 5678 90' })], + ]) + const candidates = await run(supabase, { ...hi3gInvoice, payment_reference: '1234567890' }) + expect(candidates.map((c) => c.match_reason)).toEqual(['ocr_exact']) + }) + + it('a short supplier invoice number is not an OCR: "7" does not turn every reference with a 7 into an exact match', async () => { + const { supabase } = createRecordingSupabase([ + [outboundRow({ description: 'HI3G', reference: '7' })], + ]) + const candidates = await run(supabase, { ...hi3gInvoice, supplier_invoice_number: '7' }) + expect(candidates.map((c) => c.match_reason)).toEqual(['name_amount_fuzzy']) + }) + + it('missing supplier name: no query, empty result, and the skipped guard is logged', async () => { + const { supabase, queries } = createRecordingSupabase([]) + const warn = captureWarnings() + expect(await run(supabase, { ...hi3gInvoice, supplier_name: null })).toEqual([]) + expect(queries).toHaveLength(0) + expect(JSON.stringify(warn.mock.calls)).toContain('missing_supplier_name') + }) + + it('a name with no usable token ("AB") skips the sweep the same way', async () => { + const { supabase, queries } = createRecordingSupabase([]) + const warn = captureWarnings() + expect(await run(supabase, { ...hi3gInvoice, supplier_name: 'AB' })).toEqual([]) + expect(queries).toHaveLength(0) + expect(JSON.stringify(warn.mock.calls)).toContain('unusable_supplier_name') + }) + + it('EUR invoice with a rate: EUR rows banded in EUR, kronor rows in kronor, both outbound', async () => { + const { supabase, queries } = createRecordingSupabase([[], [outboundRow({ amount: -11500 })]]) + const candidates = await findDuplicatePaymentCandidatesForSupplierInvoice(supabase, { + companyId: 'company-1', + invoice: { ...hi3gInvoice, currency: 'EUR', total: 1000, total_sek: 11500, exchange_rate: 11.5 }, + paymentAmount: 1000, + paymentDate: '2026-09-01', + }) + expect(queries).toHaveLength(2) + expect(queries[0].or).toEqual([[sweepLogic('EUR', 'hi3g')]]) + expect(queries[0].gte).toContainEqual(['amount', -1020]) + expect(queries[0].lte).toContainEqual(['amount', -980]) + expect(queries[1].or).toEqual([[sweepLogic('SEK', 'hi3g')]]) + expect(queries[1].gte).toContainEqual(['amount', -11730]) + expect(queries[1].lte).toContainEqual(['amount', -11270]) + expect(candidates.map((c) => c.id)).toEqual(['tx-out']) + }) +}) diff --git a/lib/invoices/__tests__/duplicate-payment-candidates.tool.test.ts b/lib/invoices/__tests__/duplicate-payment-candidates.tool.test.ts new file mode 100644 index 00000000..108061d3 --- /dev/null +++ b/lib/invoices/__tests__/duplicate-payment-candidates.tool.test.ts @@ -0,0 +1,267 @@ +/** + * The duplicate-payment sweep against a REAL PostgREST. + * + * What only this file can prove: that the ONE logic expression a currency + * sweep sends, `or=(and(or(),or(merchant_name.ilike.*x*, + * description.ilike.*x*)))`, is parsed by PostgREST as "currency AND name", + * so a row of the wrong currency is excluded by the SQL, not merely by the + * per-row re-check in JS. A recording stub answers whatever it is queued + * with; the grammar and the semantics live in PostgREST. + * + * Seeds, for one company, three outbound rows and three inbound rows of the + * same shape: + * right currency + name hit -> the only row a sweep may return + * wrong currency + name hit -> must be excluded by the currency clause. + * Its amount_sek equals the payment, so if + * the SQL leaked it every JS check would pass + * and the detector would offer it. + * right currency + no name hit -> must be excluded by the name clause + * + * Both detectors run twice: a SEK invoice (one kronor sweep) and a EUR invoice + * with a stored rate (a EUR sweep and a kronor sweep), and the ids PostgREST + * actually returned are read at the transport as well. + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest' +import { randomUUID } from 'node:crypto' +import { seedCompany } from '@/tests/pg/fixtures' +import { getPool } from '@/tests/pg/setup' +import { createToolPgClient, TOOL_PG_REST_URL } from '@/tests/tool-pg/client' +import { + findDuplicatePaymentCandidatesForInvoice, + findDuplicatePaymentCandidatesForSupplierInvoice, +} from '@/lib/invoices/duplicate-payment-candidates' + +const REST_HOST = TOOL_PG_REST_URL.replace(/^https?:\/\//, '') + +interface CapturedSweep { + url: URL + ids: string[] +} + +const captured: CapturedSweep[] = [] +const originalFetch = globalThis.fetch + +beforeAll(() => { + globalThis.fetch = (async (...args: Parameters) => { + const response = await originalFetch(...args) + const url = String(args[0]) + if (url.includes(REST_HOST) && url.includes('/transactions?')) { + let ids: string[] = [] + try { + const body = (await response.clone().json()) as Array<{ id?: string }> + ids = Array.isArray(body) ? body.map((r) => String(r.id)) : [] + } catch { + ids = [] + } + captured.push({ url: new URL(url), ids }) + } + return response + }) as typeof fetch +}) + +afterAll(() => { + globalThis.fetch = originalFetch +}) + +let companyId: string +let userId: string +let client: ReturnType + +/** Ids of the seeded rows, keyed by what they are meant to prove. */ +const rows: Record = {} + +async function insertRow(params: { + amount: number + currency: 'SEK' | 'EUR' + amountSek: number | null + description: string + merchantName: string | null +}): Promise { + const id = randomUUID() + await getPool().query( + `INSERT INTO public.transactions + (id, company_id, user_id, currency, amount, amount_sek, date, description, + merchant_name, is_business, category) + VALUES ($1, $2, $3, $4, $5, $6, '2026-09-01', $7, $8, true, 'uncategorized')`, + [ + id, + companyId, + userId, + params.currency, + params.amount, + params.amountSek, + params.description, + params.merchantName, + ], + ) + return id +} + +beforeAll(async () => { + client = createToolPgClient() + const seeded = await seedCompany() + companyId = seeded.companyId + userId = seeded.userId + + // Outbound (supplier side). The SEK invoice pays 12 500 kr; the EUR invoice + // pays 1 000 EUR at 11,50 (11 500 kr). + rows.outSekHit = await insertRow({ amount: -12500, currency: 'SEK', amountSek: null, description: 'HI3G', merchantName: null }) + rows.outEurHitWrongCurrency = await insertRow({ amount: -12500, currency: 'EUR', amountSek: -12500, description: 'HI3G', merchantName: null }) + rows.outSekMiss = await insertRow({ amount: -12500, currency: 'SEK', amountSek: null, description: 'Telia', merchantName: 'TELIA' }) + rows.outEurHit = await insertRow({ amount: -1000, currency: 'EUR', amountSek: -11500, description: 'HI3G', merchantName: null }) + rows.outSekHitWrongCurrencyForEur = await insertRow({ amount: -1000, currency: 'SEK', amountSek: null, description: 'HI3G', merchantName: null }) + rows.outEurMiss = await insertRow({ amount: -1000, currency: 'EUR', amountSek: -11500, description: 'Telia', merchantName: null }) + + // Inbound (customer side), same shapes with the sign flipped. + rows.inSekHit = await insertRow({ amount: 12500, currency: 'SEK', amountSek: null, description: 'HI3G', merchantName: null }) + rows.inEurHitWrongCurrency = await insertRow({ amount: 12500, currency: 'EUR', amountSek: 12500, description: 'HI3G', merchantName: null }) + rows.inSekMiss = await insertRow({ amount: 12500, currency: 'SEK', amountSek: null, description: 'Telia', merchantName: 'TELIA' }) + rows.inEurHit = await insertRow({ amount: 1000, currency: 'EUR', amountSek: 11500, description: 'HI3G', merchantName: null }) + rows.inSekHitWrongCurrencyForEur = await insertRow({ amount: 1000, currency: 'SEK', amountSek: null, description: 'HI3G', merchantName: null }) + rows.inEurMiss = await insertRow({ amount: 1000, currency: 'EUR', amountSek: 11500, description: 'Telia', merchantName: null }) +}, 30_000) + +afterAll(async () => { + // Best-effort cleanup: the harness database is shared across files. + try { + await getPool().query('DELETE FROM public.transactions WHERE company_id = $1', [companyId]) + await getPool().query('DELETE FROM public.company_members WHERE company_id = $1', [companyId]) + await getPool().query('DELETE FROM public.fiscal_periods WHERE company_id = $1', [companyId]) + await getPool().query('DELETE FROM public.companies WHERE id = $1', [companyId]) + } catch { + // Leftover seed rows are harmless: every query here filters by company_id. + } +}) + +/** The sweeps PostgREST answered since `from`, with the ids it returned. */ +function sweepsSince(from: number): CapturedSweep[] { + return captured.slice(from) +} + +describe('duplicate-payment sweep against real PostgREST', () => { + it('parses the nested single-or expression at all (self-test: a malformed one is a 400)', async () => { + const good = await client + .from('transactions') + .select('id') + .eq('company_id', companyId) + .or('and(or(currency.is.null,currency.eq.SEK),or(merchant_name.ilike.*hi3g*,description.ilike.*hi3g*))') + expect(good.error).toBeNull() + + const bad = await client + .from('transactions') + .select('id') + .eq('company_id', companyId) + .or('and(or(currency.is.null,currency.eq.SEK),or(merchant_name.ilike.*hi3g*,description.ilike.*hi3g*)') + expect(bad.error).not.toBeNull() + expect(bad.error?.code).toBe('PGRST100') + }) + + it('supplier side, SEK invoice: only the kronor row with the name hit', async () => { + const from = captured.length + const candidates = await findDuplicatePaymentCandidatesForSupplierInvoice(client, { + companyId, + 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(candidates.map((c) => c.id)).toEqual([rows.outSekHit]) + expect(candidates[0].match_reason).toBe('name_amount_fuzzy') + + // What PostgREST itself returned for the one kronor sweep: the wrong-currency + // row is absent HERE, so the currency clause did its work in SQL. + const sweeps = sweepsSince(from) + expect(sweeps).toHaveLength(1) + expect(sweeps[0].url.searchParams.getAll('or')).toHaveLength(1) + expect(sweeps[0].ids).toEqual([rows.outSekHit]) + }) + + it('supplier side, EUR invoice with a rate: the EUR sweep returns only the EUR name hit', async () => { + const from = captured.length + const candidates = await findDuplicatePaymentCandidatesForSupplierInvoice(client, { + companyId, + invoice: { + supplier_invoice_number: '4471023987', + supplier_name: 'Hi3G Access AB', + currency: 'EUR', + total: 1000, + total_sek: 11500, + exchange_rate: 11.5, + }, + paymentAmount: 1000, + paymentDate: '2026-09-01', + }) + + expect(candidates.map((c) => c.id)).toEqual([rows.outEurHit]) + + const sweeps = sweepsSince(from) + expect(sweeps).toHaveLength(2) + const eurSweep = sweeps.find((s) => s.url.searchParams.getAll('or')[0]?.includes('currency.eq.EUR')) + const sekSweep = sweeps.find((s) => s.url.searchParams.getAll('or')[0]?.includes('currency.eq.SEK')) + expect(eurSweep).toBeDefined() + expect(sekSweep).toBeDefined() + // The kronor row of the same raw magnitude with the name hit sits inside the + // EUR band; only the currency clause keeps it out of the EUR sweep. + expect(eurSweep!.ids).toEqual([rows.outEurHit]) + // Nothing in kronor is within 2 % of 11 500 kr. + expect(sekSweep!.ids).toEqual([]) + }) + + it('customer side, SEK invoice: only the kronor row with the name hit', async () => { + const from = captured.length + const candidates = await findDuplicatePaymentCandidatesForInvoice(client, { + companyId, + invoice: { + invoice_number: '2026-0042', + customer_name: 'Hi3G Access AB', + currency: 'SEK', + total: 12500, + total_sek: 12500, + exchange_rate: null, + }, + paymentAmount: 12500, + paymentDate: '2026-09-01', + }) + + expect(candidates.map((c) => c.id)).toEqual([rows.inSekHit]) + + const sweeps = sweepsSince(from) + // One name sweep; a hit means the aggregate sweep never runs. + expect(sweeps).toHaveLength(1) + expect(sweeps[0].url.searchParams.getAll('or')).toHaveLength(1) + expect(sweeps[0].ids).toEqual([rows.inSekHit]) + }) + + it('customer side, EUR invoice with a rate: the EUR sweep returns only the EUR name hit', async () => { + const from = captured.length + const candidates = await findDuplicatePaymentCandidatesForInvoice(client, { + companyId, + invoice: { + invoice_number: '2026-0043', + customer_name: 'Hi3G Access AB', + currency: 'EUR', + total: 1000, + total_sek: 11500, + exchange_rate: 11.5, + }, + paymentAmount: 1000, + paymentDate: '2026-09-01', + }) + + expect(candidates.map((c) => c.id)).toEqual([rows.inEurHit]) + + const sweeps = sweepsSince(from) + expect(sweeps).toHaveLength(2) + const eurSweep = sweeps.find((s) => s.url.searchParams.getAll('or')[0]?.includes('currency.eq.EUR')) + const sekSweep = sweeps.find((s) => s.url.searchParams.getAll('or')[0]?.includes('currency.eq.SEK')) + expect(eurSweep!.ids).toEqual([rows.inEurHit]) + expect(sekSweep!.ids).toEqual([]) + }) +}) diff --git a/lib/invoices/__tests__/duplicate-payment-guard.test.ts b/lib/invoices/__tests__/duplicate-payment-guard.test.ts index ece8ff50..d8844fd7 100644 --- a/lib/invoices/__tests__/duplicate-payment-guard.test.ts +++ b/lib/invoices/__tests__/duplicate-payment-guard.test.ts @@ -1,5 +1,12 @@ import { describe, it, expect } from 'vitest' -import { escapeLikePattern, normalizeOcrReference } from '../duplicate-payment-guard' +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 @@ -51,3 +58,94 @@ describe('normalizeOcrReference', () => { 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/) + }) +}) diff --git a/lib/invoices/__tests__/duplicate-payment-sweep-params.test.ts b/lib/invoices/__tests__/duplicate-payment-sweep-params.test.ts new file mode 100644 index 00000000..7655e9a1 --- /dev/null +++ b/lib/invoices/__tests__/duplicate-payment-sweep-params.test.ts @@ -0,0 +1,128 @@ +/** + * 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*)))', + ]) + }) +}) diff --git a/lib/invoices/duplicate-payment-candidates.ts b/lib/invoices/duplicate-payment-candidates.ts index ae875bb3..4296bf06 100644 --- a/lib/invoices/duplicate-payment-candidates.ts +++ b/lib/invoices/duplicate-payment-candidates.ts @@ -1,8 +1,11 @@ import type { SupabaseClient } from '@supabase/supabase-js' import { + COUNTERPARTY_NEEDLE_SHAPE, DUPLICATE_AMOUNT_TOLERANCE_PCT, DUPLICATE_DATE_WINDOW_DAYS, - escapeLikePattern, + counterpartyNeedle, + counterpartySearchTerms, + counterpartySweepLogic, normalizeOcrReference, } from './duplicate-payment-guard' import { @@ -30,6 +33,15 @@ export type DuplicatePaymentMatchReason = * them. No counterparty text is consulted; such rows carry none. */ | 'aggregate_exact' + /** + * The bank row already carries a posted verifikat (`journal_entry_id` set) + * but was never linked to this invoice: the money was booked straight from + * the bank side, as an expense or an income. Marking the invoice paid now + * books the same movement a second time (the 2026-09-04 case doubled both + * 6212 and 1930). The remedy is a rättelse, not a link: reverse one of the + * two vouchers with a storno entry and attach the underlag to the remaining one. + */ + | 'already_booked' export interface DuplicatePaymentCandidate { id: string @@ -38,6 +50,8 @@ export interface DuplicatePaymentCandidate { description: string | null merchant_name: string | null reference: string | null + /** The verifikat the row is already booked on; set iff `match_reason` is `already_booked`. */ + journal_entry_id: string | null match_reason: DuplicatePaymentMatchReason match_confidence: number /** For aggregate_exact: the other open invoices the row also covers. */ @@ -45,19 +59,28 @@ export interface DuplicatePaymentCandidate { } const MATCH_REASON_RANK: Record = { - ocr_exact: 0, - aggregate_exact: 1, - name_amount_fuzzy: 2, - amount_only: 3, + already_booked: 0, + ocr_exact: 1, + aggregate_exact: 2, + name_amount_fuzzy: 3, + amount_only: 4, } const MATCH_REASON_CONFIDENCE: Record = { + already_booked: 0.85, ocr_exact: 0.99, aggregate_exact: 0.9, name_amount_fuzzy: 0.7, amount_only: 0.5, } +/** + * Fewer digits than this is not an OCR / invoice number, it is a coincidence: + * a supplier invoice numbered "7" must not read every bank reference with a 7 + * in it as an exact match. + */ +const MIN_OCR_DIGITS = 4 + /** ± 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. */ @@ -85,6 +108,23 @@ interface CustomerInvoice { exchange_rate: number | null } +/** + * The supplier-side twin of `CustomerInvoice`. Same currency contract; the + * name is the supplier's, and the OCR signal is the invoice's payment + * reference (or its number) rather than our own invoice number. + */ +interface SupplierInvoiceForGuard { + supplier_invoice_number: string | null + /** The OCR / payment reference printed on the supplier's invoice, if captured. */ + payment_reference?: string | null + supplier_name: string | null | undefined + /** `supplier_invoices.currency` (NOT NULL DEFAULT 'SEK'; null tolerated). */ + currency: string | null + total: number | null + total_sek: number | null + exchange_rate: number | null +} + type Row = { id: string date: string @@ -92,39 +132,31 @@ type Row = { description: string | null merchant_name: string | null reference: string | null + journal_entry_id: string | null currency: string | null amount_sek: number | null exchange_rate: number | null } +const ROW_COLUMNS = + 'id, date, amount, description, merchant_name, reference, journal_entry_id, currency, amount_sek, exchange_rate' + /** * 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: + * Customer-side adaptations vs the supplier twin below: * - 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 + * - OCR signal is OUR invoice number (the payer quotes it as reference) + * - falls through to the Bankgirot aggregate sweep when nothing 1:1 turns up * - * 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. + * Both sides share `sweepByCounterparty` and `scoreCandidate`, so the + * prefilter (first distinctive token of the name against merchant_name OR + * description), the currency banding and the ranking cannot drift apart + * again: the supplier side used to carry its own copy that probed + * merchant_name only, with the full legal name as needle, and missed the + * abbreviated bank text the feed actually writes (issue #2299). */ export async function findDuplicatePaymentCandidatesForInvoice( supabase: SupabaseClient, @@ -137,25 +169,148 @@ export async function findDuplicatePaymentCandidatesForInvoice( }, ): Promise { const { companyId, invoice, paymentAmount, paymentDate } = params - const customerName = invoice.customer_name const paymentCurrency = normalizeCurrencyCode(invoice.currency) + const aggregate = () => + paymentCurrency === 'SEK' + ? runAggregateSweep(supabase, { companyId, invoice, paymentAmount, paymentDate }) + : Promise.resolve([] as DuplicatePaymentCandidate[]) - // The name sweeps need a payer to look for; the aggregate sweep does not + // The name sweep needs 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({ + const needle = counterpartyNeedle(invoice.customer_name) + if (!needle) return aggregate() + + const rows = await sweepByCounterparty(supabase, { + companyId, + direction: 'inbound', + needle, + reference: { amount: paymentAmount, currency: paymentCurrency, - total: invoice.total, - totalSek: invoice.total_sek, - exchangeRate: invoice.exchange_rate, - }), + sek: invoiceAmountSek({ + amount: paymentAmount, + currency: paymentCurrency, + total: invoice.total, + totalSek: invoice.total_sek, + exchangeRate: invoice.exchange_rate, + }), + }, + paymentDate, + logContext: { companyId, invoiceNumber: invoice.invoice_number }, + }) + + // 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 sweep came back empty. Kronor only: the sum is taken over + // remaining amounts stored in invoice currency. + if (rows.length === 0) return aggregate() + + return rankCandidates(rows, { + invoiceOcrs: ocrKeys([invoice.invoice_number]), + searchTerms: counterpartySearchTerms(invoice.customer_name), + }) +} + +/** + * Supplier-side twin: unlinked NEGATIVE (outbound) business bank rows that + * could be the payment of this supplier invoice. Same sweep, same scorer, + * same reasons as the customer side; the OCR signal is the supplier's payment + * reference (or the invoice number) as the payer typed it into the bank + * transfer. No aggregate sweep: a Bankgirot daily aggregate is an inbound + * shape, and our own outbound batches (betalfil) link every row explicitly. + * + * A candidate with `match_reason: 'already_booked'` is the case the issue + * names third: the bank row was booked straight as an expense, and the + * invoice then registered on top of it. Paying it would double 6212 and 1930. + */ +export async function findDuplicatePaymentCandidatesForSupplierInvoice( + supabase: SupabaseClient, + params: { + companyId: string + invoice: SupplierInvoiceForGuard + /** The payment being booked, in `invoice.currency`. */ + paymentAmount: number + paymentDate: string + }, +): Promise { + const { companyId, invoice, paymentAmount, paymentDate } = params + const needle = counterpartyNeedle(invoice.supplier_name) + if (!needle) { + // An invoice without a usable supplier name is arguably HIGHER risk for + // duplicate booking, not lower (BFL 5 kap 7 §: motpart should be + // identifiable). Log the skip so the gap is visible in audit. + log.warn('duplicate-payment guard skipped', { + reason: invoice.supplier_name ? 'unusable_supplier_name' : 'missing_supplier_name', + companyId, + supplierInvoiceNumber: invoice.supplier_invoice_number, + }) + return [] + } + const paymentCurrency = normalizeCurrencyCode(invoice.currency) + + const rows = await sweepByCounterparty(supabase, { + companyId, + direction: 'outbound', + needle, + reference: { + amount: paymentAmount, + currency: paymentCurrency, + sek: invoiceAmountSek({ + amount: paymentAmount, + currency: paymentCurrency, + total: invoice.total, + totalSek: invoice.total_sek, + exchangeRate: invoice.exchange_rate, + }), + }, + paymentDate, + logContext: { companyId, supplierInvoiceNumber: invoice.supplier_invoice_number }, + }) + if (rows.length === 0) return [] + + return rankCandidates(rows, { + invoiceOcrs: ocrKeys([invoice.payment_reference, invoice.supplier_invoice_number]), + searchTerms: counterpartySearchTerms(invoice.supplier_name), + }) +} + +/** + * The one counterparty sweep both sides run. + * + * Units: `reference.amount` is denominated in the INVOICE's currency (that is + * what `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 query. + * + * Each currency sweep is ONE query with ONE `.or()`: the currency predicate + * and the name probe are nested into a single logic expression by + * `counterpartySweepLogic`, so the guard never depends on how PostgREST + * treats a repeated `or=` key. Interpolating the needle into that DSL string + * is only safe because `counterpartyNeedle` reduces the name to letters and + * digits (`COUNTERPARTY_NEEDLE_SHAPE`): no `,` `.` `(` `)` to inject a clause, + * no LIKE wildcard to widen the match. The shape is re-checked here so a + * future needle builder cannot silently reopen that hole. + */ +async function sweepByCounterparty( + supabase: SupabaseClient, + args: { + companyId: string + /** inbound = customer payment (amount > 0); outbound = supplier payment (amount < 0). */ + direction: 'inbound' | 'outbound' + needle: string + reference: ComparableAmount + paymentDate: string + logContext: Record + }, +): Promise { + const { companyId, direction, needle, reference, paymentDate, logContext } = args + if (!COUNTERPARTY_NEEDLE_SHAPE.test(needle)) { + log.warn('duplicate-payment guard skipped', { reason: 'unsafe_needle', ...logContext }) + return [] } const { sweeps, crossCurrencyUnverifiable } = planAmountSweeps( reference, @@ -166,88 +321,70 @@ export async function findDuplicatePaymentCandidatesForInvoice( // 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. + // rows are still swept. Logged because an unevaluated candidate set is not + // a clean "no duplicate": 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, + currency: reference.currency, + ...logContext, }) } 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 dayMs = 24 * 3600 * 1000 + const dateLow = new Date(dateMs - DUPLICATE_DATE_WINDOW_DAYS * dayMs).toISOString().split('T')[0] + const dateHigh = new Date(dateMs + DUPLICATE_DATE_WINDOW_DAYS * dayMs).toISOString().split('T')[0] 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), - ]), + sweeps.map((sweep) => { + const base = supabase + .from('transactions') + .select(ROW_COLUMNS) + .eq('company_id', companyId) + .eq('is_business', true) + .is('invoice_id', null) + .is('supplier_invoice_id', null) + const banded = + direction === 'inbound' + ? base.gt('amount', 0).gte('amount', sweep.low).lte('amount', sweep.high) + : base.lt('amount', 0).gte('amount', -sweep.high).lte('amount', -sweep.low) + return banded + .gte('date', dateLow) + .lte('date', dateHigh) + .or(counterpartySweepLogic(sweep.currencyFilter, needle)) + .order('date', { ascending: false }) + .limit(5) + }), ) const merged = new Map() for (const res of responses) { - for (const row of (res.data ?? []) as Row[]) { + for (const row of (Array.isArray(res.data) ? res.data : []) as Row[]) { if (!merged.has(row.id)) merged.set(row.id, row) } } - const data = Array.from(merged.values()) + return 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 }) - } +/** Normalised OCR keys worth comparing: digits only, at least MIN_OCR_DIGITS, deduplicated. */ +function ocrKeys(values: Array): string[] { + const keys = values.map(normalizeOcrReference).filter((key) => key.length >= MIN_OCR_DIGITS) + return Array.from(new Set(keys)) +} - 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, - }) +function rankCandidates( + rows: Row[], + args: { invoiceOcrs: string[]; searchTerms: string[] }, +): DuplicatePaymentCandidate[] { + const candidates: DuplicatePaymentCandidate[] = rows.map((row) => { + const reason = scoreCandidate({ row, ...args }) return { id: row.id, date: row.date, @@ -255,11 +392,11 @@ export async function findDuplicatePaymentCandidatesForInvoice( description: row.description, merchant_name: row.merchant_name, reference: row.reference, + journal_entry_id: row.journal_entry_id ?? null, 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 } @@ -395,6 +532,7 @@ async function findAggregateCandidates( description: row.description, merchant_name: row.merchant_name, reference: row.reference, + journal_entry_id: null, match_reason: 'aggregate_exact', match_confidence: MATCH_REASON_CONFIDENCE.aggregate_exact, aggregate_invoice_numbers: set.map((s) => s.invoiceNumber), @@ -424,15 +562,22 @@ function rowAmount(row: Row): ComparableAmount { } function scoreCandidate(args: { - row: { reference: string | null; description: string | null; merchant_name: string | null } - invoiceOcr: string + row: { + reference: string | null + description: string | null + merchant_name: string | null + journal_entry_id?: string | null + } + invoiceOcrs: string[] searchTerms: string[] }): DuplicatePaymentMatchReason { - const { row, invoiceOcr, searchTerms } = args - if (invoiceOcr && row.reference) { - if (normalizeOcrReference(row.reference) === invoiceOcr) { - return 'ocr_exact' - } + const { row, invoiceOcrs, searchTerms } = args + // Booked-ness decides the REMEDY, so it outranks every match-strength signal: + // a row that is already a verifikat must never be offered as "link it". + if (row.journal_entry_id) return 'already_booked' + if (invoiceOcrs.length > 0 && row.reference) { + const rowOcr = normalizeOcrReference(row.reference) + if (rowOcr && invoiceOcrs.includes(rowOcr)) return 'ocr_exact' } if (searchTerms.length > 0) { const haystack = `${row.description ?? ''} ${row.merchant_name ?? ''}`.toLowerCase() diff --git a/lib/invoices/duplicate-payment-guard.ts b/lib/invoices/duplicate-payment-guard.ts index 69a2be6a..7770771e 100644 --- a/lib/invoices/duplicate-payment-guard.ts +++ b/lib/invoices/duplicate-payment-guard.ts @@ -40,3 +40,102 @@ 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}*))` +} diff --git a/lib/pending-operations/commit.ts b/lib/pending-operations/commit.ts index bb29a65d..d427ef5b 100644 --- a/lib/pending-operations/commit.ts +++ b/lib/pending-operations/commit.ts @@ -2594,12 +2594,19 @@ async function commitMarkInvoicePaid( log.warn('duplicate-payment detection failed (continuing)', err) } if (candidates.length > 0) { + // Reason-aware wording: a row that is already a verifikat must not be + // "matched" (that books the money twice); it must be corrected. + const alreadyBooked = candidates.some((c) => c.match_reason === 'already_booked') return { - error: - `Möjlig dubbelbetalning: en obokförd banktransaktion ser ut att vara betalningen för faktura ` + - `${invoice.invoice_number}. Matcha banktransaktionen mot fakturan (gnubok_match_transaction_to_invoice) ` + - `i stället för att bokföra en separat betalning. Om det verkligen rör sig om en annan betalning, ` + - `kör om med allow_duplicate=true.`, + error: alreadyBooked + ? `Möjlig dubbelbokning: banktransaktionen som ser ut att vara betalningen för faktura ` + + `${invoice.invoice_number} är redan bokförd som en egen verifikation. Bokför inte betalningen ` + + `igen: rätta dubbelbokföringen i stället (vänd en av verifikationerna med storno och koppla underlaget ` + + `till den som blir kvar). Kör om med allow_duplicate=true bara om det verkligen är en separat betalning.` + : `Möjlig dubbelbetalning: en obokförd banktransaktion ser ut att vara betalningen för faktura ` + + `${invoice.invoice_number}. Matcha banktransaktionen mot fakturan (gnubok_match_transaction_to_invoice) ` + + `i stället för att bokföra en separat betalning. Om det verkligen rör sig om en annan betalning, ` + + `kör om med allow_duplicate=true.`, status: 409, } } diff --git a/messages/en.json b/messages/en.json index 91ba67b4..70135e86 100644 --- a/messages/en.json +++ b/messages/en.json @@ -4549,6 +4549,9 @@ "match_reason_name_amount_fuzzy": "Likely match", "match_reason_amount_only": "Possible match", "match_reason_aggregate_exact": "Aggregated payment", + "match_reason_already_booked": "Already booked", + "already_booked_hint": "This bank transaction is already booked as its own verifikat. Do not book the payment again: correct the bookkeeping instead.", + "show_voucher": "Show verifikat", "aggregate_covers": "The amount also covers {count, plural, =1 {invoice {numbers}} other {invoices {numbers}}} exactly. Split the payment under Transactions so it is booked once.", "allocate_transaction": "Split under Transactions", "duplicate_title": "Possible duplicate payment", @@ -4906,6 +4909,9 @@ "duplicate_payment_title": "Possible duplicate payment", "duplicate_payment_description_one": "We found a bank transaction that appears to match this payment. Link the existing transaction instead of creating a new verifikat.", "duplicate_payment_description_many": "We found bank transactions that appear to match this payment. Link the existing transaction instead of creating a new verifikat.", + "duplicate_payment_already_booked_description": "This bank transaction is already booked as its own verifikat. Registering the payment would book the money twice. Correct it instead: reverse one of the two vouchers with a storno entry and attach the underlag to the remaining one.", + "duplicate_payment_already_booked_hint": "Already booked on a verifikat", + "duplicate_payment_show_voucher": "Show verifikat", "bank_transaction_fallback": "Bank transaction", "go_to": "Go to", "create_voucher_anyway": "Create new verifikat anyway" diff --git a/messages/sv.json b/messages/sv.json index 21ebe4da..d5fc666f 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -4549,6 +4549,9 @@ "match_reason_name_amount_fuzzy": "Sannolik träff", "match_reason_amount_only": "Möjlig träff", "match_reason_aggregate_exact": "Samlad inbetalning", + "match_reason_already_booked": "Redan bokförd", + "already_booked_hint": "Banktransaktionen är redan bokförd som en egen verifikation. Bokför inte betalningen igen: rätta bokföringen i stället.", + "show_voucher": "Visa verifikation", "aggregate_covers": "Beloppet täcker exakt även {count, plural, =1 {faktura {numbers}} other {fakturorna {numbers}}}. Fördela inbetalningen under Transaktioner så bokförs den en gång.", "allocate_transaction": "Fördela under Transaktioner", "duplicate_title": "Möjlig dubblettbetalning", @@ -4906,6 +4909,9 @@ "duplicate_payment_title": "Möjlig dubbelbetalning", "duplicate_payment_description_one": "Vi hittade en banktransaktion som verkar matcha denna betalning. Länka den befintliga transaktionen istället för att skapa en ny verifikation.", "duplicate_payment_description_many": "Vi hittade banktransaktioner som verkar matcha denna betalning. Länka den befintliga transaktionen istället för att skapa en ny verifikation.", + "duplicate_payment_already_booked_description": "Banktransaktionen är redan bokförd som en egen verifikation. Registrerar du betalningen bokförs pengarna två gånger. Rätta i stället: vänd en av verifikationerna med storno och koppla underlaget till den som blir kvar.", + "duplicate_payment_already_booked_hint": "Redan bokförd på en verifikation", + "duplicate_payment_show_voucher": "Visa verifikation", "bank_transaction_fallback": "Banktransaktion", "go_to": "Gå till", "create_voucher_anyway": "Skapa ny verifikation ändå" diff --git a/skills/accounted-api/references/suppliers.md b/skills/accounted-api/references/suppliers.md index 060842a6..6cd0e1fb 100644 --- a/skills/accounted-api/references/suppliers.md +++ b/skills/accounted-api/references/suppliers.md @@ -514,6 +514,7 @@ Books the payment journal entry (Debit 2440 / Credit 1930 under accrual; or Debi - exchange_rate_difference (SEK delta vs the booked rate at registration) is required for foreign-currency SIs to book the FX gain/loss to 3960 / 7960. Omitting it on a non-SEK SI under accrual mis-books FX. - Strict-mode: a JE creation failure ABORTS before the status flip. There is no partial-state recovery banner: retry the call. - Cash basis (kontantmetoden) recognizes the expense + ingående moms HERE, not at :create. +- Duplicate-payment guard: on a full settlement, if a business bank transaction of the same amount around payment_date carries the supplier name (first distinctive token, so abbreviated bank text such as "HI3G" for Hi3G Access AB counts), returns 409 SI_PAID_LIKELY_DUPLICATE with candidate transactions. A candidate with match_reason `already_booked` is a bank row that is ALREADY a verifikat: do not pay the invoice, correct the double booking instead. Retry with `force: true` only after the user confirms, and with a fresh Idempotency-Key (the original is body-hash bound). Also evaluated under dry-run. | Parameter | In | Type | Required | Notes | |---|---|---|---|---|