Files
accounted/lib/invoices/__tests__/duplicate-payment-detection.test.ts
T
Jakob WennbergandClaude Sonnet 5 ec27228a8e style: remove em/en dashes repo-wide, add CLAUDE.md rule against them (#890)
Em dashes (—) and en dashes (–) had spread across comments, docs, tests,
and a few UI strings, reading as AI-generated boilerplate rather than
house style. Replaced each with punctuation matching its context: colon
for explanatory clauses, comma for asides, plain hyphen for numeric/legal
ranges (e.g. "21-23§"), "to"/"till" for date ranges, parentheses for
paired-dash asides. messages/en.json and messages/sv.json were fixed by
hand together to keep sv/en in sync.

Left untouched where the dash is the functional subject rather than
decorative punctuation: date-range-parser.ts's separator regex,
charset-repair.ts's CP1252 byte-mapping table (and its test), the SIE
encoding mojibake docs, generic-csv.ts's minus-sign normalizer, the
agent system-prompt files that already instruct against em dashes, and
a golden iXBRL test fixture compared byte-for-byte.

Also fixes two bugs surfaced along the way: an off-by-one in
ApiKeysPanel's scope-label split (a leftover from an earlier partial
pass), and a charset-repair test that had lost the literal en-dash it
exists to verify.

Regenerated the agent atom seed migration (skills:generate) since 27
SKILL.md files changed. Added a CLAUDE.md rule against em/en dashes,
with an explicit carve-out for the functional-dash cases above.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-04 15:58:06 +02:00

349 lines
9.4 KiB
TypeScript

import { describe, it, expect, beforeEach } from 'vitest'
import { detectDuplicatePaymentVoucher } from '../duplicate-payment-detection'
import { createQueuedMockSupabase } from '@/tests/helpers'
const { supabase, enqueue, reset } = createQueuedMockSupabase()
describe('detectDuplicatePaymentVoucher', () => {
beforeEach(() => {
reset()
})
function makeLineRow(opts: {
je_id: string
account: string
debit: number
date: string
voucher_label?: string
source_type?: string | null
description?: string | null
}) {
const [series, ...numParts] = (opts.voucher_label ?? 'A1').split('')
const num = parseInt(numParts.join(''), 10) || 1
return {
account_number: opts.account,
debit_amount: opts.debit,
journal_entry: {
id: opts.je_id,
entry_date: opts.date,
description: opts.description ?? `Voucher ${opts.je_id}`,
voucher_series: series,
voucher_number: num,
status: 'posted',
source_type: opts.source_type ?? 'manual',
company_id: 'company-1',
},
}
}
it('returns null when transaction amount is 0', async () => {
const result = await detectDuplicatePaymentVoucher(supabase as never, {
companyId: 'company-1',
transactionId: 'tx-1',
transactionDate: '2026-05-15',
transactionAmount: 0,
})
expect(result).toBeNull()
})
it('returns null when transaction date is invalid', async () => {
const result = await detectDuplicatePaymentVoucher(supabase as never, {
companyId: 'company-1',
transactionId: 'tx-1',
transactionDate: 'not-a-date',
transactionAmount: 1000,
})
expect(result).toBeNull()
})
it('returns null when no lines are found', async () => {
enqueue({ data: [], error: null })
const result = await detectDuplicatePaymentVoucher(supabase as never, {
companyId: 'company-1',
transactionId: 'tx-1',
transactionDate: '2026-05-15',
transactionAmount: 1000,
})
expect(result).toBeNull()
})
it('returns the candidate when an unlinked manual JE matches exactly on the same date', async () => {
enqueue({
data: [
makeLineRow({
je_id: 'je-1',
account: '1930',
debit: 1000,
date: '2026-05-15',
voucher_label: 'A12',
}),
],
error: null,
})
// invoice_payments link check (no links)
enqueue({ data: [], error: null })
// transactions link check (no links)
enqueue({ data: [], error: null })
const result = await detectDuplicatePaymentVoucher(supabase as never, {
companyId: 'company-1',
transactionId: 'tx-1',
transactionDate: '2026-05-15',
transactionAmount: 1000,
})
expect(result).not.toBeNull()
expect(result!.journal_entry_id).toBe('je-1')
expect(result!.bank_account_number).toBe('1930')
expect(result!.reason).toBe('exact_amount_same_date')
expect(result!.amount).toBe(1000)
})
it('returns within_window reason when JE date is close but not equal', async () => {
enqueue({
data: [
makeLineRow({
je_id: 'je-2',
account: '1930',
debit: 500,
date: '2026-05-12',
voucher_label: 'A5',
}),
],
error: null,
})
enqueue({ data: [], error: null })
enqueue({ data: [], error: null })
const result = await detectDuplicatePaymentVoucher(supabase as never, {
companyId: 'company-1',
transactionId: 'tx-1',
transactionDate: '2026-05-15',
transactionAmount: 500,
})
expect(result).not.toBeNull()
expect(result!.reason).toBe('exact_amount_within_window')
})
it('excludes JEs that are already linked via invoice_payments', async () => {
enqueue({
data: [
makeLineRow({
je_id: 'je-3',
account: '1930',
debit: 1000,
date: '2026-05-15',
}),
],
error: null,
})
// invoice_payments has a row linking this JE
enqueue({ data: [{ journal_entry_id: 'je-3' }], error: null })
enqueue({ data: [], error: null })
const result = await detectDuplicatePaymentVoucher(supabase as never, {
companyId: 'company-1',
transactionId: 'tx-1',
transactionDate: '2026-05-15',
transactionAmount: 1000,
})
expect(result).toBeNull()
})
it('excludes JEs already linked from another transaction', async () => {
enqueue({
data: [
makeLineRow({
je_id: 'je-4',
account: '1930',
debit: 1000,
date: '2026-05-15',
}),
],
error: null,
})
enqueue({ data: [], error: null })
// another transaction already links this JE
enqueue({ data: [{ id: 'tx-other', journal_entry_id: 'je-4' }], error: null })
const result = await detectDuplicatePaymentVoucher(supabase as never, {
companyId: 'company-1',
transactionId: 'tx-1',
transactionDate: '2026-05-15',
transactionAmount: 1000,
})
expect(result).toBeNull()
})
it('excludes storno entries', async () => {
enqueue({
data: [
makeLineRow({
je_id: 'je-storno',
account: '1930',
debit: 1000,
date: '2026-05-15',
source_type: 'storno',
}),
],
error: null,
})
enqueue({ data: [], error: null })
enqueue({ data: [], error: null })
const result = await detectDuplicatePaymentVoucher(supabase as never, {
companyId: 'company-1',
transactionId: 'tx-1',
transactionDate: '2026-05-15',
transactionAmount: 1000,
})
expect(result).toBeNull()
})
it('excludes correction entries', async () => {
enqueue({
data: [
makeLineRow({
je_id: 'je-corr',
account: '1930',
debit: 1000,
date: '2026-05-15',
source_type: 'correction',
}),
],
error: null,
})
enqueue({ data: [], error: null })
enqueue({ data: [], error: null })
const result = await detectDuplicatePaymentVoucher(supabase as never, {
companyId: 'company-1',
transactionId: 'tx-1',
transactionDate: '2026-05-15',
transactionAmount: 1000,
})
expect(result).toBeNull()
})
it('picks the same-date candidate over a within-window candidate', async () => {
enqueue({
data: [
makeLineRow({
je_id: 'je-far',
account: '1930',
debit: 1000,
date: '2026-05-12',
voucher_label: 'A1',
}),
makeLineRow({
je_id: 'je-same',
account: '1930',
debit: 1000,
date: '2026-05-15',
voucher_label: 'A2',
}),
],
error: null,
})
enqueue({ data: [], error: null })
enqueue({ data: [], error: null })
const result = await detectDuplicatePaymentVoucher(supabase as never, {
companyId: 'company-1',
transactionId: 'tx-1',
transactionDate: '2026-05-15',
transactionAmount: 1000,
})
expect(result).not.toBeNull()
expect(result!.journal_entry_id).toBe('je-same')
expect(result!.reason).toBe('exact_amount_same_date')
})
it('matches absolute value for negative transaction amounts (expense)', async () => {
enqueue({
data: [
makeLineRow({
je_id: 'je-x',
account: '1930',
debit: 250,
date: '2026-05-15',
}),
],
error: null,
})
enqueue({ data: [], error: null })
enqueue({ data: [], error: null })
const result = await detectDuplicatePaymentVoucher(supabase as never, {
companyId: 'company-1',
transactionId: 'tx-1',
transactionDate: '2026-05-15',
transactionAmount: -250,
})
// Note: while the match-invoice route only handles income, the
// detector itself is amount-direction agnostic: it just finds JEs
// that book the same magnitude on the bank side. Callers gate by
// direction.
expect(result).not.toBeNull()
expect(result!.amount).toBe(250)
})
it('skips lines whose amount differs by more than 0.01', async () => {
enqueue({
data: [
makeLineRow({
je_id: 'je-off',
account: '1930',
debit: 1001,
date: '2026-05-15',
}),
],
error: null,
})
const result = await detectDuplicatePaymentVoucher(supabase as never, {
companyId: 'company-1',
transactionId: 'tx-1',
transactionDate: '2026-05-15',
transactionAmount: 1000,
})
expect(result).toBeNull()
})
it('ignores the caller transaction even if it carries a journal_entry_id link', async () => {
enqueue({
data: [
makeLineRow({
je_id: 'je-caller',
account: '1930',
debit: 1000,
date: '2026-05-15',
}),
],
error: null,
})
enqueue({ data: [], error: null })
// The caller transaction itself links the JE (defensive: shouldn't happen
// in normal flow because we call this before the link, but a retry could).
enqueue({ data: [{ id: 'tx-caller', journal_entry_id: 'je-caller' }], error: null })
const result = await detectDuplicatePaymentVoucher(supabase as never, {
companyId: 'company-1',
transactionId: 'tx-caller',
transactionDate: '2026-05-15',
transactionAmount: 1000,
})
expect(result).not.toBeNull()
expect(result!.journal_entry_id).toBe('je-caller')
})
})