* fix(invoices): record manual and Stripe settlements in invoice_payments (#2019)
settleInvoicePayment created the payment voucher and flipped the invoice to
paid but never wrote the AR sub-ledger row. The kontantmetod bokslut cut-off
reads invoice_payments only (payment DATE, not remaining_amount), so a
manually settled invoice was booked again as a fordran with vilande moms at
year end, double-counting revenue and VAT. The same gap hid the payment from
the Betalningar view and from the voucher -> invoice reference map.
- Insert the row between voucher creation and the CAS status update, same
shape as the bank-match path (amount in invoice currency, transaction_id
null). An insert failure cancels the voucher and fails closed; both CAS
failure branches remove the row together with the voucher.
- Backfill: scripts/backfill-invoice-payment-rows.ts (dry-run default) with
a pure planner in lib/invoices/backfill-invoice-payment-rows.ts. Writes
only where exactly one posted payment voucher exists; zero or several are
reported, never guessed. Rows carry notes 'backfill:#2019' so one DELETE
reverts a run. Executed on staging (10 rows); prod awaits explicit go.
- pg-real: transaction-less rows coexist under the tx/invoice unique index,
the je/invoice index still refuses a double link, and the authenticated
writer can delete its own row (the CAS-failure path depends on it).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018pMEgrnPsxDMiYfnXcD2Zo
* fix(invoices): write the payment row from every mark-paid path and harden the backfill
Skeptic and review round on #2236 (issue #2019):
- One helper (lib/invoices/invoice-payment-row.ts) now writes the
invoice_payments row for all four transaction-less settlement paths:
dashboard mark-paid and Stripe via settleInvoicePayment, plus the MCP
mark_invoice_paid commit and the v1 mark-paid route, which booked their
own voucher and never wrote the row. Amount = applied amount (new
paid_amount minus prior), not cash received, so a 3740 öre absorption
never yields a negative fordran in the cut-off or a wrong storno restore.
- The two duplicate detectors no longer treat a payment row with
transaction_id NULL as "reconciled to a bank line": the bank line for a
manual settlement arrives later and the voucher must stay a twin.
- Backfill: payment_date from the voucher entry_date (paid_at was
wall-clock before #1332); refuse rows that disagree with the voucher's
1510 credit / settlement debit; report partially covered invoices
(rows_short) instead of patching; record each executed run in
behandlingshistorik (InvoicePaymentRowBackfilled, migration
20260903180000). Re-run end to end on staging: 10 rows, 10 events.
- Typecheck ratchet: cast in the cut-off test.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018pMEgrnPsxDMiYfnXcD2Zo
* fix(invoices): use roundOre in the #2019 backfill (guard ratchet)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018pMEgrnPsxDMiYfnXcD2Zo
* fix(invoices): log a failed payment-row rollback and keep backfill rows with their audit event
Swedish review round 2 on #2236:
- removeInvoicePaymentRow no longer swallows a failed compensating DELETE:
it logs at error level with company and row id (a stranded row would
read as a settlement in the kontantmetod cut-off) and returns whether
the row is gone. Unit tests for the helper.
- The backfill deletes a company's rows from the run again when its
behandlingshistorik event cannot be written, so rows and change log
(BFNAR 2013:2 p. 9.16) never diverge; the company is listed for a re-run.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018pMEgrnPsxDMiYfnXcD2Zo
* fix(invoices): keep raw insert errors out of the v1 and MCP mark-paid responses
Compliance swarm on #2236 (ISO 27001 A.8.28): the payment-row insert
failure returned the driver's error text to API callers and MCP users.
The text now stays in the server log; callers get the reason code and a
generic Swedish outcome.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018pMEgrnPsxDMiYfnXcD2Zo
* fix(invoices): never backfill a payment row into a closed or locked period
Swedish review round 3 on #2236: a row dated into a closed or locked
fiscal period changes facts a filed bokslut or deklaration relied on. The
planner now reports such invoices (period_closed) instead of writing them,
and the script header states that the tagged DELETE is an emergency revert
for the window before any cut-off relies on the rows; afterwards the
correction path is a storno of the cut-off verifikat.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018pMEgrnPsxDMiYfnXcD2Zo
* test(fiscal-periods): pass route params in the two mid-month tests (typecheck ratchet)
cc18e9d53 (#2242) added two POST(req) calls without the params argument,
raising the file's TypeScript error count above the ratchet baseline
(25 vs 23). main is red on "Checks" for every PR since; this unblocks the
gate for #2236 and the rest without touching the baseline.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
584 lines
18 KiB
TypeScript
584 lines
18 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',
|
|
},
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Enqueue the two pages the two-step entry-lines fetch reads
|
|
* (lib/bookkeeping/entry-lines.ts): the parent entries first, then the bare
|
|
* lines keyed by journal_entry_id. Fixtures stay embed-shaped; the helper
|
|
* reattaches the parent under `journal_entry`, which is exactly what the
|
|
* old aliased `journal_entry:journal_entries!inner(...)` embed produced.
|
|
*/
|
|
function enqueueLines(rows: ReturnType<typeof makeLineRow>[]) {
|
|
const entries = [
|
|
...new Map(rows.map((r) => [r.journal_entry.id, r.journal_entry])).values(),
|
|
]
|
|
enqueue({ data: entries, error: null })
|
|
// No entries means the helper never queries the lines at all.
|
|
if (entries.length === 0) return
|
|
enqueue({
|
|
data: rows.map((r, i) => ({
|
|
id: `line-${String(i).padStart(4, '0')}`,
|
|
journal_entry_id: r.journal_entry.id,
|
|
account_number: r.account_number,
|
|
debit_amount: r.debit_amount,
|
|
})),
|
|
error: null,
|
|
})
|
|
}
|
|
|
|
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,
|
|
transactionCurrency: 'SEK',
|
|
})
|
|
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,
|
|
transactionCurrency: 'SEK',
|
|
})
|
|
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,
|
|
transactionCurrency: 'SEK',
|
|
})
|
|
expect(result).toBeNull()
|
|
})
|
|
|
|
it('returns the candidate when an unlinked manual JE matches exactly on the same date', async () => {
|
|
enqueueLines([
|
|
makeLineRow({
|
|
je_id: 'je-1',
|
|
account: '1930',
|
|
debit: 1000,
|
|
date: '2026-05-15',
|
|
voucher_label: 'A12',
|
|
}),
|
|
])
|
|
// 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,
|
|
transactionCurrency: 'SEK',
|
|
})
|
|
|
|
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('drives the scan from journal_entries and reattaches the parent under journal_entry', async () => {
|
|
// Shape guard for the entry-lines conversion: no query starts on
|
|
// journal_entry_lines with the tenant scope buried in an embed, and the
|
|
// candidate is still built from the parent fields (voucher label, date,
|
|
// description) plus the line fields (account, debit).
|
|
// The mock client is module-level, so only this test's calls are read.
|
|
const callsBefore = supabase.from.mock.calls.length
|
|
enqueueLines([
|
|
makeLineRow({
|
|
je_id: 'je-shape',
|
|
account: '1930',
|
|
debit: 1000,
|
|
date: '2026-05-15',
|
|
voucher_label: 'A12',
|
|
description: 'Manuell inbetalning',
|
|
}),
|
|
])
|
|
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,
|
|
transactionCurrency: 'SEK',
|
|
})
|
|
|
|
const tables = supabase.from.mock.calls.slice(callsBefore).map((c) => c[0])
|
|
expect(tables[0]).toBe('journal_entries')
|
|
expect(tables[1]).toBe('journal_entry_lines')
|
|
expect(result).toEqual({
|
|
journal_entry_id: 'je-shape',
|
|
voucher_label: 'A12',
|
|
entry_date: '2026-05-15',
|
|
description: 'Manuell inbetalning',
|
|
amount: 1000,
|
|
bank_account_number: '1930',
|
|
reason: 'exact_amount_same_date',
|
|
amount_verified: true,
|
|
unverified_reason: null,
|
|
})
|
|
})
|
|
|
|
it('returns within_window reason when JE date is close but not equal', async () => {
|
|
enqueueLines([
|
|
makeLineRow({
|
|
je_id: 'je-2',
|
|
account: '1930',
|
|
debit: 500,
|
|
date: '2026-05-12',
|
|
voucher_label: 'A5',
|
|
}),
|
|
])
|
|
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,
|
|
transactionCurrency: 'SEK',
|
|
})
|
|
|
|
expect(result).not.toBeNull()
|
|
expect(result!.reason).toBe('exact_amount_within_window')
|
|
})
|
|
|
|
it('excludes JEs that are already linked via invoice_payments', async () => {
|
|
enqueueLines([
|
|
makeLineRow({
|
|
je_id: 'je-3',
|
|
account: '1930',
|
|
debit: 1000,
|
|
date: '2026-05-15',
|
|
}),
|
|
])
|
|
// invoice_payments has a row linking this JE to a bank transaction
|
|
enqueue({ data: [{ journal_entry_id: 'je-3', transaction_id: 'tx-bank' }], 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,
|
|
transactionCurrency: 'SEK',
|
|
})
|
|
|
|
expect(result).toBeNull()
|
|
})
|
|
|
|
// #2019: "Markera som betald" and Stripe now write a payment row WITHOUT a
|
|
// bank transaction. That row means "paid by hand", not "reconciled to a
|
|
// bank line", so the voucher must still surface when the real bank line
|
|
// arrives; otherwise a second payment voucher posts silently.
|
|
it('still flags a voucher whose payment row carries no bank transaction (manual settlement)', async () => {
|
|
enqueueLines([
|
|
makeLineRow({
|
|
je_id: 'je-manual',
|
|
account: '1930',
|
|
debit: 1000,
|
|
date: '2026-05-15',
|
|
}),
|
|
])
|
|
enqueue({ data: [{ journal_entry_id: 'je-manual', transaction_id: null }], 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,
|
|
transactionCurrency: 'SEK',
|
|
})
|
|
|
|
expect(result?.journal_entry_id).toBe('je-manual')
|
|
})
|
|
|
|
it('excludes JEs already linked from another transaction', async () => {
|
|
enqueueLines([
|
|
makeLineRow({
|
|
je_id: 'je-4',
|
|
account: '1930',
|
|
debit: 1000,
|
|
date: '2026-05-15',
|
|
}),
|
|
])
|
|
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,
|
|
transactionCurrency: 'SEK',
|
|
})
|
|
|
|
expect(result).toBeNull()
|
|
})
|
|
|
|
it('excludes storno entries', async () => {
|
|
enqueueLines([
|
|
makeLineRow({
|
|
je_id: 'je-storno',
|
|
account: '1930',
|
|
debit: 1000,
|
|
date: '2026-05-15',
|
|
source_type: 'storno',
|
|
}),
|
|
])
|
|
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,
|
|
transactionCurrency: 'SEK',
|
|
})
|
|
|
|
expect(result).toBeNull()
|
|
})
|
|
|
|
it('excludes correction entries', async () => {
|
|
enqueueLines([
|
|
makeLineRow({
|
|
je_id: 'je-corr',
|
|
account: '1930',
|
|
debit: 1000,
|
|
date: '2026-05-15',
|
|
source_type: 'correction',
|
|
}),
|
|
])
|
|
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,
|
|
transactionCurrency: 'SEK',
|
|
})
|
|
|
|
expect(result).toBeNull()
|
|
})
|
|
|
|
it('picks the same-date candidate over a within-window candidate', async () => {
|
|
enqueueLines([
|
|
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',
|
|
}),
|
|
])
|
|
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,
|
|
transactionCurrency: 'SEK',
|
|
})
|
|
|
|
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 () => {
|
|
enqueueLines([
|
|
makeLineRow({
|
|
je_id: 'je-x',
|
|
account: '1930',
|
|
debit: 250,
|
|
date: '2026-05-15',
|
|
}),
|
|
])
|
|
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,
|
|
transactionCurrency: 'SEK',
|
|
})
|
|
|
|
// 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 () => {
|
|
enqueueLines([
|
|
makeLineRow({
|
|
je_id: 'je-off',
|
|
account: '1930',
|
|
debit: 1001,
|
|
date: '2026-05-15',
|
|
}),
|
|
])
|
|
|
|
const result = await detectDuplicatePaymentVoucher(supabase as never, {
|
|
companyId: 'company-1',
|
|
transactionId: 'tx-1',
|
|
transactionDate: '2026-05-15',
|
|
transactionAmount: 1000,
|
|
transactionCurrency: 'SEK',
|
|
})
|
|
|
|
expect(result).toBeNull()
|
|
})
|
|
|
|
it('ignores the caller transaction even if it carries a journal_entry_id link', async () => {
|
|
enqueueLines([
|
|
makeLineRow({
|
|
je_id: 'je-caller',
|
|
account: '1930',
|
|
debit: 1000,
|
|
date: '2026-05-15',
|
|
}),
|
|
])
|
|
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,
|
|
transactionCurrency: 'SEK',
|
|
})
|
|
|
|
expect(result).not.toBeNull()
|
|
expect(result!.journal_entry_id).toBe('je-caller')
|
|
})
|
|
|
|
// ── FX: the bank line may be foreign, the 19xx debit is always SEK ─────────
|
|
//
|
|
// journal_entry_lines.debit_amount is written in SEK even when the line
|
|
// carries currency='EUR' + amount_in_currency as document metadata, so the
|
|
// bank line has to be converted before the two can be compared at all.
|
|
|
|
it('flags a foreign receipt against the SEK voucher its own booking produced', async () => {
|
|
// 100 EUR at 11.50 was booked as a 1150 SEK debit.
|
|
enqueueLines([
|
|
makeLineRow({ je_id: 'je-fx', account: '1930', debit: 1150, date: '2026-05-15', voucher_label: 'A9' }),
|
|
])
|
|
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: 100,
|
|
transactionCurrency: 'EUR',
|
|
transactionAmountSek: 1150,
|
|
transactionExchangeRate: 11.5,
|
|
})
|
|
|
|
expect(result).not.toBeNull()
|
|
expect(result!.journal_entry_id).toBe('je-fx')
|
|
expect(result!.amount).toBe(1150)
|
|
expect(result!.amount_verified).toBe(true)
|
|
expect(result!.unverified_reason).toBeNull()
|
|
})
|
|
|
|
it('converts via exchange_rate when amount_sek was never stored', async () => {
|
|
enqueueLines([
|
|
makeLineRow({ je_id: 'je-rate', account: '1930', debit: 1150, date: '2026-05-15' }),
|
|
])
|
|
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: 100,
|
|
transactionCurrency: 'EUR',
|
|
transactionAmountSek: null,
|
|
transactionExchangeRate: 11.5,
|
|
})
|
|
|
|
expect(result!.journal_entry_id).toBe('je-rate')
|
|
expect(result!.amount_verified).toBe(true)
|
|
})
|
|
|
|
it('does NOT flag a EUR line against an unrelated same-magnitude SEK voucher', async () => {
|
|
// 1000 EUR is ~11 500 SEK, nothing to do with a 1000 SEK voucher. The old
|
|
// comparison put the raw 1000 against the leg and matched.
|
|
enqueueLines([
|
|
makeLineRow({ je_id: 'je-coincidence', account: '1930', debit: 1000, date: '2026-05-15' }),
|
|
])
|
|
|
|
const result = await detectDuplicatePaymentVoucher(supabase as never, {
|
|
companyId: 'company-1',
|
|
transactionId: 'tx-1',
|
|
transactionDate: '2026-05-15',
|
|
transactionAmount: 1000,
|
|
transactionCurrency: 'EUR',
|
|
transactionAmountSek: 11500,
|
|
transactionExchangeRate: 11.5,
|
|
})
|
|
|
|
expect(result).toBeNull()
|
|
})
|
|
|
|
it('WARNS rather than passing when a foreign line carries no rate', async () => {
|
|
// Nothing can be compared. A null return would read as "no duplicate" and
|
|
// let the matcher post a second payment voucher for one affärshändelse.
|
|
enqueueLines([
|
|
makeLineRow({ je_id: 'je-unverifiable', account: '1930', debit: 1150, date: '2026-05-15' }),
|
|
])
|
|
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: 100,
|
|
transactionCurrency: 'EUR',
|
|
transactionAmountSek: null,
|
|
transactionExchangeRate: null,
|
|
})
|
|
|
|
expect(result).not.toBeNull()
|
|
expect(result!.journal_entry_id).toBe('je-unverifiable')
|
|
expect(result!.amount_verified).toBe(false)
|
|
expect(result!.unverified_reason).toBe('transaction_missing_sek_value')
|
|
// The amount test never ran, so the reason must not claim an amount match:
|
|
// 'exact_amount_*' here made the dialog render "på samma belopp" for a
|
|
// candidate whose amounts were never compared.
|
|
expect(result!.reason).toBe('date_window_only')
|
|
// The leg's SEK figure, never the bank line's foreign 100.
|
|
expect(result!.amount).toBe(1150)
|
|
})
|
|
|
|
it('returns null (a verified pass) for a rateless foreign line with no 19xx debit in the window', async () => {
|
|
enqueueLines([])
|
|
|
|
const result = await detectDuplicatePaymentVoucher(supabase as never, {
|
|
companyId: 'company-1',
|
|
transactionId: 'tx-1',
|
|
transactionDate: '2026-05-15',
|
|
transactionAmount: 100,
|
|
transactionCurrency: 'EUR',
|
|
transactionAmountSek: null,
|
|
transactionExchangeRate: null,
|
|
})
|
|
|
|
expect(result).toBeNull()
|
|
})
|
|
|
|
it('still excludes storno when the amount cannot be verified', async () => {
|
|
enqueueLines([
|
|
makeLineRow({ je_id: 'je-s', account: '1930', debit: 1150, date: '2026-05-15', source_type: 'storno' }),
|
|
])
|
|
|
|
const result = await detectDuplicatePaymentVoucher(supabase as never, {
|
|
companyId: 'company-1',
|
|
transactionId: 'tx-1',
|
|
transactionDate: '2026-05-15',
|
|
transactionAmount: 100,
|
|
transactionCurrency: 'EUR',
|
|
transactionAmountSek: null,
|
|
transactionExchangeRate: null,
|
|
})
|
|
|
|
expect(result).toBeNull()
|
|
})
|
|
|
|
it('leaves a SEK company on exactly the old path (null currency = SEK default)', async () => {
|
|
enqueueLines([
|
|
makeLineRow({ je_id: 'je-sek', account: '1930', debit: 1000, date: '2026-05-15' }),
|
|
])
|
|
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,
|
|
transactionCurrency: null,
|
|
})
|
|
|
|
expect(result!.journal_entry_id).toBe('je-sek')
|
|
expect(result!.amount_verified).toBe(true)
|
|
})
|
|
})
|