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>
This commit is contained in:
co-authored by
Claude Fable 5.1
Jakob Wennberg
parent
82d25b7dee
commit
272d19b287
@@ -161,9 +161,8 @@ describe('POST /api/invoices/[id]/mark-paid', () => {
|
||||
|
||||
// Fetch invoice
|
||||
enqueue({ data: invoice, error: null })
|
||||
// Duplicate-payment guard: merchant_name ILIKE, no candidates
|
||||
enqueue({ data: [], error: null })
|
||||
// Duplicate-payment guard: description ILIKE, no candidates
|
||||
// Duplicate-payment guard: ONE counterparty probe (merchant_name OR
|
||||
// description in a single .or(), issue #2299), no candidates
|
||||
enqueue({ data: [], error: null })
|
||||
// Duplicate-payment guard: aggregate sweep (larger unbooked kronor rows), none
|
||||
enqueue({ data: [], error: null })
|
||||
@@ -234,8 +233,7 @@ describe('POST /api/invoices/[id]/mark-paid', () => {
|
||||
|
||||
// Fetch invoice
|
||||
enqueue({ data: invoice, error: null })
|
||||
// Duplicate-payment guard: two ILIKE probes, no candidates
|
||||
enqueue({ data: [], error: null })
|
||||
// Duplicate-payment guard: one counterparty probe, no candidates
|
||||
enqueue({ data: [], error: null })
|
||||
// Duplicate-payment guard: aggregate sweep (larger unbooked kronor rows), none
|
||||
enqueue({ data: [], error: null })
|
||||
@@ -266,9 +264,8 @@ describe('POST /api/invoices/[id]/mark-paid', () => {
|
||||
})
|
||||
|
||||
enqueue({ data: invoice, error: null })
|
||||
// Duplicate-payment guard: merchant_name ILIKE, no candidates
|
||||
enqueue({ data: [], error: null })
|
||||
// Duplicate-payment guard: description ILIKE, no candidates
|
||||
// Duplicate-payment guard: ONE counterparty probe (merchant_name OR
|
||||
// description in a single .or(), issue #2299), no candidates
|
||||
enqueue({ data: [], error: null })
|
||||
// Duplicate-payment guard: aggregate sweep (larger unbooked kronor rows), none
|
||||
enqueue({ data: [], error: null })
|
||||
@@ -426,7 +423,7 @@ describe('POST /api/invoices/[id]/mark-paid', () => {
|
||||
})
|
||||
|
||||
enqueue({ data: invoice, error: null })
|
||||
// Duplicate-payment guard: merchant_name ILIKE returns the match
|
||||
// Duplicate-payment guard: the single counterparty probe returns the match
|
||||
enqueue({
|
||||
data: [
|
||||
{
|
||||
@@ -440,8 +437,6 @@ describe('POST /api/invoices/[id]/mark-paid', () => {
|
||||
],
|
||||
error: null,
|
||||
})
|
||||
// description ILIKE, no additional match (dedup keeps merchant_name result)
|
||||
enqueue({ data: [], error: null })
|
||||
|
||||
const request = createMockRequest('/api/invoices/inv-1/mark-paid', { method: 'POST' })
|
||||
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
|
||||
@@ -803,8 +798,6 @@ describe('POST /api/invoices/[id]/mark-paid', () => {
|
||||
],
|
||||
error: null,
|
||||
})
|
||||
// description ILIKE, no additional match
|
||||
enqueue({ data: [], error: null })
|
||||
|
||||
const request = createMockRequest('/api/invoices/inv-1/mark-paid', { method: 'POST' })
|
||||
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
|
||||
@@ -851,8 +844,6 @@ describe('POST /api/invoices/[id]/mark-paid', () => {
|
||||
],
|
||||
error: null,
|
||||
})
|
||||
// description ILIKE, no additional matches
|
||||
enqueue({ data: [], error: null })
|
||||
|
||||
const request = createMockRequest('/api/invoices/inv-1/mark-paid', { method: 'POST' })
|
||||
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
|
||||
@@ -877,9 +868,8 @@ describe('POST /api/invoices/[id]/mark-paid', () => {
|
||||
})
|
||||
|
||||
enqueue({ data: invoice, error: null })
|
||||
// Duplicate-payment guard: merchant_name ILIKE, no candidates
|
||||
enqueue({ data: [], error: null })
|
||||
// Duplicate-payment guard: description ILIKE, no candidates
|
||||
// Duplicate-payment guard: ONE counterparty probe (merchant_name OR
|
||||
// description in a single .or(), issue #2299), no candidates
|
||||
enqueue({ data: [], error: null })
|
||||
// Duplicate-payment guard: aggregate sweep (larger unbooked kronor rows), none
|
||||
enqueue({ data: [], error: null })
|
||||
@@ -994,8 +984,9 @@ describe('POST /api/invoices/[id]/mark-paid', () => {
|
||||
})
|
||||
|
||||
enqueue({ data: invoice, error: null })
|
||||
// merchant_name ILIKE probe: the bank row is in kronor, because the
|
||||
// candidate lookup scans transactions.amount, which is SEK.
|
||||
// EUR currency sweep (one counterparty probe per currency, issue #2299):
|
||||
// the queued stub ignores filters, so the kronor row lands here and is
|
||||
// kept by the per-row re-check as a SEK magnitude of the EUR payment.
|
||||
enqueue({
|
||||
data: [
|
||||
{
|
||||
@@ -1009,7 +1000,7 @@ describe('POST /api/invoices/[id]/mark-paid', () => {
|
||||
],
|
||||
error: null,
|
||||
})
|
||||
// description ILIKE probe: no additional match.
|
||||
// SEK currency sweep: no additional match.
|
||||
enqueue({ data: [], error: null })
|
||||
|
||||
const request = createMockRequest('/api/invoices/inv-1/mark-paid', {
|
||||
@@ -1159,8 +1150,7 @@ describe('POST /api/invoices/[id]/mark-paid', () => {
|
||||
})
|
||||
|
||||
enqueue({ data: invoice, error: null })
|
||||
enqueue({ data: [], error: null }) // duplicate guard: merchant_name
|
||||
enqueue({ data: [], error: null }) // duplicate guard: description
|
||||
enqueue({ data: [], error: null }) // duplicate guard: one counterparty probe
|
||||
enqueue({ data: [], error: null }) // duplicate guard: aggregate sweep
|
||||
enqueue({ data: { accounting_method: 'cash', entity_type: 'enskild_firma' }, error: null })
|
||||
enqueue({ data: { id: 'ip-1' }, error: null }) // invoice_payments insert
|
||||
|
||||
@@ -155,7 +155,7 @@ describe('POST /api/supplier-invoices/[id]/mark-paid: duplicate-guard band units
|
||||
eventBus.clear()
|
||||
})
|
||||
|
||||
it('SEK invoice: one kronor-banded sweep, byte-identical to the pre-fix query', async () => {
|
||||
it('SEK invoice: one kronor-banded sweep with the same band, probing both name columns', async () => {
|
||||
useSupabase({
|
||||
supplier_invoices: [
|
||||
{
|
||||
@@ -188,7 +188,15 @@ describe('POST /api/supplier-invoices/[id]/mark-paid: duplicate-guard band units
|
||||
expect(q.lte).toContainEqual(['amount', -12250])
|
||||
// Band is kronor, so the rows it is applied to must be kronor. NULL is
|
||||
// kronor too: transactions.currency is nullable with DEFAULT 'SEK'.
|
||||
expect(q.or).toEqual([['currency.is.null,currency.eq.SEK']])
|
||||
// Issue #2299: the needle is the first distinctive token of the supplier
|
||||
// name, probed on merchant_name OR description (the old guard sent
|
||||
// `%Leverantör AB%` against merchant_name only), and the currency clause
|
||||
// is nested into the SAME single .or() so nothing rides on how PostgREST
|
||||
// treats a repeated `or=` key.
|
||||
expect(q.or).toEqual([[
|
||||
'and(or(currency.is.null,currency.eq.SEK),or(merchant_name.ilike.*leverantör*,description.ilike.*leverantör*))',
|
||||
]])
|
||||
expect(q.ilike).toBeUndefined()
|
||||
// The per-row re-check reads these columns; a narrow projection would make
|
||||
// it read `undefined` and silently default every row to SEK.
|
||||
expect(q.select?.[0][0]).toContain('currency')
|
||||
@@ -229,12 +237,16 @@ describe('POST /api/supplier-invoices/[id]/mark-paid: duplicate-guard band units
|
||||
|
||||
expect(txQueries()).toHaveLength(2)
|
||||
const eur = txQueries()[0].calls
|
||||
expect(eur.or).toEqual([['currency.eq.EUR']])
|
||||
expect(eur.or).toEqual([[
|
||||
'and(or(currency.eq.EUR),or(merchant_name.ilike.*leverantör*,description.ilike.*leverantör*))',
|
||||
]])
|
||||
expect(eur.gte).toContainEqual(['amount', -1020])
|
||||
expect(eur.lte).toContainEqual(['amount', -980])
|
||||
|
||||
const sek = txQueries()[1].calls
|
||||
expect(sek.or).toEqual([['currency.is.null,currency.eq.SEK']])
|
||||
expect(sek.or).toEqual([[
|
||||
'and(or(currency.is.null,currency.eq.SEK),or(merchant_name.ilike.*leverantör*,description.ilike.*leverantör*))',
|
||||
]])
|
||||
// 1 000 EUR x 11,50 = 11 500 kr, banded plus-minus 2 %. The pre-fix query
|
||||
// asked kronor rows for -1 020..-980 and matched nothing.
|
||||
expect(sek.gte).toContainEqual(['amount', -11730])
|
||||
@@ -275,6 +287,8 @@ describe('POST /api/supplier-invoices/[id]/mark-paid: duplicate-guard band units
|
||||
expect(status).toBe(200)
|
||||
expect(body.success).toBe(true)
|
||||
expect(txQueries()).toHaveLength(1)
|
||||
expect(txQueries()[0].calls.or).toEqual([['currency.eq.EUR']])
|
||||
expect(txQueries()[0].calls.or).toEqual([[
|
||||
'and(or(currency.eq.EUR),or(merchant_name.ilike.*leverantör*,description.ilike.*leverantör*))',
|
||||
]])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -497,6 +497,140 @@ describe('POST /api/supplier-invoices/[id]/mark-paid', () => {
|
||||
expect(mockCreateSupplierInvoicePaymentEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('issue #2299: flags the row whose description is the abbreviated bank text "HI3G", merchant_name empty', async () => {
|
||||
const invoice = makeSupplierInvoice({
|
||||
id: 'si-1',
|
||||
status: 'approved',
|
||||
total: 1249,
|
||||
remaining_amount: 1249,
|
||||
paid_amount: 0,
|
||||
supplier: makeSupplier({ name: 'Hi3G Access AB' }),
|
||||
items: [],
|
||||
})
|
||||
|
||||
enqueue({ data: invoice, error: null })
|
||||
// The single counterparty sweep: the bank feed wrote "HI3G" and nothing in
|
||||
// merchant_name. The full-name needle never hit this row.
|
||||
enqueue({
|
||||
data: [
|
||||
{
|
||||
id: 'tx-hi3g',
|
||||
date: '2026-09-01',
|
||||
amount: -1249,
|
||||
description: 'HI3G',
|
||||
merchant_name: null,
|
||||
reference: null,
|
||||
journal_entry_id: null,
|
||||
currency: 'SEK',
|
||||
amount_sek: null,
|
||||
exchange_rate: null,
|
||||
},
|
||||
],
|
||||
error: null,
|
||||
})
|
||||
|
||||
const request = createMockRequest('/api/supplier-invoices/si-1/mark-paid', {
|
||||
method: 'POST',
|
||||
body: { payment_date: '2026-09-01' },
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'si-1' }))
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
error: { code: string; details: { candidates: Array<{ id: string; match_reason: string }> } }
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(409)
|
||||
expect(body.error.code).toBe('SI_PAID_LIKELY_DUPLICATE')
|
||||
expect(body.error.details.candidates.map((c) => [c.id, c.match_reason])).toEqual([
|
||||
['tx-hi3g', 'name_amount_fuzzy'],
|
||||
])
|
||||
expect(mockCreateSupplierInvoicePaymentEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('surfaces a bank row already booked as an expense as already_booked, with its verifikat id', async () => {
|
||||
// A61 in the case: booked straight from the bank row, then the invoice
|
||||
// registered and paid on top of it. The remedy is a rättelse, not a link,
|
||||
// so the reason has to be its own code for the UI and the agent to word.
|
||||
const invoice = makeSupplierInvoice({
|
||||
id: 'si-1',
|
||||
status: 'approved',
|
||||
total: 1249,
|
||||
remaining_amount: 1249,
|
||||
paid_amount: 0,
|
||||
supplier: makeSupplier({ name: 'Hi3G Access AB' }),
|
||||
items: [],
|
||||
})
|
||||
|
||||
enqueue({ data: invoice, error: null })
|
||||
enqueue({
|
||||
data: [
|
||||
{
|
||||
id: 'tx-a61',
|
||||
date: '2026-08-28',
|
||||
amount: -1249,
|
||||
description: 'HI3G',
|
||||
merchant_name: null,
|
||||
reference: null,
|
||||
journal_entry_id: 'je-a61',
|
||||
currency: 'SEK',
|
||||
amount_sek: null,
|
||||
exchange_rate: null,
|
||||
},
|
||||
],
|
||||
error: null,
|
||||
})
|
||||
|
||||
const request = createMockRequest('/api/supplier-invoices/si-1/mark-paid', {
|
||||
method: 'POST',
|
||||
body: { payment_date: '2026-09-01' },
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'si-1' }))
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
error: {
|
||||
code: string
|
||||
details: { candidates: Array<{ id: string; match_reason: string; journal_entry_id: string | null }> }
|
||||
}
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(409)
|
||||
expect(body.error.code).toBe('SI_PAID_LIKELY_DUPLICATE')
|
||||
expect(body.error.details.candidates).toHaveLength(1)
|
||||
expect(body.error.details.candidates[0]).toMatchObject({
|
||||
id: 'tx-a61',
|
||||
match_reason: 'already_booked',
|
||||
journal_entry_id: 'je-a61',
|
||||
})
|
||||
expect(mockCreateSupplierInvoicePaymentEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('an invoice whose supplier has no resolved name skips the guard and books (logged, not blocked)', async () => {
|
||||
const invoice = makeSupplierInvoice({
|
||||
id: 'si-1',
|
||||
status: 'approved',
|
||||
total: 10000,
|
||||
remaining_amount: 10000,
|
||||
paid_amount: 0,
|
||||
supplier: makeSupplier({ name: '' }),
|
||||
items: [],
|
||||
})
|
||||
|
||||
enqueue({ data: invoice, error: null })
|
||||
// No sweep is issued without a needle: the next query is the settings fetch.
|
||||
enqueue({ data: { accounting_method: 'accrual' }, error: null })
|
||||
mockCreateSupplierInvoicePaymentEntry.mockResolvedValue({ id: 'je-1' })
|
||||
enqueue({ data: [{ id: 'si-1' }], error: null })
|
||||
enqueue({ data: null, error: null })
|
||||
|
||||
const request = createMockRequest('/api/supplier-invoices/si-1/mark-paid', {
|
||||
method: 'POST',
|
||||
body: {},
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'si-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ success: boolean }>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.success).toBe(true)
|
||||
})
|
||||
|
||||
// ── Duplicate-guard currency: the plus-minus 2 % band and the column it is
|
||||
// applied to must share a unit. `remaining_amount` is invoice currency,
|
||||
// `transactions.amount` is the bank row's currency; at ~11,50 SEK/EUR a EUR
|
||||
|
||||
@@ -16,19 +16,7 @@ import { validateBody } from '@/lib/api/validate'
|
||||
import { MarkSupplierInvoicePaidSchema } from '@/lib/api/schemas'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
import {
|
||||
DUPLICATE_AMOUNT_TOLERANCE_PCT,
|
||||
DUPLICATE_DATE_WINDOW_DAYS,
|
||||
escapeLikePattern,
|
||||
} from '@/lib/invoices/duplicate-payment-guard'
|
||||
import {
|
||||
invoiceAmountSek,
|
||||
magnitudesWithinTolerance,
|
||||
normalizeCurrencyCode,
|
||||
planAmountSweeps,
|
||||
type ComparableAmount,
|
||||
} from '@/lib/invoices/duplicate-guard-currency'
|
||||
import { resolveTransactionAmountSek } from '@/lib/transactions/booking-duplicate-detection'
|
||||
import { findDuplicatePaymentCandidatesForSupplierInvoice } from '@/lib/invoices/duplicate-payment-candidates'
|
||||
import type { SupplierInvoice, SupplierInvoiceItem } from '@/types'
|
||||
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
|
||||
|
||||
@@ -77,141 +65,43 @@ export const POST = withRouteContext(
|
||||
})
|
||||
}
|
||||
|
||||
// Duplicate-payment guard: if a likely-matching unlinked bank transaction
|
||||
// exists for this supplier, surface it before booking a new payment entry.
|
||||
// Caller can override with `force: true`. Skipped on partial payments:
|
||||
// those are an explicit, deliberate action.
|
||||
// Duplicate-payment guard: if a bank transaction already looks like this
|
||||
// payment, surface it before booking a new payment entry. Caller can
|
||||
// override with `force: true`. Skipped on partial payments: those are an
|
||||
// explicit, deliberate action. The detector is the same one the customer
|
||||
// side uses (lib/invoices/duplicate-payment-candidates.ts): first
|
||||
// distinctive token of the supplier name against merchant_name OR
|
||||
// description, per-currency amount band, JS ranking. A candidate with
|
||||
// match_reason `already_booked` is a row that is already a verifikat
|
||||
// (booked straight from the bank side): the remedy is a rättelse, not a
|
||||
// link, and the UI words it that way.
|
||||
const paidRounded = Math.round(paymentAmount * 100) / 100
|
||||
const remainingRounded = Math.round(invoice.remaining_amount * 100) / 100
|
||||
if (!body.force && paidRounded >= remainingRounded) {
|
||||
const supplierName = (invoice as SupplierInvoice & { supplier?: { name?: string } })
|
||||
.supplier?.name
|
||||
if (!supplierName) {
|
||||
// An invoice without a resolved 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.
|
||||
opLog.warn('duplicate-payment guard skipped', {
|
||||
reason: 'missing_supplier_name',
|
||||
supplierInvoiceId: id,
|
||||
const candidates = await findDuplicatePaymentCandidatesForSupplierInvoice(supabase, {
|
||||
companyId: companyId!,
|
||||
invoice: {
|
||||
supplier_invoice_number: invoice.supplier_invoice_number ?? null,
|
||||
payment_reference: (invoice as { payment_reference?: string | null }).payment_reference ?? null,
|
||||
supplier_name: supplierName,
|
||||
currency: invoice.currency ?? null,
|
||||
total: invoice.total ?? null,
|
||||
total_sek: invoice.total_sek ?? null,
|
||||
exchange_rate: invoice.exchange_rate ?? null,
|
||||
},
|
||||
// Denominated in the invoice's currency (that is what remaining_amount
|
||||
// and body.amount are); the detector bands bank rows per currency.
|
||||
paymentAmount,
|
||||
paymentDate,
|
||||
})
|
||||
if (candidates.length > 0) {
|
||||
return errorResponseFromCode('SI_PAID_LIKELY_DUPLICATE', opLog, {
|
||||
requestId,
|
||||
details: { candidates },
|
||||
})
|
||||
}
|
||||
if (supplierName) {
|
||||
// Units: `paymentAmount` is denominated in the supplier invoice's
|
||||
// currency (that is what `remaining_amount` and `body.amount` are),
|
||||
// while `transactions.amount` is denominated in the bank row's own
|
||||
// currency. The plus-minus tolerance band is therefore planned per
|
||||
// currency and re-checked per row, so band and column always share a
|
||||
// unit. A SEK invoice yields exactly one sweep with the band it had
|
||||
// before, so a SEK-only company sees the identical single query.
|
||||
const paymentCurrency = normalizeCurrencyCode(invoice.currency)
|
||||
const reference: ComparableAmount = {
|
||||
amount: paymentAmount,
|
||||
currency: paymentCurrency,
|
||||
sek: invoiceAmountSek({
|
||||
amount: paymentAmount,
|
||||
currency: paymentCurrency,
|
||||
total: invoice.total,
|
||||
totalSek: invoice.total_sek,
|
||||
exchangeRate: invoice.exchange_rate,
|
||||
}),
|
||||
}
|
||||
const { sweeps, crossCurrencyUnverifiable } = planAmountSweeps(
|
||||
reference,
|
||||
DUPLICATE_AMOUNT_TOLERANCE_PCT,
|
||||
)
|
||||
if (crossCurrencyUnverifiable) {
|
||||
// A foreign invoice with no stored rate cannot be stated in kronor,
|
||||
// so kronor bank rows can only be excluded, never compared raw
|
||||
// (a raw compare reads 1 000 EUR as 1 000 kr). Same-currency rows are
|
||||
// still swept. Logged so the blind spot is visible in audit rather
|
||||
// than passing as a clean "no duplicate".
|
||||
opLog.warn('duplicate-payment guard: cross-currency candidates not evaluated', {
|
||||
reason: 'invoice_missing_sek_value',
|
||||
currency: paymentCurrency,
|
||||
supplierInvoiceId: id,
|
||||
})
|
||||
}
|
||||
|
||||
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 escapedSupplierName = escapeLikePattern(supplierName)
|
||||
|
||||
type CandidateRow = {
|
||||
id: string
|
||||
date: string
|
||||
amount: number
|
||||
description: string | null
|
||||
merchant_name: string | null
|
||||
currency: string | null
|
||||
amount_sek: number | null
|
||||
exchange_rate: number | null
|
||||
}
|
||||
|
||||
const sweepResults = await Promise.all(
|
||||
sweeps.map((sweep) =>
|
||||
supabase
|
||||
.from('transactions')
|
||||
.select(
|
||||
'id, date, amount, description, merchant_name, currency, amount_sek, exchange_rate',
|
||||
)
|
||||
.eq('company_id', companyId!)
|
||||
.eq('is_business', true)
|
||||
.is('supplier_invoice_id', null)
|
||||
.is('invoice_id', null)
|
||||
.lt('amount', 0)
|
||||
.or(sweep.currencyFilter)
|
||||
.gte('amount', -sweep.high)
|
||||
.lte('amount', -sweep.low)
|
||||
.gte('date', dateLow)
|
||||
.lte('date', dateHigh)
|
||||
.ilike('merchant_name', `%${escapedSupplierName}%`)
|
||||
.order('date', { ascending: false })
|
||||
.limit(5),
|
||||
),
|
||||
)
|
||||
|
||||
const byId = new Map<string, CandidateRow>()
|
||||
for (const res of sweepResults) {
|
||||
for (const row of (res.data ?? []) as CandidateRow[]) {
|
||||
if (!byId.has(row.id)) byId.set(row.id, row)
|
||||
}
|
||||
}
|
||||
const candidates = Array.from(byId.values())
|
||||
.filter((c) =>
|
||||
magnitudesWithinTolerance(
|
||||
reference,
|
||||
{
|
||||
amount: Number(c.amount),
|
||||
currency: normalizeCurrencyCode(c.currency),
|
||||
sek: resolveTransactionAmountSek({
|
||||
amount: c.amount,
|
||||
currency: c.currency,
|
||||
amount_sek: c.amount_sek,
|
||||
exchange_rate: c.exchange_rate,
|
||||
}),
|
||||
},
|
||||
DUPLICATE_AMOUNT_TOLERANCE_PCT,
|
||||
),
|
||||
)
|
||||
.sort((a, b) => (a.date < b.date ? 1 : a.date > b.date ? -1 : 0))
|
||||
.slice(0, 5)
|
||||
|
||||
if (candidates.length > 0) {
|
||||
return errorResponseFromCode('SI_PAID_LIKELY_DUPLICATE', opLog, {
|
||||
requestId,
|
||||
details: {
|
||||
candidates: candidates.map((c) => ({
|
||||
id: c.id,
|
||||
date: c.date,
|
||||
amount: c.amount,
|
||||
description: c.description,
|
||||
merchant_name: c.merchant_name,
|
||||
})),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const { data: settings } = await supabase
|
||||
|
||||
+97
-2
@@ -1,10 +1,14 @@
|
||||
/**
|
||||
* Coverage for POST /api/v1/companies/:companyId/supplier-invoices/:id/mark-paid.
|
||||
*
|
||||
* Scoped to the settled-suggestion cleanup (issue #1259): a supplier invoice
|
||||
* paid through the API must not leave bank transactions pointing at it as an
|
||||
* The settled-suggestion cleanup (issue #1259): a supplier invoice paid
|
||||
* through the API must not leave bank transactions pointing at it as an
|
||||
* import-time match suggestion, and a PARTIAL payment must leave those
|
||||
* suggestions alone because the invoice is still matchable.
|
||||
*
|
||||
* The duplicate-payment guard (issue #2299): this door had none, so an agent
|
||||
* could book a payment the bank feed already carried. Same shared detector
|
||||
* as the dashboard route.
|
||||
*/
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
@@ -233,4 +237,95 @@ describe('POST /api/v1/companies/:companyId/supplier-invoices/:id/mark-paid', ()
|
||||
expect(body.data.status).toBe('partially_paid')
|
||||
expect(mockClearSuggestions).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
const hi3gRow = {
|
||||
id: 'tx-hi3g',
|
||||
date: '2026-05-11',
|
||||
amount: -1000,
|
||||
description: 'HI3G',
|
||||
merchant_name: null,
|
||||
reference: null,
|
||||
journal_entry_id: null,
|
||||
currency: 'SEK',
|
||||
amount_sek: null,
|
||||
exchange_rate: null,
|
||||
}
|
||||
const hi3gSI = {
|
||||
...APPROVED_SI,
|
||||
supplier: { ...APPROVED_SI.supplier, name: 'Hi3G Access AB' },
|
||||
}
|
||||
|
||||
it('returns 409 SI_PAID_LIKELY_DUPLICATE when an outbound bank row carries the abbreviated supplier text (issue #2299)', async () => {
|
||||
const calls: RecordedCall[] = []
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
supplier_invoices: { data: hi3gSI, error: null },
|
||||
company_settings: { data: { accounting_method: 'accrual' }, error: null },
|
||||
transactions: { data: [hi3gRow], error: null },
|
||||
}, calls),
|
||||
)
|
||||
|
||||
const res = await markPaid(makeRequest({ payment_date: '2026-05-12' }), detailParams())
|
||||
|
||||
expect(res.status).toBe(409)
|
||||
const body = await res.json()
|
||||
expect(body.error.code).toBe('SI_PAID_LIKELY_DUPLICATE')
|
||||
expect(body.error.details.candidates.map((c: { id: string; match_reason: string }) => [c.id, c.match_reason]))
|
||||
.toEqual([['tx-hi3g', 'name_amount_fuzzy']])
|
||||
// Nothing was booked and nothing was flipped.
|
||||
expect(calls.some((c) => c.table === 'supplier_invoices' && c.method === 'update')).toBe(false)
|
||||
expect(calls.some((c) => c.table === 'supplier_invoice_payments' && c.method === 'insert')).toBe(false)
|
||||
// The probe is the first distinctive token on both name columns, nested
|
||||
// with the currency clause into ONE .or(), outbound.
|
||||
const sweep = calls.filter((c) => c.table === 'transactions')
|
||||
expect(sweep.filter((c) => c.method === 'or').map((c) => c.args)).toEqual([[
|
||||
'and(or(currency.is.null,currency.eq.SEK),or(merchant_name.ilike.*hi3g*,description.ilike.*hi3g*))',
|
||||
]])
|
||||
expect(sweep.map((c) => c.args)).toContainEqual(['amount', 0])
|
||||
})
|
||||
|
||||
it('force: true bypasses the guard and books the payment', async () => {
|
||||
const calls: RecordedCall[] = []
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
supplier_invoices: [
|
||||
{ data: hi3gSI, error: null },
|
||||
{ data: { ...hi3gSI, status: 'paid', paid_amount: 1000, remaining_amount: 0 }, error: null },
|
||||
],
|
||||
company_settings: { data: { accounting_method: 'accrual' }, error: null },
|
||||
transactions: { data: [hi3gRow], error: null },
|
||||
supplier_invoice_payments: { data: null, error: null },
|
||||
}, calls),
|
||||
)
|
||||
|
||||
const res = await markPaid(makeRequest({ payment_date: '2026-05-12', force: true }), detailParams())
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.data.status).toBe('paid')
|
||||
expect(calls.some((c) => c.table === 'transactions')).toBe(false)
|
||||
})
|
||||
|
||||
it('a partial payment skips the guard: it is an explicit, deliberate action', async () => {
|
||||
const calls: RecordedCall[] = []
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
supplier_invoices: [
|
||||
{ data: hi3gSI, error: null },
|
||||
{ data: { ...hi3gSI, status: 'partially_paid', paid_amount: 400, remaining_amount: 600 }, error: null },
|
||||
],
|
||||
company_settings: { data: { accounting_method: 'accrual' }, error: null },
|
||||
transactions: { data: [hi3gRow], error: null },
|
||||
supplier_invoice_payments: { data: null, error: null },
|
||||
}, calls),
|
||||
)
|
||||
|
||||
const res = await markPaid(makeRequest({ payment_date: '2026-05-12', amount: 400 }), detailParams())
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(calls.some((c) => c.table === 'transactions')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -31,6 +31,7 @@ import { cashPartialBlockReason } from '@/lib/bookkeeping/booking-mode'
|
||||
import { isBookkeepingError } from '@/lib/bookkeeping/errors'
|
||||
import { anchorSupplierInvoiceDocument } from '@/lib/core/documents/supplier-invoice-underlag'
|
||||
import { clearSettledInvoiceSuggestions } from '@/lib/invoices/clear-settled-invoice-suggestions'
|
||||
import { findDuplicatePaymentCandidatesForSupplierInvoice } from '@/lib/invoices/duplicate-payment-candidates'
|
||||
import { paidAtFromDate } from '@/lib/invoices/paid-at'
|
||||
import { eventBus } from '@/lib/events'
|
||||
import type { SupplierInvoice, SupplierInvoiceItem } from '@/types'
|
||||
@@ -68,6 +69,7 @@ registerEndpoint({
|
||||
'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.',
|
||||
],
|
||||
example: {
|
||||
request: { payment_date: '2026-05-13' },
|
||||
@@ -122,6 +124,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
|
||||
let bodyPaymentDate: string | undefined
|
||||
let exchangeRateDifference: number | undefined
|
||||
let bodyNotes: string | undefined
|
||||
let force = false
|
||||
let customLines:
|
||||
| Array<{ account_number: string; debit_amount: number; credit_amount: number; line_description?: string }>
|
||||
| undefined
|
||||
@@ -133,6 +136,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
|
||||
exchangeRateDifference = parsed.data.exchange_rate_difference
|
||||
bodyNotes = parsed.data.notes
|
||||
customLines = parsed.data.lines
|
||||
force = parsed.data.force === true
|
||||
}
|
||||
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
@@ -161,7 +165,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
|
||||
.from('supplier_invoices')
|
||||
.select(`
|
||||
id, supplier_id, status, currency, exchange_rate, total, paid_amount, remaining_amount,
|
||||
supplier_invoice_number, arrival_number, invoice_date, vat_treatment, reverse_charge,
|
||||
supplier_invoice_number, arrival_number, invoice_date, vat_treatment, reverse_charge, payment_reference,
|
||||
subtotal, subtotal_sek, vat_amount, vat_amount_sek, total_sek, due_date, received_date,
|
||||
is_credit_note, credited_invoice_id, payment_journal_entry_id, default_dimensions,
|
||||
supplier:suppliers(id, name, supplier_type),
|
||||
@@ -327,6 +331,47 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
|
||||
})
|
||||
}
|
||||
|
||||
const pickSupplier = (s: SI['supplier']): SupplierObj | null => {
|
||||
if (!s) return null
|
||||
return Array.isArray(s) ? (s[0] ?? null) : s
|
||||
}
|
||||
const supplierRow = pickSupplier(typed.supplier)
|
||||
|
||||
// Duplicate-payment guard: parity with the dashboard mark-paid route and
|
||||
// the v1 invoices twin, which this door lacked entirely. Runs before the
|
||||
// dry-run preview so a successful preview cannot mask the warning, only on
|
||||
// a full settlement (a partial is an explicit, deliberate action), and
|
||||
// never on force=true. Same shared detector as the dashboard route.
|
||||
if (!force && newStatus === 'paid') {
|
||||
const candidates = await findDuplicatePaymentCandidatesForSupplierInvoice(ctx.supabase, {
|
||||
companyId: ctx.companyId!,
|
||||
invoice: {
|
||||
supplier_invoice_number: typed.supplier_invoice_number ?? null,
|
||||
payment_reference: (typed as { payment_reference?: string | null }).payment_reference ?? null,
|
||||
supplier_name: supplierRow?.name,
|
||||
currency: typed.currency ?? null,
|
||||
total: typed.total ?? null,
|
||||
total_sek: (typed as { total_sek?: number | null }).total_sek ?? null,
|
||||
exchange_rate: (typed as { exchange_rate?: number | null }).exchange_rate ?? null,
|
||||
},
|
||||
paymentAmount,
|
||||
paymentDate,
|
||||
})
|
||||
if (candidates.length > 0) {
|
||||
return v1ErrorResponseFromCode('SI_PAID_LIKELY_DUPLICATE', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: { candidates },
|
||||
})
|
||||
}
|
||||
} else if (force) {
|
||||
ctx.log.warn('duplicate-payment guard bypassed', {
|
||||
reason: 'force=true',
|
||||
invoiceId,
|
||||
userId: ctx.userId,
|
||||
paymentAmount,
|
||||
})
|
||||
}
|
||||
|
||||
if (ctx.dryRun) {
|
||||
// Keep the preview aligned with the live date-only payment timestamp.
|
||||
return dryRunPreview(
|
||||
@@ -344,12 +389,6 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
|
||||
)
|
||||
}
|
||||
|
||||
const pickSupplier = (s: SI['supplier']): SupplierObj | null => {
|
||||
if (!s) return null
|
||||
return Array.isArray(s) ? (s[0] ?? null) : s
|
||||
}
|
||||
const supplierRow = pickSupplier(typed.supplier)
|
||||
|
||||
// Strict-mode: book the JE FIRST. Failure aborts before any SI mutation.
|
||||
let journalEntryId: string | null = null
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user