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
@@ -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<MarkPaidPreview | null>(null)
|
||||
@@ -1251,31 +1254,50 @@ export default function SupplierInvoiceDetailPage() {
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{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')}
|
||||
</p>
|
||||
<div className="space-y-2 rounded-lg border bg-muted/30 p-3">
|
||||
{duplicateCandidates?.map((c) => (
|
||||
<div key={c.id} className="flex items-center justify-between gap-3 text-sm">
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium tabular-nums">{formatDate(c.date)}</div>
|
||||
<div className="truncate text-xs text-muted-foreground">
|
||||
{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 (
|
||||
<div key={c.id} className="flex items-center justify-between gap-3 text-sm">
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium tabular-nums">{formatDate(c.date)}</div>
|
||||
<div className="truncate text-xs text-muted-foreground">
|
||||
{c.merchant_name || c.description || t('bank_transaction_fallback')}
|
||||
</div>
|
||||
{alreadyBooked && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t('duplicate_payment_already_booked_hint')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="tabular-nums font-medium">
|
||||
{formatCurrency(Math.abs(c.amount), invoice.currency)}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
router.push(
|
||||
alreadyBooked
|
||||
? `/bookkeeping/${c.journal_entry_id}`
|
||||
: `/transactions?highlight=${c.id}`,
|
||||
)
|
||||
}
|
||||
>
|
||||
{alreadyBooked ? t('duplicate_payment_show_voucher') : t('go_to')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="tabular-nums font-medium">
|
||||
{formatCurrency(Math.abs(c.amount), invoice.currency)}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => router.push(`/transactions?highlight=${c.id}`)}
|
||||
>
|
||||
{t('go_to')}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||
<Button variant="outline" onClick={() => setDuplicateCandidates(null)}>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -30,7 +30,12 @@ import type { EntityType } from '@/types'
|
||||
import type { InvoiceWithRelations } from '@/components/invoices/types'
|
||||
import { loadBasCatalog, type CatalogAccount } from '@/lib/bookkeeping/bas-catalog-client'
|
||||
|
||||
type DuplicateMatchReason = 'ocr_exact' | 'name_amount_fuzzy' | 'amount_only' | 'aggregate_exact'
|
||||
type DuplicateMatchReason =
|
||||
| 'ocr_exact'
|
||||
| 'name_amount_fuzzy'
|
||||
| 'amount_only'
|
||||
| 'aggregate_exact'
|
||||
| 'already_booked'
|
||||
|
||||
interface DuplicateCandidate {
|
||||
id: string
|
||||
@@ -39,6 +44,8 @@ interface DuplicateCandidate {
|
||||
description: string | null
|
||||
merchant_name: string | null
|
||||
reference: string | null
|
||||
/** already_booked: the verifikat the row is already booked on. */
|
||||
journal_entry_id?: string | null
|
||||
match_reason: DuplicateMatchReason
|
||||
match_confidence: number
|
||||
/** aggregate_exact: the other open invoices the bank row also covers. */
|
||||
@@ -70,6 +77,7 @@ export default function PaymentBookingDialog({
|
||||
name_amount_fuzzy: t('match_reason_name_amount_fuzzy'),
|
||||
amount_only: t('match_reason_amount_only'),
|
||||
aggregate_exact: t('match_reason_aggregate_exact'),
|
||||
already_booked: t('match_reason_already_booked'),
|
||||
}
|
||||
|
||||
// Session-cached reference data (lib/reference-data), seeded by the
|
||||
@@ -365,14 +373,19 @@ export default function PaymentBookingDialog({
|
||||
</div>
|
||||
<ul className="space-y-2">
|
||||
{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 (
|
||||
<li
|
||||
key={c.id}
|
||||
@@ -403,15 +416,26 @@ export default function PaymentBookingDialog({
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{isAlreadyBooked && (
|
||||
<p className="text-xs text-muted-foreground">{t('already_booked_hint')}</p>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleLinkExisting(c.id)}
|
||||
onClick={() =>
|
||||
isAlreadyBooked
|
||||
? router.push(`/bookkeeping/${c.journal_entry_id}`)
|
||||
: handleLinkExisting(c.id)
|
||||
}
|
||||
className="shrink-0"
|
||||
>
|
||||
{isAggregate ? t('allocate_transaction') : t('link_transaction')}
|
||||
{isAlreadyBooked
|
||||
? t('show_voucher')
|
||||
: isAggregate
|
||||
? t('allocate_transaction')
|
||||
: t('link_transaction')}
|
||||
</Button>
|
||||
</li>
|
||||
)
|
||||
|
||||
@@ -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.`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3032,12 +3032,12 @@ const SUPPLIER_INVOICE_WAVE4: Record<string, StructuredErrorEntry> = {
|
||||
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: {
|
||||
|
||||
@@ -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<string, unknown[][]>
|
||||
|
||||
@@ -8,7 +11,8 @@ type QueryRecord = Record<string, unknown[][]>
|
||||
* 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<Array<Record<string, unknown>>>) {
|
||||
const queries: QueryRecord[] = []
|
||||
@@ -40,6 +44,12 @@ function createRecordingSupabase(pages: Array<Array<Record<string, unknown>>>) {
|
||||
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<Record<string, unknown>> = {}) {
|
||||
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<Record<string, unknown>> = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `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<Record<string, unknown>> = {}) {
|
||||
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'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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(<currency>),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<typeof fetch>) => {
|
||||
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<typeof createToolPgClient>
|
||||
|
||||
/** Ids of the seeded rows, keyed by what they are meant to prove. */
|
||||
const rows: Record<string, string> = {}
|
||||
|
||||
async function insertRow(params: {
|
||||
amount: number
|
||||
currency: 'SEK' | 'EUR'
|
||||
amountSek: number | null
|
||||
description: string
|
||||
merchantName: string | null
|
||||
}): Promise<string> {
|
||||
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([])
|
||||
})
|
||||
})
|
||||
@@ -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/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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*)))',
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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<DuplicatePaymentMatchReason, number> = {
|
||||
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<DuplicatePaymentMatchReason, number> = {
|
||||
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<DuplicatePaymentCandidate[]> {
|
||||
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<DuplicatePaymentCandidate[]> {
|
||||
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<string, unknown>
|
||||
},
|
||||
): Promise<Row[]> {
|
||||
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<string, Row>()
|
||||
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 | null | undefined>): 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()
|
||||
|
||||
@@ -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}*))`
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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å"
|
||||
|
||||
@@ -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 |
|
||||
|---|---|---|---|---|
|
||||
|
||||
Reference in New Issue
Block a user