fix(payments): refuse to book a bank row that unlinked vouchers already explain (#2300)
* fix(payments): refuse to book a bank row that unlinked vouchers already explain A bank feed can deliver several affarshandelser as one row (a Bankgirot daily aggregate: two customers' invoices, one "BGGIRERING" row with no payer). When each invoice was already marked paid by hand, nothing on the account equals the row, the 1:1 duplicate check passes, and "Dela betalning" books the money a second time against whatever open invoices the user picks (the next period's identical ones, in the reported case). - lib/reconciliation/covering-set.ts: exact ore subset sum over a capped candidate list, smallest set first, closest in date second. - detectExplainingVoucherSet(+ForTransaction): the vouchers whose bank legs on the row's settlement account, in the row's direction, within 7 days, add up exactly to the row; linked through any of the three anchors drops a voucher, a payment row without a bank transaction keeps it. - POST match-batch refuses with BATCH_TX_POSSIBLE_DUPLICATE and returns the set; force=true must echo expected_journal_entry_ids (same binding as the single door). Fails open on a detection error. - GET duplicate-payment-check returns candidate_set next to candidate. - MatchAllocationDialog: pre-flight panel with the vouchers, one click links the row to them through the existing 1:1 or 1:N bank link (no new voucher), "Bokfor anda" acknowledges the set; confirm is disabled until then. Invoices dated after the bank row get a hint badge. - Mark-paid guard: aggregate sweep (row = this invoice + an exact subset of other open invoices, 7 days, kronor) when the name sweeps found nothing; PaymentBookingDialog shows the covered invoice numbers and points to the split under Transaktioner. Follow-ups: #2293 (1:N proposals in the auto-matcher), #2294 (MCP staging guard), #2299 (supplier-side text guard). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NyjeEi1U8vnuPT4QXgayXu * test(invoices): account for the aggregate sweep in the mark-paid route queue The sweep issues one more transactions query whenever the name probes come back empty, so every queued-mock sequence that reaches it gains a slot. The sweep itself now fails open on odd client shapes (a single object for a list query) and on errors: an advisory guard must never block "Markera som betald". Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NyjeEi1U8vnuPT4QXgayXu * fix(payments): fail open on resolved query errors; aggregate sweep without a payer name Review follow-ups on #2300. A PostgREST failure resolves with { data: null, error } instead of throwing, so the set detector read a failed link lookup as "no links" and a failed cash-account lookup as "scan every 19xx account"; both now return null (the booking RPC keeps the last word). The aggregate sweep never needed a customer name (a Bankgirot row names nobody), so a nameless invoice goes straight to it instead of skipping the guard. The already-booked panel is announced as a live region, and the "also covers" string is plural-aware. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NyjeEi1U8vnuPT4QXgayXu --------- 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
9418de585f
commit
287828a850
@@ -2096,8 +2096,27 @@ export const MatchBatchSchema = z
|
||||
// (PR #603 compliance review, OWASP V4.2). Domain-appropriate ceiling:
|
||||
// a real samlingsverifikat rarely covers more than a few dozen invoices.
|
||||
.max(100, 'At most 100 allocations per batch'),
|
||||
// Bypass the already-explained guard (BATCH_TX_POSSIBLE_DUPLICATE): the
|
||||
// bank row is fully covered by one or more posted, unlinked vouchers on
|
||||
// its settlement account (an invoice marked paid by hand, a salary
|
||||
// voucher per employee). Set only after the user has seen those vouchers
|
||||
// and decided the row is a separate event.
|
||||
force: z.boolean().optional(),
|
||||
// Required whenever force=true: the journal_entry_ids of the set the
|
||||
// user reviewed. The route re-detects the set and refuses force unless
|
||||
// the ids match, so an automation cannot sweep through force=true
|
||||
// without ever consulting the vouchers (same binding as
|
||||
// MatchInvoiceSchema.expected_journal_entry_id).
|
||||
expected_journal_entry_ids: z.array(uuid).max(10).optional(),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.force && !(data.expected_journal_entry_ids?.length)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['expected_journal_entry_ids'],
|
||||
message: 'expected_journal_entry_ids is required when force=true',
|
||||
})
|
||||
}
|
||||
// Reject mixed customer + supplier in a single batch: semantically a
|
||||
// single bank transfer settles invoices on one side. The RPC also guards
|
||||
// this with BATCH_MIXED_KINDS_UNSUPPORTED, but rejecting at the schema
|
||||
|
||||
@@ -3644,6 +3644,13 @@ const MATCH_BATCH: Record<string, StructuredErrorEntry> = {
|
||||
message_en:
|
||||
'Transaction is already booked. Reverse the existing journal entry before re-allocating.',
|
||||
},
|
||||
BATCH_TX_POSSIBLE_DUPLICATE: {
|
||||
httpStatus: 409,
|
||||
message_sv:
|
||||
'Transaktionen ser redan ut att vara bokförd: en eller flera verifikationer utan bankkoppling summerar exakt till beloppet. Koppla transaktionen till dem i stället, eller bokför ändå om de inte hör ihop.',
|
||||
message_en:
|
||||
'The transaction already looks booked: one or more posted vouchers with no bank link add up exactly to its amount. Link the transaction to them instead, or pass force=true with expected_journal_entry_ids to book anyway.',
|
||||
},
|
||||
BATCH_TX_ZERO_AMOUNT: {
|
||||
httpStatus: 400,
|
||||
message_sv: 'Transaktioner med beloppet 0 kan inte bokföras.',
|
||||
|
||||
@@ -234,8 +234,25 @@ describe('findDuplicatePaymentCandidatesForInvoice', () => {
|
||||
expect(warn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns nothing when the invoice has no customer name', async () => {
|
||||
const { supabase, queries } = createRecordingSupabase([])
|
||||
it('runs the aggregate sweep for a nameless SEK invoice: a Bankgirot row names nobody anyway', async () => {
|
||||
const { supabase, queries } = createRecordingSupabase([
|
||||
[{ id: 'tx-bg', date: '2026-07-31', amount: 88250, description: 'BGGIRERING 03447786', merchant_name: null, reference: null }],
|
||||
[{ id: 'inv-064', invoice_number: '064', remaining_amount: 25750, total: 25750, due_date: '2026-07-31' }],
|
||||
])
|
||||
const candidates = await findDuplicatePaymentCandidatesForInvoice(supabase, {
|
||||
companyId: 'company-1',
|
||||
invoice: { ...sekInvoice, invoice_number: '063', customer_name: null, total: 62500, total_sek: 62500 },
|
||||
paymentAmount: 62500,
|
||||
paymentDate: '2026-07-31',
|
||||
})
|
||||
// No name sweeps 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 () => {
|
||||
const { supabase, queries } = createRecordingSupabase([[]])
|
||||
const candidates = await findDuplicatePaymentCandidatesForInvoice(supabase, {
|
||||
companyId: 'company-1',
|
||||
invoice: { ...sekInvoice, customer_name: null },
|
||||
@@ -243,6 +260,120 @@ describe('findDuplicatePaymentCandidatesForInvoice', () => {
|
||||
paymentDate: '2026-05-10',
|
||||
})
|
||||
expect(candidates).toEqual([])
|
||||
expect(queries).toHaveLength(0)
|
||||
// No ILIKE probe without a name; the aggregate row sweep found nothing and stopped.
|
||||
expect(queries).toHaveLength(1)
|
||||
expect(queries[0].ilike).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('findDuplicatePaymentCandidatesForInvoice: Bankgirot aggregate rows', () => {
|
||||
const invoice063 = { ...sekInvoice, invoice_number: '063', customer_name: 'Twelve Football AB', total: 62500, total_sek: 62500 }
|
||||
|
||||
function aggregateRow(over: Partial<Record<string, unknown>> = {}) {
|
||||
return {
|
||||
id: 'tx-bg',
|
||||
date: '2026-07-31',
|
||||
amount: 88250,
|
||||
description: 'BGGIRERING 03447786',
|
||||
merchant_name: null,
|
||||
reference: null,
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
it('offers the aggregate row whose excess is exactly another open invoice', async () => {
|
||||
// Name sweeps find nothing ("BGGIRERING" carries no payer), then the
|
||||
// aggregate sweep: 88 250 - 62 500 = 25 750 = invoice 064's remaining.
|
||||
const { supabase, queries } = createRecordingSupabase([
|
||||
[],
|
||||
[],
|
||||
[aggregateRow()],
|
||||
[
|
||||
{ id: 'inv-064', invoice_number: '064', remaining_amount: 25750, total: 25750, due_date: '2026-07-31' },
|
||||
{ id: 'inv-065', invoice_number: '065', remaining_amount: 25750, total: 25750, due_date: '2026-09-30' },
|
||||
{ id: 'inv-070', invoice_number: '070', remaining_amount: 999, total: 999, due_date: '2026-08-15' },
|
||||
],
|
||||
])
|
||||
|
||||
const candidates = await findDuplicatePaymentCandidatesForInvoice(supabase, {
|
||||
companyId: 'company-1',
|
||||
invoice: invoice063,
|
||||
paymentAmount: 62500,
|
||||
paymentDate: '2026-07-31',
|
||||
})
|
||||
|
||||
expect(queries).toHaveLength(4)
|
||||
// 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'])
|
||||
// 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(candidates).toHaveLength(1)
|
||||
expect(candidates[0]).toMatchObject({
|
||||
id: 'tx-bg',
|
||||
amount: 88250,
|
||||
match_reason: 'aggregate_exact',
|
||||
match_confidence: 0.9,
|
||||
})
|
||||
// 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 candidates = await findDuplicatePaymentCandidatesForInvoice(supabase, {
|
||||
companyId: 'company-1',
|
||||
invoice: sekInvoice,
|
||||
paymentAmount: 12500,
|
||||
paymentDate: '2026-05-10',
|
||||
})
|
||||
expect(queries).toHaveLength(2)
|
||||
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' }],
|
||||
])
|
||||
const candidates = await findDuplicatePaymentCandidatesForInvoice(supabase, {
|
||||
companyId: 'company-1',
|
||||
invoice: invoice063,
|
||||
paymentAmount: 62500,
|
||||
paymentDate: '2026-07-31',
|
||||
})
|
||||
expect(candidates).toEqual([])
|
||||
})
|
||||
|
||||
it('stops after the row sweep when no larger unbooked row exists', async () => {
|
||||
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(candidates).toEqual([])
|
||||
})
|
||||
|
||||
it('never runs for a foreign-currency invoice', async () => {
|
||||
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)
|
||||
expect(candidates).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { detectDuplicatePaymentVoucher } from '../duplicate-payment-detection'
|
||||
import {
|
||||
detectDuplicatePaymentVoucher,
|
||||
detectExplainingVoucherSet,
|
||||
detectExplainingVoucherSetForTransaction,
|
||||
} from '../duplicate-payment-detection'
|
||||
import { createQueuedMockSupabase } from '@/tests/helpers'
|
||||
|
||||
const { supabase, enqueue, reset } = createQueuedMockSupabase()
|
||||
@@ -581,3 +585,272 @@ describe('detectDuplicatePaymentVoucher', () => {
|
||||
expect(result!.amount_verified).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('detectExplainingVoucherSet', () => {
|
||||
beforeEach(() => {
|
||||
reset()
|
||||
})
|
||||
|
||||
type SetLine = {
|
||||
account_number: string
|
||||
debit_amount: number
|
||||
credit_amount: number
|
||||
journal_entry: {
|
||||
id: string
|
||||
entry_date: string
|
||||
description: string | null
|
||||
voucher_series: string
|
||||
voucher_number: number
|
||||
status: string
|
||||
source_type: string | null
|
||||
company_id: string
|
||||
}
|
||||
}
|
||||
|
||||
function leg(opts: {
|
||||
je_id: string
|
||||
date: string
|
||||
debit?: number
|
||||
credit?: number
|
||||
account?: string
|
||||
label?: string
|
||||
source_type?: string | null
|
||||
description?: string
|
||||
}): SetLine {
|
||||
const label = opts.label ?? 'A1'
|
||||
return {
|
||||
account_number: opts.account ?? '1930',
|
||||
debit_amount: opts.debit ?? 0,
|
||||
credit_amount: opts.credit ?? 0,
|
||||
journal_entry: {
|
||||
id: opts.je_id,
|
||||
entry_date: opts.date,
|
||||
description: opts.description ?? `Voucher ${opts.je_id}`,
|
||||
voucher_series: label[0],
|
||||
voucher_number: parseInt(label.slice(1), 10) || 1,
|
||||
status: 'posted',
|
||||
source_type: opts.source_type === undefined ? 'invoice_paid' : opts.source_type,
|
||||
company_id: 'company-1',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** entries page, lines page, then the four link lookups (all empty unless given). */
|
||||
function enqueueScan(
|
||||
rows: SetLine[],
|
||||
links: {
|
||||
invoicePayments?: unknown[]
|
||||
supplierPayments?: unknown[]
|
||||
transactions?: unknown[]
|
||||
junction?: unknown[]
|
||||
} = {},
|
||||
) {
|
||||
const entries = [...new Map(rows.map((r) => [r.journal_entry.id, r.journal_entry])).values()]
|
||||
enqueue({ data: entries, error: null })
|
||||
if (entries.length === 0) return
|
||||
enqueue({
|
||||
data: rows.map((r, i) => ({
|
||||
id: `line-${i}`,
|
||||
journal_entry_id: r.journal_entry.id,
|
||||
account_number: r.account_number,
|
||||
debit_amount: r.debit_amount,
|
||||
credit_amount: r.credit_amount,
|
||||
})),
|
||||
error: null,
|
||||
})
|
||||
enqueue({ data: links.invoicePayments ?? [], error: null })
|
||||
enqueue({ data: links.supplierPayments ?? [], error: null })
|
||||
enqueue({ data: links.transactions ?? [], error: null })
|
||||
enqueue({ data: links.junction ?? [], error: null })
|
||||
}
|
||||
|
||||
const baseArgs = {
|
||||
companyId: 'company-1',
|
||||
transactionId: 'tx-bg',
|
||||
transactionDate: '2026-07-31',
|
||||
transactionCurrency: 'SEK',
|
||||
}
|
||||
|
||||
it('explains a Bankgirot aggregate with the two mark-paid vouchers that sum to it', async () => {
|
||||
// The reported case: 063 and 064 marked paid by hand (A57 + A58), then one
|
||||
// 88 250 "BGGIRERING" row. Their payment rows carry no bank transaction.
|
||||
enqueueScan(
|
||||
[
|
||||
leg({ je_id: 'A57', date: '2026-07-31', debit: 62500, label: 'A57', description: 'Inbetalning kundfaktura 063' }),
|
||||
leg({ je_id: 'A58', date: '2026-07-31', debit: 25750, label: 'A58', description: 'Inbetalning kundfaktura 064' }),
|
||||
leg({ je_id: 'A56', date: '2026-07-20', debit: 150, label: 'A56', source_type: 'bank_transaction' }),
|
||||
],
|
||||
{
|
||||
invoicePayments: [
|
||||
{ journal_entry_id: 'A57', transaction_id: null },
|
||||
{ journal_entry_id: 'A58', transaction_id: null },
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
const set = await detectExplainingVoucherSet(supabase as never, {
|
||||
...baseArgs,
|
||||
transactionAmount: 88250,
|
||||
bankAccountNumber: '1930',
|
||||
})
|
||||
|
||||
expect(set).not.toBeNull()
|
||||
expect(set!.vouchers.map((v) => v.journal_entry_id).sort()).toEqual(['A57', 'A58'])
|
||||
expect(set!.total).toBe(88250)
|
||||
expect(set!.bank_account_number).toBe('1930')
|
||||
expect(set!.same_date).toBe(true)
|
||||
expect(set!.vouchers[0].voucher_label).toBe('A57')
|
||||
})
|
||||
|
||||
it('scopes the scan to the row settlement account and the row direction', async () => {
|
||||
const callsBefore = supabase.from.mock.calls.length
|
||||
enqueueScan([leg({ je_id: 'je-1', date: '2026-07-31', credit: 1185 })])
|
||||
|
||||
const set = await detectExplainingVoucherSet(supabase as never, {
|
||||
...baseArgs,
|
||||
transactionAmount: -1185,
|
||||
bankAccountNumber: '1930',
|
||||
})
|
||||
|
||||
const tables = supabase.from.mock.calls.slice(callsBefore).map((c) => c[0])
|
||||
expect(tables.slice(0, 2)).toEqual(['journal_entries', 'journal_entry_lines'])
|
||||
// Money out: the credit leg is the voucher's bank line.
|
||||
expect(set?.vouchers.map((v) => v.amount)).toEqual([1185])
|
||||
})
|
||||
|
||||
it('returns null when no set of at most four vouchers sums exactly to the row', async () => {
|
||||
enqueueScan([
|
||||
leg({ je_id: 'a', date: '2026-07-31', debit: 62500 }),
|
||||
leg({ je_id: 'b', date: '2026-07-31', debit: 25000 }),
|
||||
])
|
||||
const set = await detectExplainingVoucherSet(supabase as never, {
|
||||
...baseArgs,
|
||||
transactionAmount: 88250,
|
||||
})
|
||||
expect(set).toBeNull()
|
||||
})
|
||||
|
||||
it('drops vouchers a bank transaction already explains through any anchor', async () => {
|
||||
enqueueScan(
|
||||
[
|
||||
leg({ je_id: 'via-tx', date: '2026-07-31', debit: 100 }),
|
||||
leg({ je_id: 'via-payment', date: '2026-07-31', debit: 100 }),
|
||||
leg({ je_id: 'via-supplier-payment', date: '2026-07-31', debit: 100 }),
|
||||
leg({ je_id: 'via-junction', date: '2026-07-31', debit: 100 }),
|
||||
leg({ je_id: 'free', date: '2026-07-31', debit: 100 }),
|
||||
],
|
||||
{
|
||||
invoicePayments: [{ journal_entry_id: 'via-payment', transaction_id: 'tx-other' }],
|
||||
supplierPayments: [{ journal_entry_id: 'via-supplier-payment', transaction_id: 'tx-other' }],
|
||||
transactions: [{ id: 'tx-other', journal_entry_id: 'via-tx' }],
|
||||
junction: [{ journal_entry_id: 'via-junction' }],
|
||||
},
|
||||
)
|
||||
const set = await detectExplainingVoucherSet(supabase as never, {
|
||||
...baseArgs,
|
||||
transactionAmount: 100,
|
||||
})
|
||||
expect(set?.vouchers.map((v) => v.journal_entry_id)).toEqual(['free'])
|
||||
})
|
||||
|
||||
it('never sums storno, correction or opening-balance entries', async () => {
|
||||
enqueueScan([
|
||||
leg({ je_id: 'storno', date: '2026-07-31', debit: 500, source_type: 'storno' }),
|
||||
leg({ je_id: 'corr', date: '2026-07-31', debit: 300, source_type: 'correction' }),
|
||||
leg({ je_id: 'ib', date: '2026-07-31', debit: 200, source_type: 'opening_balance' }),
|
||||
])
|
||||
const set = await detectExplainingVoucherSet(supabase as never, {
|
||||
...baseArgs,
|
||||
transactionAmount: 1000,
|
||||
})
|
||||
expect(set).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null without scanning when the row cannot be stated in SEK', async () => {
|
||||
const callsBefore = supabase.from.mock.calls.length
|
||||
const set = await detectExplainingVoucherSet(supabase as never, {
|
||||
...baseArgs,
|
||||
transactionAmount: 1000,
|
||||
transactionCurrency: 'EUR',
|
||||
transactionAmountSek: null,
|
||||
transactionExchangeRate: null,
|
||||
})
|
||||
expect(set).toBeNull()
|
||||
expect(supabase.from.mock.calls.length).toBe(callsBefore)
|
||||
})
|
||||
|
||||
it('states a foreign row in SEK before summing', async () => {
|
||||
enqueueScan([leg({ je_id: 'je-eur', date: '2026-07-31', debit: 11500 })])
|
||||
const set = await detectExplainingVoucherSet(supabase as never, {
|
||||
...baseArgs,
|
||||
transactionAmount: 1000,
|
||||
transactionCurrency: 'EUR',
|
||||
transactionAmountSek: 11500,
|
||||
})
|
||||
expect(set?.vouchers.map((v) => v.journal_entry_id)).toEqual(['je-eur'])
|
||||
expect(set?.total).toBe(11500)
|
||||
})
|
||||
|
||||
it('fails open (null) when a link lookup resolves with an error instead of throwing', async () => {
|
||||
const entries = [leg({ je_id: 'je-1', date: '2026-07-31', debit: 1000 })]
|
||||
enqueue({ data: entries.map((r) => r.journal_entry), error: null })
|
||||
enqueue({
|
||||
data: entries.map((r, i) => ({ id: `line-${i}`, journal_entry_id: r.journal_entry.id, account_number: '1930', debit_amount: 1000, credit_amount: 0 })),
|
||||
error: null,
|
||||
})
|
||||
enqueue({ data: [], error: null })
|
||||
enqueue({ data: [], error: null })
|
||||
// transactions lookup fails: PostgREST resolves, it does not throw.
|
||||
enqueue({ data: null, error: { message: 'permission denied' } })
|
||||
enqueue({ data: [], error: null })
|
||||
const set = await detectExplainingVoucherSet(supabase as never, {
|
||||
...baseArgs,
|
||||
transactionAmount: 1000,
|
||||
})
|
||||
expect(set).toBeNull()
|
||||
})
|
||||
|
||||
it('fails open (null) when the ledger scan throws', async () => {
|
||||
enqueue({ data: null, error: { message: 'boom' } })
|
||||
const set = await detectExplainingVoucherSet(supabase as never, {
|
||||
...baseArgs,
|
||||
transactionAmount: 1000,
|
||||
})
|
||||
expect(set).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('detectExplainingVoucherSetForTransaction', () => {
|
||||
beforeEach(() => {
|
||||
reset()
|
||||
})
|
||||
|
||||
it('returns null for a row that already carries a pointer, without scanning', async () => {
|
||||
enqueue({ data: { id: 'tx-1', date: '2026-07-31', amount: 100, currency: 'SEK', journal_entry_id: 'je-live' }, error: null })
|
||||
const callsBefore = supabase.from.mock.calls.length
|
||||
const set = await detectExplainingVoucherSetForTransaction(supabase as never, 'company-1', 'tx-1')
|
||||
expect(set).toBeNull()
|
||||
expect(supabase.from.mock.calls.length - callsBefore).toBe(1)
|
||||
})
|
||||
|
||||
it('fails open when the cash-account lookup errors instead of widening the scan', async () => {
|
||||
const callsBefore = supabase.from.mock.calls.length
|
||||
enqueue({ data: { id: 'tx-1', date: '2026-07-31', amount: 100, currency: 'SEK', cash_account_id: 'ca-1', journal_entry_id: null }, error: null })
|
||||
enqueue({ data: null, error: { message: 'boom' } })
|
||||
const set = await detectExplainingVoucherSetForTransaction(supabase as never, 'company-1', 'tx-1')
|
||||
expect(set).toBeNull()
|
||||
expect(supabase.from.mock.calls.length - callsBefore).toBe(2)
|
||||
})
|
||||
|
||||
it('resolves the settlement account from the cash account before scanning', async () => {
|
||||
const callsBefore = supabase.from.mock.calls.length
|
||||
enqueue({ data: { id: 'tx-1', date: '2026-07-31', amount: 100, currency: 'SEK', cash_account_id: 'ca-1', journal_entry_id: null }, error: null })
|
||||
enqueue({ data: { ledger_account: '1940' }, error: null })
|
||||
// entries page: nothing on the account, scan ends.
|
||||
enqueue({ data: [], error: null })
|
||||
const set = await detectExplainingVoucherSetForTransaction(supabase as never, 'company-1', 'tx-1')
|
||||
expect(set).toBeNull()
|
||||
const tables = supabase.from.mock.calls.slice(callsBefore).map((c) => c[0])
|
||||
expect(tables).toEqual(['transactions', 'cash_accounts', 'journal_entries'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
type ComparableAmount,
|
||||
} from './duplicate-guard-currency'
|
||||
import { resolveTransactionAmountSek } from '@/lib/transactions/booking-duplicate-detection'
|
||||
import { findExactCoveringSet } from '@/lib/reconciliation/covering-set'
|
||||
import { roundOre } from '@/lib/money'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
|
||||
const log = createLogger('invoices/duplicate-payment-candidates')
|
||||
@@ -21,6 +23,13 @@ export type DuplicatePaymentMatchReason =
|
||||
| 'ocr_exact'
|
||||
| 'name_amount_fuzzy'
|
||||
| 'amount_only'
|
||||
/**
|
||||
* The bank row is larger than this payment and the difference is exactly
|
||||
* (to the öre) the remaining amount of one to three OTHER open invoices:
|
||||
* a Bankgirot daily aggregate that settled this invoice together with
|
||||
* them. No counterparty text is consulted; such rows carry none.
|
||||
*/
|
||||
| 'aggregate_exact'
|
||||
|
||||
export interface DuplicatePaymentCandidate {
|
||||
id: string
|
||||
@@ -31,20 +40,33 @@ export interface DuplicatePaymentCandidate {
|
||||
reference: string | null
|
||||
match_reason: DuplicatePaymentMatchReason
|
||||
match_confidence: number
|
||||
/** For aggregate_exact: the other open invoices the row also covers. */
|
||||
aggregate_invoice_numbers?: string[]
|
||||
}
|
||||
|
||||
const MATCH_REASON_RANK: Record<DuplicatePaymentMatchReason, number> = {
|
||||
ocr_exact: 0,
|
||||
name_amount_fuzzy: 1,
|
||||
amount_only: 2,
|
||||
aggregate_exact: 1,
|
||||
name_amount_fuzzy: 2,
|
||||
amount_only: 3,
|
||||
}
|
||||
|
||||
const MATCH_REASON_CONFIDENCE: Record<DuplicatePaymentMatchReason, number> = {
|
||||
ocr_exact: 0.99,
|
||||
aggregate_exact: 0.9,
|
||||
name_amount_fuzzy: 0.7,
|
||||
amount_only: 0.5,
|
||||
}
|
||||
|
||||
/** ± 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. */
|
||||
const AGGREGATE_DATE_WINDOW_DAYS = 7
|
||||
/** Other open invoices an aggregate row may cover besides this one. */
|
||||
const AGGREGATE_MAX_OTHER_INVOICES = 3
|
||||
const AGGREGATE_MAX_ROWS = 40
|
||||
const AGGREGATE_MAX_OPEN_INVOICES = 200
|
||||
|
||||
interface CustomerInvoice {
|
||||
invoice_number: string | null
|
||||
customer_name: string | null | undefined
|
||||
@@ -116,9 +138,14 @@ export async function findDuplicatePaymentCandidatesForInvoice(
|
||||
): Promise<DuplicatePaymentCandidate[]> {
|
||||
const { companyId, invoice, paymentAmount, paymentDate } = params
|
||||
const customerName = invoice.customer_name
|
||||
if (!customerName) return []
|
||||
|
||||
const paymentCurrency = normalizeCurrencyCode(invoice.currency)
|
||||
|
||||
// The name sweeps need 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,
|
||||
@@ -200,7 +227,14 @@ export async function findDuplicatePaymentCandidatesForInvoice(
|
||||
.sort((a, b) => (a.date < b.date ? 1 : a.date > b.date ? -1 : 0))
|
||||
.slice(0, 5)
|
||||
|
||||
if (data.length === 0) return []
|
||||
// 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 })
|
||||
}
|
||||
|
||||
const invoiceOcr = normalizeOcrReference(invoice.invoice_number)
|
||||
const searchTerms = customerName
|
||||
@@ -230,6 +264,146 @@ export async function findDuplicatePaymentCandidatesForInvoice(
|
||||
return candidates
|
||||
}
|
||||
|
||||
async function runAggregateSweep(
|
||||
supabase: SupabaseClient,
|
||||
params: {
|
||||
companyId: string
|
||||
invoice: Pick<CustomerInvoice, 'invoice_number'>
|
||||
paymentAmount: number
|
||||
paymentDate: string
|
||||
},
|
||||
): Promise<DuplicatePaymentCandidate[]> {
|
||||
const { companyId, invoice, paymentAmount, paymentDate } = params
|
||||
try {
|
||||
return await findAggregateCandidates(supabase, {
|
||||
companyId,
|
||||
invoiceNumber: invoice.invoice_number,
|
||||
paymentAmount,
|
||||
paymentDate,
|
||||
})
|
||||
} catch (err) {
|
||||
// Advisory guard: a failed sweep must never block "Markera som betald".
|
||||
// Logged so the blind spot is visible rather than passing silently.
|
||||
log.warn('duplicate-payment guard: aggregate sweep failed', {
|
||||
companyId,
|
||||
invoiceNumber: invoice.invoice_number,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
type OpenInvoiceRow = {
|
||||
id: string
|
||||
invoice_number: string | null
|
||||
remaining_amount: number | string | null
|
||||
total: number | string | null
|
||||
due_date: string | null
|
||||
}
|
||||
|
||||
type AggregateRow = Pick<Row, 'id' | 'date' | 'amount' | 'description' | 'merchant_name' | 'reference'>
|
||||
|
||||
/**
|
||||
* Unlinked inbound kronor rows around the payment date that are LARGER than
|
||||
* the payment, where the excess is exactly the remaining amount of one to
|
||||
* three other open invoices. That is what a Bankgirot daily aggregate looks
|
||||
* like from the invoice side: "BGGIRERING", no payer, one sum for two
|
||||
* customers' invoices. Same exact-sum search the bank-side guard uses
|
||||
* (lib/reconciliation/covering-set.ts), so the two doors agree on what
|
||||
* "already paid" means. The remedy is the split under Transaktioner, which
|
||||
* books ONE samlingsverifikation and links the row; marking the invoices
|
||||
* paid one by one is what books the money twice.
|
||||
*/
|
||||
async function findAggregateCandidates(
|
||||
supabase: SupabaseClient,
|
||||
params: {
|
||||
companyId: string
|
||||
invoiceNumber: string | null
|
||||
paymentAmount: number
|
||||
paymentDate: string
|
||||
},
|
||||
): Promise<DuplicatePaymentCandidate[]> {
|
||||
const { companyId, invoiceNumber, paymentAmount, paymentDate } = params
|
||||
const payment = roundOre(paymentAmount)
|
||||
if (!(payment > 0)) return []
|
||||
const dateMs = new Date(paymentDate).getTime()
|
||||
if (Number.isNaN(dateMs)) return []
|
||||
const dayMs = 24 * 3600 * 1000
|
||||
const dateLow = new Date(dateMs - AGGREGATE_DATE_WINDOW_DAYS * dayMs).toISOString().split('T')[0]
|
||||
const dateHigh = new Date(dateMs + AGGREGATE_DATE_WINDOW_DAYS * dayMs).toISOString().split('T')[0]
|
||||
|
||||
const { data: rowsData } = await supabase
|
||||
.from('transactions')
|
||||
.select('id, date, amount, description, merchant_name, reference')
|
||||
.eq('company_id', companyId)
|
||||
.eq('is_business', true)
|
||||
.is('invoice_id', null)
|
||||
.is('supplier_invoice_id', null)
|
||||
.is('journal_entry_id', null)
|
||||
.or('currency.is.null,currency.eq.SEK')
|
||||
.gt('amount', payment)
|
||||
.gte('date', dateLow)
|
||||
.lte('date', dateHigh)
|
||||
.order('date', { ascending: false })
|
||||
.limit(AGGREGATE_MAX_ROWS)
|
||||
// Defensive shape check: a client that answers a list query with a single
|
||||
// object (older test doubles do) must read as "no rows", not throw.
|
||||
const rows = (Array.isArray(rowsData) ? rowsData : []) as AggregateRow[]
|
||||
if (rows.length === 0) return []
|
||||
|
||||
let othersQuery = supabase
|
||||
.from('invoices')
|
||||
.select('id, invoice_number, remaining_amount, total, due_date')
|
||||
.eq('company_id', companyId)
|
||||
.eq('document_type', 'invoice')
|
||||
.is('credited_invoice_id', null)
|
||||
.in('status', ['sent', 'overdue', 'partially_paid'])
|
||||
.gt('remaining_amount', 0)
|
||||
.or('currency.is.null,currency.eq.SEK')
|
||||
if (invoiceNumber) othersQuery = othersQuery.neq('invoice_number', invoiceNumber)
|
||||
const { data: othersData } = await othersQuery
|
||||
.order('due_date', { ascending: true })
|
||||
.limit(AGGREGATE_MAX_OPEN_INVOICES)
|
||||
const others = ((Array.isArray(othersData) ? othersData : []) as OpenInvoiceRow[]).filter(
|
||||
(inv) => inv.invoice_number && Number(inv.remaining_amount ?? inv.total ?? 0) > 0,
|
||||
)
|
||||
if (others.length === 0) return []
|
||||
|
||||
const candidates: DuplicatePaymentCandidate[] = []
|
||||
for (const row of rows) {
|
||||
const residual = roundOre(Number(row.amount) - payment)
|
||||
if (!(residual > 0)) continue
|
||||
const rowMs = new Date(row.date).getTime()
|
||||
const set = findExactCoveringSet(
|
||||
residual,
|
||||
others.map((inv) => ({
|
||||
id: inv.id,
|
||||
amount: Number(inv.remaining_amount ?? inv.total ?? 0),
|
||||
dateDistanceDays:
|
||||
inv.due_date && !Number.isNaN(rowMs)
|
||||
? Math.round(Math.abs(new Date(inv.due_date).getTime() - rowMs) / dayMs)
|
||||
: AGGREGATE_DATE_WINDOW_DAYS,
|
||||
invoiceNumber: inv.invoice_number as string,
|
||||
})),
|
||||
{ maxSize: AGGREGATE_MAX_OTHER_INVOICES },
|
||||
)
|
||||
if (!set) continue
|
||||
candidates.push({
|
||||
id: row.id,
|
||||
date: row.date,
|
||||
amount: Number(row.amount),
|
||||
description: row.description,
|
||||
merchant_name: row.merchant_name,
|
||||
reference: row.reference,
|
||||
match_reason: 'aggregate_exact',
|
||||
match_confidence: MATCH_REASON_CONFIDENCE.aggregate_exact,
|
||||
aggregate_invoice_numbers: set.map((s) => s.invoiceNumber),
|
||||
})
|
||||
if (candidates.length >= 5) break
|
||||
}
|
||||
return candidates
|
||||
}
|
||||
|
||||
/**
|
||||
* A bank row as a comparable amount. `resolveTransactionAmountSek` is the one
|
||||
* definition of "this bank line in kronor" (shared with the booking-time
|
||||
|
||||
@@ -26,6 +26,8 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { fetchEntryLines, type EntryLinesQuery } from '@/lib/bookkeeping/entry-lines'
|
||||
import { resolveTransactionAmountSek } from '@/lib/transactions/booking-duplicate-detection'
|
||||
import { findExactCoveringSet } from '@/lib/reconciliation/covering-set'
|
||||
import { roundOre } from '@/lib/money'
|
||||
|
||||
/** ± days around the transaction date considered "the same payment". */
|
||||
const DATE_WINDOW_DAYS = 7
|
||||
@@ -274,3 +276,329 @@ export async function detectDuplicatePaymentVoucher(
|
||||
unverified_reason: targetSek === null ? 'transaction_missing_sek_value' : null,
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Explaining voucher SET: one bank row, one or several vouchers
|
||||
// ============================================================
|
||||
|
||||
/** ± days around the bank row considered "the same payment" for a set. */
|
||||
const SET_DATE_WINDOW_DAYS = 7
|
||||
|
||||
/** Largest set of vouchers offered as the explanation of one bank row. */
|
||||
export const EXPLAINING_SET_MAX_VOUCHERS = 4
|
||||
|
||||
export interface ExplainingVoucher {
|
||||
journal_entry_id: string
|
||||
voucher_label: string
|
||||
entry_date: string
|
||||
description: string | null
|
||||
source_type: string | null
|
||||
/** The voucher's bank leg in SEK, positive, in the bank row's direction. */
|
||||
amount: number
|
||||
bank_account_number: string
|
||||
}
|
||||
|
||||
export interface ExplainingVoucherSet {
|
||||
/** One to EXPLAINING_SET_MAX_VOUCHERS vouchers, closest in date first. */
|
||||
vouchers: ExplainingVoucher[]
|
||||
/** SEK sum of the legs: equals the bank row stated in SEK, to the öre. */
|
||||
total: number
|
||||
bank_account_number: string
|
||||
/** True when every voucher is dated on the bank row's date. */
|
||||
same_date: boolean
|
||||
}
|
||||
|
||||
export interface DetectSetArgs extends DetectArgs {
|
||||
/**
|
||||
* The settlement account the bank row belongs to (cash_accounts.ledger_account)
|
||||
* when known. Narrows the scan to that account, so a 1940 leg can never be
|
||||
* summed into a 1930 row. Null or omitted scans the whole 19xx range, the
|
||||
* legacy shape for rows with no resolvable cash account.
|
||||
*/
|
||||
bankAccountNumber?: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the vouchers that already book this bank row, allowing the row to be
|
||||
* explained by SEVERAL of them.
|
||||
*
|
||||
* The 1:1 detector above answers "is there one voucher of this amount?". A
|
||||
* bank feed regularly delivers one row for several affärshändelser (a
|
||||
* Bankgirot daily aggregate: two customers' invoices, one "BGGIRERING" row
|
||||
* with no payer and no reference), and each of those may already be booked on
|
||||
* its own: "Markera som betald" per invoice, one salary voucher per employee.
|
||||
* Nothing on the account then equals the row, the 1:1 check passes, and the
|
||||
* next door (a batch allocation, a fresh categorisation) books the same
|
||||
* money a second time. That is exactly the double booking this catches.
|
||||
*
|
||||
* Deterministic on purpose: the only signal is an exact öre sum of unlinked
|
||||
* bank legs in the row's direction, on the row's account, within ±7 days.
|
||||
* No counterparty text is consulted: the bank rows this exists for carry
|
||||
* none. A voucher counts as linked (and drops out) when a transaction points
|
||||
* at it, a payment row with a bank transaction references it, or a
|
||||
* transaction_voucher_links row anchors it: the same three storage
|
||||
* locations isTransactionBooked reads, seen from the voucher side. A payment
|
||||
* row WITHOUT a bank transaction is a manual settlement (#2019) and keeps the
|
||||
* voucher in play: its bank line is precisely what has not been matched yet.
|
||||
*
|
||||
* Returns null when the row cannot be stated in SEK (a foreign row with no
|
||||
* stored rate): a set cannot be summed in an unknown unit, and the 1:1
|
||||
* detector's `amount_verified: false` path already surfaces that case.
|
||||
*/
|
||||
export async function detectExplainingVoucherSet(
|
||||
supabase: SupabaseClient,
|
||||
args: DetectSetArgs,
|
||||
): Promise<ExplainingVoucherSet | null> {
|
||||
const { companyId, transactionId, transactionDate, transactionAmount } = args
|
||||
if (Math.round(Math.abs(transactionAmount) * 100) === 0) return null
|
||||
|
||||
const signedSek = resolveTransactionAmountSek({
|
||||
amount: transactionAmount,
|
||||
currency: args.transactionCurrency,
|
||||
amount_sek: args.transactionAmountSek,
|
||||
exchange_rate: args.transactionExchangeRate,
|
||||
})
|
||||
if (signedSek === null) return null
|
||||
const targetSek = roundOre(Math.abs(signedSek))
|
||||
if (targetSek === 0) return null
|
||||
|
||||
const dateMs = new Date(transactionDate).getTime()
|
||||
if (Number.isNaN(dateMs)) return null
|
||||
const lowDate = new Date(dateMs - SET_DATE_WINDOW_DAYS * 24 * 3600 * 1000)
|
||||
.toISOString()
|
||||
.split('T')[0]
|
||||
const highDate = new Date(dateMs + SET_DATE_WINDOW_DAYS * 24 * 3600 * 1000)
|
||||
.toISOString()
|
||||
.split('T')[0]
|
||||
|
||||
// Money in: the voucher DEBITS the bank account. Money out: it CREDITS it.
|
||||
const inbound = transactionAmount > 0
|
||||
const account = args.bankAccountNumber?.trim() || null
|
||||
|
||||
type SetLineRow = {
|
||||
account_number: string
|
||||
debit_amount: number | string | null
|
||||
credit_amount: number | string | null
|
||||
journal_entry: {
|
||||
id: string
|
||||
entry_date: string
|
||||
description: string | null
|
||||
voucher_series: string | null
|
||||
voucher_number: number | null
|
||||
status: string
|
||||
source_type: string | null
|
||||
}
|
||||
}
|
||||
|
||||
let lines: SetLineRow[]
|
||||
try {
|
||||
lines = await fetchEntryLines<SetLineRow>({
|
||||
supabase,
|
||||
entryColumns:
|
||||
'id, entry_date, description, voucher_series, voucher_number, status, source_type, company_id',
|
||||
lineColumns: 'account_number, debit_amount, credit_amount',
|
||||
filterEntries: (q: EntryLinesQuery) =>
|
||||
q
|
||||
.eq('company_id', companyId)
|
||||
.eq('status', 'posted')
|
||||
.gte('entry_date', lowDate)
|
||||
.lte('entry_date', highDate),
|
||||
filterLines: (q: EntryLinesQuery) => {
|
||||
const scoped = account
|
||||
? q.eq('account_number', account)
|
||||
: q.gte('account_number', String(BANK_ACCOUNT_LOW)).lte('account_number', String(BANK_ACCOUNT_HIGH))
|
||||
return inbound ? scoped.gt('debit_amount', 0) : scoped.gt('credit_amount', 0)
|
||||
},
|
||||
attachEntriesAs: 'journal_entry',
|
||||
})
|
||||
} catch {
|
||||
// Fail-open like the 1:1 detector: a detection failure must not block a
|
||||
// booking. Callers log the miss.
|
||||
return null
|
||||
}
|
||||
if (lines.length === 0) return null
|
||||
|
||||
// Reversals, corrections and opening balances are bookkeeping scaffolding,
|
||||
// never the payment itself (the reconciliation RPCs drop the same three).
|
||||
const legs = lines.filter(
|
||||
(l) =>
|
||||
l.journal_entry.source_type !== 'storno' &&
|
||||
l.journal_entry.source_type !== 'correction' &&
|
||||
l.journal_entry.source_type !== 'opening_balance',
|
||||
)
|
||||
if (legs.length === 0) return null
|
||||
|
||||
// One candidate per voucher and account: a voucher with two legs on the
|
||||
// same account (a split payment line) is summed, a voucher touching two
|
||||
// bank accounts (a transfer) keeps its largest leg so it can appear once.
|
||||
type Candidate = ExplainingVoucher & { dateDistanceDays: number; id: string }
|
||||
const byEntry = new Map<string, Candidate>()
|
||||
for (const leg of legs) {
|
||||
const raw = inbound ? leg.debit_amount : leg.credit_amount
|
||||
const amount = roundOre(Number(raw))
|
||||
if (!(amount > 0)) continue
|
||||
const entry = leg.journal_entry
|
||||
const existing = byEntry.get(entry.id)
|
||||
if (existing && existing.bank_account_number === leg.account_number) {
|
||||
existing.amount = roundOre(existing.amount + amount)
|
||||
continue
|
||||
}
|
||||
if (existing && existing.amount >= amount) continue
|
||||
const entryMs = new Date(entry.entry_date).getTime()
|
||||
byEntry.set(entry.id, {
|
||||
id: entry.id,
|
||||
journal_entry_id: entry.id,
|
||||
voucher_label: `${entry.voucher_series ?? 'A'}${entry.voucher_number ?? ''}`,
|
||||
entry_date: entry.entry_date,
|
||||
description: entry.description,
|
||||
source_type: entry.source_type,
|
||||
amount,
|
||||
bank_account_number: leg.account_number,
|
||||
dateDistanceDays: Number.isNaN(entryMs)
|
||||
? SET_DATE_WINDOW_DAYS
|
||||
: Math.round(Math.abs(entryMs - dateMs) / (24 * 3600 * 1000)),
|
||||
})
|
||||
}
|
||||
if (byEntry.size === 0) return null
|
||||
|
||||
// Drop vouchers a bank transaction already explains, through any of the
|
||||
// three anchors. All four lookups are company-scoped (defense in depth).
|
||||
const entryIds = Array.from(byEntry.keys())
|
||||
const [paymentLinksRes, supplierPaymentLinksRes, txLinksRes, junctionLinksRes] =
|
||||
await Promise.all([
|
||||
supabase
|
||||
.from('invoice_payments')
|
||||
.select('journal_entry_id, transaction_id')
|
||||
.eq('company_id', companyId)
|
||||
.in('journal_entry_id', entryIds),
|
||||
supabase
|
||||
.from('supplier_invoice_payments')
|
||||
.select('journal_entry_id, transaction_id')
|
||||
.eq('company_id', companyId)
|
||||
.in('journal_entry_id', entryIds),
|
||||
supabase
|
||||
.from('transactions')
|
||||
.select('id, journal_entry_id')
|
||||
.eq('company_id', companyId)
|
||||
.in('journal_entry_id', entryIds),
|
||||
supabase
|
||||
.from('transaction_voucher_links')
|
||||
.select('journal_entry_id')
|
||||
.eq('company_id', companyId)
|
||||
.in('journal_entry_id', entryIds),
|
||||
])
|
||||
// A PostgREST failure resolves with { data: null, error } rather than
|
||||
// throwing. Reading that as "no links" would offer a voucher a bank row
|
||||
// already settles, so a failed lookup fails open (null) like a thrown one:
|
||||
// the guard stays advisory and the booking RPC keeps the last word.
|
||||
if (paymentLinksRes.error || supplierPaymentLinksRes.error || txLinksRes.error || junctionLinksRes.error) {
|
||||
return null
|
||||
}
|
||||
const paymentLinks = paymentLinksRes.data
|
||||
const supplierPaymentLinks = supplierPaymentLinksRes.data
|
||||
const txLinks = txLinksRes.data
|
||||
const junctionLinks = junctionLinksRes.data
|
||||
|
||||
const linkedIds = new Set<string>()
|
||||
for (const row of [...((paymentLinks ?? []) as PaymentLinkRow[]), ...((supplierPaymentLinks ?? []) as PaymentLinkRow[])]) {
|
||||
if (row.journal_entry_id && row.transaction_id) linkedIds.add(row.journal_entry_id)
|
||||
}
|
||||
for (const row of (txLinks ?? []) as { id: string; journal_entry_id: string | null }[]) {
|
||||
// The caller's own row is never a link: the guard runs before it is linked.
|
||||
if (row.journal_entry_id && row.id !== transactionId) linkedIds.add(row.journal_entry_id)
|
||||
}
|
||||
for (const row of (junctionLinks ?? []) as { journal_entry_id: string | null }[]) {
|
||||
if (row.journal_entry_id) linkedIds.add(row.journal_entry_id)
|
||||
}
|
||||
|
||||
const pool = Array.from(byEntry.values()).filter((c) => !linkedIds.has(c.journal_entry_id))
|
||||
if (pool.length === 0) return null
|
||||
|
||||
// Sets never mix accounts: the link that resolves the warning is made on
|
||||
// one settlement account. Search per account, closest account first.
|
||||
const accounts = Array.from(new Set(pool.map((c) => c.bank_account_number))).sort()
|
||||
for (const accountNumber of accounts) {
|
||||
const set = findExactCoveringSet(
|
||||
targetSek,
|
||||
pool.filter((c) => c.bank_account_number === accountNumber),
|
||||
{ maxSize: EXPLAINING_SET_MAX_VOUCHERS },
|
||||
)
|
||||
if (!set) continue
|
||||
const vouchers = [...set]
|
||||
.sort((a, b) => a.dateDistanceDays - b.dateDistanceDays || a.entry_date.localeCompare(b.entry_date))
|
||||
.map(({ id: _id, dateDistanceDays: _distance, ...voucher }) => voucher)
|
||||
return {
|
||||
vouchers,
|
||||
total: targetSek,
|
||||
bank_account_number: accountNumber,
|
||||
same_date: vouchers.every((v) => v.entry_date === transactionDate),
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
type PaymentLinkRow = { journal_entry_id: string | null; transaction_id: string | null }
|
||||
|
||||
/** The transaction columns the set detector needs; a caller that already holds the row passes it. */
|
||||
export interface TransactionForExplaining {
|
||||
id: string
|
||||
date: string
|
||||
amount: number
|
||||
currency: string | null
|
||||
amount_sek?: number | null
|
||||
exchange_rate?: number | null
|
||||
cash_account_id?: string | null
|
||||
journal_entry_id?: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience for the routes: resolve the row's settlement account from its
|
||||
* cash account and run the set detector. Accepts the transaction id (one
|
||||
* fetch) or a row a caller already holds. A row that already carries a live
|
||||
* pointer returns null: the booking RPCs refuse it on their own terms.
|
||||
*/
|
||||
export async function detectExplainingVoucherSetForTransaction(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
transaction: string | TransactionForExplaining,
|
||||
): Promise<ExplainingVoucherSet | null> {
|
||||
let row: TransactionForExplaining | null
|
||||
if (typeof transaction === 'string') {
|
||||
const { data, error } = await supabase
|
||||
.from('transactions')
|
||||
.select('id, date, amount, currency, amount_sek, exchange_rate, cash_account_id, journal_entry_id')
|
||||
.eq('id', transaction)
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
if (error) return null
|
||||
row = (data as TransactionForExplaining | null) ?? null
|
||||
} else {
|
||||
row = transaction
|
||||
}
|
||||
if (!row || row.journal_entry_id) return null
|
||||
|
||||
let bankAccountNumber: string | null = null
|
||||
if (row.cash_account_id) {
|
||||
const { data: cashAccount, error } = await supabase
|
||||
.from('cash_accounts')
|
||||
.select('ledger_account')
|
||||
.eq('id', row.cash_account_id)
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
// Without the account the scan would widen to every 19xx account: an
|
||||
// unverified answer, so a failed lookup is a pass, not a wider guess.
|
||||
if (error) return null
|
||||
bankAccountNumber = (cashAccount?.ledger_account as string | null) ?? null
|
||||
}
|
||||
|
||||
return detectExplainingVoucherSet(supabase, {
|
||||
companyId,
|
||||
transactionId: row.id,
|
||||
transactionDate: row.date,
|
||||
transactionAmount: Number(row.amount),
|
||||
transactionCurrency: row.currency ?? null,
|
||||
transactionAmountSek: row.amount_sek ?? null,
|
||||
transactionExchangeRate: row.exchange_rate ?? null,
|
||||
bankAccountNumber,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { findExactCoveringSet } from '../covering-set'
|
||||
|
||||
function c(id: string, amount: number, dateDistanceDays = 0) {
|
||||
return { id, amount, dateDistanceDays }
|
||||
}
|
||||
|
||||
describe('findExactCoveringSet', () => {
|
||||
it('returns null for an empty list or a non-positive target', () => {
|
||||
expect(findExactCoveringSet(100, [])).toBeNull()
|
||||
expect(findExactCoveringSet(0, [c('a', 100)])).toBeNull()
|
||||
expect(findExactCoveringSet(-100, [c('a', 100)])).toBeNull()
|
||||
})
|
||||
|
||||
it('finds the single voucher of the same amount', () => {
|
||||
const set = findExactCoveringSet(1000, [c('a', 999), c('b', 1000), c('c', 1)])
|
||||
expect(set?.map((s) => s.id)).toEqual(['b'])
|
||||
})
|
||||
|
||||
it('finds the Bankgirot aggregate: two vouchers that sum to the row', () => {
|
||||
// gecko's case: 62 500 + 25 750 booked by hand, one 88 250 bank row.
|
||||
const set = findExactCoveringSet(88250, [c('A57', 62500), c('A58', 25750), c('A56', 150)])
|
||||
expect(set?.map((s) => s.id).sort()).toEqual(['A57', 'A58'])
|
||||
})
|
||||
|
||||
it('prefers the smallest set, then the one closest in date', () => {
|
||||
const one = findExactCoveringSet(1000, [c('pair-1', 400, 0), c('pair-2', 600, 0), c('single', 1000, 3)])
|
||||
expect(one?.map((s) => s.id)).toEqual(['single'])
|
||||
|
||||
const near = findExactCoveringSet(1000, [c('far', 1000, 6), c('near', 1000, 1)])
|
||||
expect(near?.map((s) => s.id)).toEqual(['near'])
|
||||
})
|
||||
|
||||
it('is exact to the öre and never reads a near miss as a match', () => {
|
||||
expect(findExactCoveringSet(1000, [c('a', 999.99)])).toBeNull()
|
||||
expect(findExactCoveringSet(1000.01, [c('a', 600), c('b', 400.01)])?.map((s) => s.id)).toEqual(['a', 'b'])
|
||||
expect(findExactCoveringSet(1000, [c('a', 600), c('b', 400.01)])).toBeNull()
|
||||
})
|
||||
|
||||
it('ignores candidates larger than the target or with no amount', () => {
|
||||
const set = findExactCoveringSet(500, [c('big', 5000), c('zero', 0), c('neg', -500), c('ok', 500)])
|
||||
expect(set?.map((s) => s.id)).toEqual(['ok'])
|
||||
})
|
||||
|
||||
it('stops at maxSize and caps the candidate pool', () => {
|
||||
const parts = [c('a', 100), c('b', 200), c('c', 300), c('d', 400)]
|
||||
expect(findExactCoveringSet(1000, parts, { maxSize: 3 })).toBeNull()
|
||||
expect(findExactCoveringSet(1000, parts, { maxSize: 4 })?.length).toBe(4)
|
||||
// Pool cap keeps only the two closest rows, so the pair cannot be formed.
|
||||
const far = [c('near-1', 100, 0), c('near-2', 200, 0), c('far-1', 700, 5)]
|
||||
expect(findExactCoveringSet(1000, far, { maxCandidates: 2 })).toBeNull()
|
||||
})
|
||||
|
||||
it('handles a busy account without blowing up', () => {
|
||||
const many = Array.from({ length: 200 }, (_, i) => c(`v${i}`, 100 + (i % 37) * 13, i % 8))
|
||||
const start = Date.now()
|
||||
const set = findExactCoveringSet(100 + 113 + 126 + 139, many)
|
||||
expect(Date.now() - start).toBeLessThan(500)
|
||||
expect(set).not.toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Exact subset sum over a short candidate list: which posted bank legs, taken
|
||||
* together, add up to one bank row to the öre.
|
||||
*
|
||||
* Why this exists: a bank feed can deliver several affärshändelser as ONE row
|
||||
* (a Bankgirot daily aggregate, a lump payout), and each of them may already
|
||||
* be booked on its own (an invoice marked paid by hand, a salary voucher per
|
||||
* employee). The 1:1 duplicate check then sees no voucher of the row's amount
|
||||
* and stays silent, while the row is fully explained by two or three vouchers
|
||||
* that carry no bank link. The exact sum is a deterministic signal that needs
|
||||
* no counterparty text, which is what bank rows like "BGGIRERING 03447786"
|
||||
* never carry.
|
||||
*
|
||||
* Pure and client-safe on purpose: the same search can rank a suggestion in
|
||||
* the reconciliation view or guard a booking route without dragging server
|
||||
* dependencies into a component.
|
||||
*
|
||||
* Search order is smallest set first (one voucher beats two), and within one
|
||||
* size the set closest in date to the bank row. The candidate list is capped
|
||||
* before the search so a busy account cannot make the combinatorics
|
||||
* unbounded: with 40 candidates and sets of at most 4 the worst case is under
|
||||
* a hundred thousand partial sums, which is well below a millisecond of work.
|
||||
*/
|
||||
|
||||
export interface CoveringCandidate {
|
||||
id: string
|
||||
/** Positive amount in the unit the target is stated in (SEK for bank legs). */
|
||||
amount: number
|
||||
/** |candidate date - bank row date| in whole days. Ranks equal-size sets. */
|
||||
dateDistanceDays: number
|
||||
}
|
||||
|
||||
export interface CoveringSetOptions {
|
||||
/** Largest set considered. Default 4. */
|
||||
maxSize?: number
|
||||
/** Candidates kept (closest in date first) before the search. Default 40. */
|
||||
maxCandidates?: number
|
||||
}
|
||||
|
||||
const DEFAULT_MAX_SIZE = 4
|
||||
const DEFAULT_MAX_CANDIDATES = 40
|
||||
|
||||
function toOre(amount: number): number {
|
||||
return Math.round(amount * 100)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the best set of candidates whose amounts sum exactly to `target`
|
||||
* (to the öre), or null when no set of at most `maxSize` candidates does.
|
||||
* Candidates with a non-positive amount never take part; the target must be
|
||||
* positive (callers pass the absolute value of the bank row).
|
||||
*/
|
||||
export function findExactCoveringSet<T extends CoveringCandidate>(
|
||||
target: number,
|
||||
candidates: T[],
|
||||
options: CoveringSetOptions = {},
|
||||
): T[] | null {
|
||||
const maxSize = Math.max(1, options.maxSize ?? DEFAULT_MAX_SIZE)
|
||||
const maxCandidates = Math.max(1, options.maxCandidates ?? DEFAULT_MAX_CANDIDATES)
|
||||
const targetOre = toOre(target)
|
||||
if (targetOre <= 0) return null
|
||||
|
||||
const pool = candidates
|
||||
.map((c) => ({ candidate: c, ore: toOre(c.amount) }))
|
||||
.filter((c) => c.ore > 0 && c.ore <= targetOre)
|
||||
.sort((a, b) => {
|
||||
if (a.candidate.dateDistanceDays !== b.candidate.dateDistanceDays) {
|
||||
return a.candidate.dateDistanceDays - b.candidate.dateDistanceDays
|
||||
}
|
||||
if (a.ore !== b.ore) return b.ore - a.ore
|
||||
return a.candidate.id < b.candidate.id ? -1 : a.candidate.id > b.candidate.id ? 1 : 0
|
||||
})
|
||||
.slice(0, maxCandidates)
|
||||
|
||||
for (let size = 1; size <= Math.min(maxSize, pool.length); size++) {
|
||||
let best: { indices: number[]; distance: number } | null = null
|
||||
const chosen: number[] = []
|
||||
|
||||
const walk = (start: number, remaining: number, distance: number) => {
|
||||
if (chosen.length === size) {
|
||||
if (remaining === 0 && (best === null || distance < best.distance)) {
|
||||
best = { indices: [...chosen], distance }
|
||||
}
|
||||
return
|
||||
}
|
||||
const slotsLeft = size - chosen.length
|
||||
for (let i = start; i <= pool.length - slotsLeft; i++) {
|
||||
const entry = pool[i]
|
||||
if (entry.ore > remaining) continue
|
||||
// Nothing smaller than what is left can complete the set once the
|
||||
// last slot is being filled: skip instead of descending.
|
||||
if (slotsLeft === 1 && entry.ore !== remaining) continue
|
||||
chosen.push(i)
|
||||
walk(i + 1, remaining - entry.ore, distance + entry.candidate.dateDistanceDays)
|
||||
chosen.pop()
|
||||
}
|
||||
}
|
||||
|
||||
walk(0, targetOre, 0)
|
||||
if (best !== null) {
|
||||
const found = best as { indices: number[]; distance: number }
|
||||
return found.indices.map((i) => pool[i].candidate)
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
Reference in New Issue
Block a user