Files
accounted/lib/transactions/__tests__/booking-duplicate-detection.test.ts
T
MattssonandClaude Opus 4.8 f63d3e3100 Bug/open banking flow (#854)
* fix(enable-banking): pin Mobile BankID (decoupled) auth_method so Handelsbanken corporate connects

We never sent auth_method to Enable Banking, so it fell back to the ASPSP's
visible default — REDIRECT for Handelsbanken. For Handelsbanken *corporate*
PSUs the redirect flow does not support Mobile BankID, so authorization failed
right after the user approved in the BankID app. Mobile BankID at Handelsbanken
is a DECOUPLED method flagged hidden_method=true, which Enable Banking only uses
when requested explicitly.

Resolve the bank's preferred auth method before /auth: query the ASPSP's
auth_methods and pick the DECOUPLED (Mobile BankID) method when present,
otherwise leave auth_method unset so banks that already work are untouched.
The method name is read dynamically per psu_type, so it is robust across
sandbox/production naming.

- api-client: add approach/hidden_method to AuthMethod, fix ASPSP.auth_methods
  field name (was available_auth_methods, never populated), add
  getPreferredAuthMethod(), thread optional authMethod through startAuthorization
- index: resolve authMethod in /connect and pass it on both fresh + reconnect
- tests: cover method selection and request-body shaping

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(invoice-inbox): clean up bulk-selection toolbar UI

Redesign the selection toolbar shown when inbox items are checked:
one solid primary "Bokför valda" button with outlined secondary
actions ("Fråga assistenten", "Ta bort") and a plain selection
count. Removes the redundant "Avmarkera" button (users uncheck the
still-visible box), fixes label clipping, and gives the toolbar more
breathing room.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(entitlements): bypass paywall in local development

Add isPaywallBypassed() so all gated capabilities are testable locally
without a subscription. Fires only on NODE_ENV=development (npm run dev)
or an explicit DISABLE_PAYWALL=true escape hatch — production builds run
under NODE_ENV=production and the entitlement suite runs under 'test',
so both keep exercising the real gate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(tic): resolve enskild firma bolagsuppgifter via 12-digit personnummer

TIC's Lens search is fuzzy and only resolves an enskild firma from the 12-digit (century-prefixed) personnummer; a 10-digit form fuzzy-matched an unrelated entity. Expand personnummer to 12 digits before querying and reject hits whose registration number is unrelated to the request. Add a "Hämta" action to the settings Bolagsuppgifter panel to (re)fetch on demand.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(transactions): implement categorize core for bank transaction categorization

- Added `categorize-core.ts` to handle categorization of bank transactions, supporting single and bulk operations.
- Introduced `categorizeMatchedTransaction` and `bulkBookMatchedInboxItems` functions for transaction processing.
- Implemented fiscal period validation and duplicate booking detection.
- Enhanced logging and error handling for transaction categorization.

feat(scripts): add diagnostic script for Handelsbanken ASPSP metadata

- Created `check-handelsbanken-aspsp.mjs` to fetch and display available authentication methods for Handelsbanken.
- Outputs metadata for business and personal PSU types, including default authentication methods.

fix(migrations): increase statement timeout for SIE bulk delete operations

- Updated `20260629160000_sie_bulk_delete_statement_timeout.sql` to set a longer statement timeout for bulk delete RPCs to prevent cancellations during large imports.

feat(migrations): add bulk book inbox items to pending operations

- Expanded `pending_operations` table to include `bulk_book_inbox_items` operation type in `20260630120000_pending_operations_add_bulk_book_inbox_items.sql`.
- Supports bulk booking of matched inbox items against bank transactions.

test(pg): add tests for replace_period_opening_balance_link RPC

- Implemented tests in `replace-period-opening-balance-link.pg.test.ts` to validate the functionality of the opening-balance correction flow.
- Ensured immutability of opening balance links and proper handling of posted vs. non-posted entries.

* fix(sie-export): update journal entries and lines handling in SIE export tests

* fix(migrations): resolve version collision on 20260629160000

The SIE bulk-delete statement_timeout migration shared version
20260629160000 with journal_entries_list_series_filter (merged from
main via #798/#823), causing a schema_migrations_pkey duplicate key
error on apply. Rename the branch's migration to 20260629160100.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(compliance): resolve compliance-swarm + review findings

- opening-balance/correct: compensating rollback for the non-atomic
  storno+rebook so a mid-sequence failure never leaves two posted OB
  entries (ASVS V2.3); durable audit event on every failure path
  (V16); reference the original verifikationsnummer in the corrected
  entry per BFL 5 kap 5§; document that requireWrite already enforces
  write-role + membership (V8.2.1 was a false positive)
- reports sources routes: validate the cursor date component as ISO
  (/^\d{4}-\d{2}-\d{2}$/) before use, 400 on malformed (ASVS V1.2),
  applied to both the VAT-declaration and trial-balance routes
- AgentSessionList: await the rename PATCH, revert the optimistic
  title and toast on failure (ASVS V4.5)
- bank booking: exclude same-batch siblings from the booking-time
  duplicate guard so bulk-booking distinct same-(date,amount)
  transactions no longer false-positives; pre-existing duplicate
  detection is preserved
- BulkBookInboxDialog: drop the unsafe currency-based reverse_charge
  default, add an omvänd skattskyldighet advisory, and type VAT
  options to the backend VatTreatment union
- OpeningBalanceRowEditor: hold onChange in a ref (synced in effect,
  not during render) so an unstable callback can't cause a render loop

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 18:13:00 +02:00

399 lines
15 KiB
TypeScript

/**
* Tests for the booking-time duplicate guard.
*
* Detection queries `transactions` for same-date already-booked siblings, then
* filters by öre + cash-account compatibility in JS, then resolves the voucher
* label from `journal_entries`. The mock returns the rows each query yields.
*/
import { describe, it, expect } from 'vitest'
import {
detectBookedDuplicateTransaction,
detectLedgerDuplicateVoucher,
detectBookingDuplicate,
} from '../booking-duplicate-detection'
type TxRow = {
id: string
date: string
amount: number | string
description: string | null
cash_account_id: string | null
journal_entry_id: string
}
type JeRow = { voucher_series: string | null; voucher_number: number | null; entry_date: string | null }
function txChain(data: TxRow[]) {
const c: Record<string, unknown> = {}
c.select = () => c
c.eq = () => c
c.not = () => c
c.neq = () => c
c.limit = () => Promise.resolve({ data, error: null })
return c
}
function jeChain(data: JeRow | null) {
const c: Record<string, unknown> = {}
c.select = () => c
c.eq = () => c
c.maybeSingle = () => Promise.resolve({ data, error: null })
return c
}
function makeSupabase(txData: TxRow[], jeData: JeRow | null = { voucher_series: 'A', voucher_number: 142, entry_date: '2025-12-19' }) {
return {
from: (table: string) => (table === 'transactions' ? txChain(txData) : jeChain(jeData)),
} as never
}
const COMPANY = 'co-1'
const sibling = (over: Partial<TxRow> = {}): TxRow => ({
id: 'sib-1',
date: '2025-12-19',
amount: -1616,
description: 'TELENOR SVERIGE AB',
cash_account_id: null,
journal_entry_id: 'je-1',
...over,
})
describe('detectBookedDuplicateTransaction', () => {
it('returns null when no same-date booked sibling exists', async () => {
const supabase = makeSupabase([])
const result = await detectBookedDuplicateTransaction(supabase, COMPANY, {
id: 'self', date: '2025-12-19', amount: -1616, cash_account_id: null,
})
expect(result).toBeNull()
})
it('flags a same date+amount+account booked sibling with its voucher label', async () => {
const supabase = makeSupabase([sibling()])
const result = await detectBookedDuplicateTransaction(supabase, COMPANY, {
id: 'self', date: '2025-12-19', amount: -1616, cash_account_id: null,
})
expect(result).toEqual({
transaction_id: 'sib-1',
journal_entry_id: 'je-1',
voucher_label: 'A142',
entry_date: '2025-12-19',
description: 'TELENOR SVERIGE AB',
amount: -1616,
})
})
it('does NOT flag a sibling on a different known cash account', async () => {
const supabase = makeSupabase([sibling({ cash_account_id: 'acct-A' })])
const result = await detectBookedDuplicateTransaction(supabase, COMPANY, {
id: 'self', date: '2025-12-19', amount: -1616, cash_account_id: 'acct-B',
})
expect(result).toBeNull()
})
it('flags when accounts are compatible via a null on either side', async () => {
const supabase = makeSupabase([sibling({ cash_account_id: 'acct-A' })])
const result = await detectBookedDuplicateTransaction(supabase, COMPANY, {
id: 'self', date: '2025-12-19', amount: -1616, cash_account_id: null,
})
expect(result?.transaction_id).toBe('sib-1')
})
it('does NOT flag a sibling with a different amount', async () => {
const supabase = makeSupabase([sibling({ amount: -1000 })])
const result = await detectBookedDuplicateTransaction(supabase, COMPANY, {
id: 'self', date: '2025-12-19', amount: -1616, cash_account_id: null,
})
expect(result).toBeNull()
})
it('matches a numeric-string amount from PostgREST against a JS number (öre)', async () => {
const supabase = makeSupabase([sibling({ amount: '-1616.00' })])
const result = await detectBookedDuplicateTransaction(supabase, COMPANY, {
id: 'self', date: '2025-12-19', amount: -1616, cash_account_id: null,
})
expect(result?.transaction_id).toBe('sib-1')
})
it('returns null for a zero-amount target without querying', async () => {
const supabase = makeSupabase([sibling({ amount: 0 })])
const result = await detectBookedDuplicateTransaction(supabase, COMPANY, {
id: 'self', date: '2025-12-19', amount: 0, cash_account_id: null,
})
expect(result).toBeNull()
})
it('picks the lowest-id sibling deterministically (stable under force re-detect)', async () => {
const supabase = makeSupabase([
sibling({ id: 'sib-9' }),
sibling({ id: 'sib-2' }),
])
const result = await detectBookedDuplicateTransaction(supabase, COMPANY, {
id: 'self', date: '2025-12-19', amount: -1616, cash_account_id: null,
})
expect(result?.transaction_id).toBe('sib-2')
})
// ── Intra-batch exclusion (bulk-book false-positive fix) ────────────────
it('excludes a same-batch sibling whose id is in excludeTransactionIds', async () => {
const supabase = makeSupabase([sibling({ id: 'sib-batch' })])
const result = await detectBookedDuplicateTransaction(
supabase,
COMPANY,
{ id: 'self', date: '2025-12-19', amount: -1616, cash_account_id: null },
{ excludeTransactionIds: ['sib-batch'] },
)
expect(result).toBeNull()
})
it('STILL flags a pre-existing sibling not in excludeTransactionIds (invariant preserved)', async () => {
// 'sib-old' existed before the batch; only 'sib-batch' was booked this run.
const supabase = makeSupabase([sibling({ id: 'sib-old' })])
const result = await detectBookedDuplicateTransaction(
supabase,
COMPANY,
{ id: 'self', date: '2025-12-19', amount: -1616, cash_account_id: null },
{ excludeTransactionIds: ['sib-batch'] },
)
expect(result?.transaction_id).toBe('sib-old')
})
})
// ── Ledger-only voucher guard (the orphan with no sibling transaction) ───────
type Jel = {
account_number: string
debit_amount: number | string
credit_amount: number | string
journal_entry: {
id: string
entry_date: string
description: string | null
voucher_series: string | null
voucher_number: number | null
status: string
source_type: string | null
}
}
/** A chain whose terminals all resolve to the SAME canned result for a table. */
function ledgerChain(result: { data: unknown; error: unknown }) {
const c: Record<string, unknown> = {}
c.select = () => c
c.eq = () => c
c.neq = () => c
c.not = () => c
c.gt = () => c
c.gte = () => c
c.lte = () => c
c.limit = () => Promise.resolve(result)
c.maybeSingle = () => Promise.resolve(result)
c.single = () => Promise.resolve(result)
c.in = () => Promise.resolve(result) // terminal for the link-exclusion lookups
return c
}
function makeLedgerSupabase(opts: {
ledgerAccount?: string | null
lines?: Jel[]
txLinks?: { journal_entry_id: string }[]
payLinks?: { journal_entry_id: string }[]
transactionRows?: TxRow[] // siblings for the orchestrator fall-through
}) {
return {
from: (table: string) => {
switch (table) {
case 'cash_accounts':
return ledgerChain({
data: opts.ledgerAccount != null ? { ledger_account: opts.ledgerAccount } : null,
error: null,
})
case 'journal_entry_lines':
return ledgerChain({ data: opts.lines ?? [], error: null })
case 'invoice_payments':
return ledgerChain({ data: opts.payLinks ?? [], error: null })
case 'transactions':
// Same table backs the sibling scan (.limit) and the link-exclusion
// lookup (.in). The sibling scan returns transactionRows; the link
// lookup returns txLinks. With a shape-only mock both share one canned
// result, so tests that need a sibling set transactionRows and leave
// txLinks empty (and vice versa).
return ledgerChain({ data: opts.transactionRows ?? opts.txLinks ?? [], error: null })
default:
return ledgerChain({ data: null, error: null })
}
},
} as never
}
const jel = (over: Partial<Jel> = {}): Jel => ({
account_number: over.account_number ?? '1930',
debit_amount: over.debit_amount ?? 98565,
credit_amount: over.credit_amount ?? 0,
journal_entry: {
id: 'je-2',
entry_date: '2026-03-30',
description: 'Inbetalning kundfaktura 2026001',
voucher_series: 'A',
voucher_number: 2,
status: 'posted',
source_type: 'invoice_paid',
...over.journal_entry,
},
})
describe('detectLedgerDuplicateVoucher', () => {
it('flags an inbound receipt already booked as a 19xx debit voucher (no sibling tx)', async () => {
const supabase = makeLedgerSupabase({ lines: [jel()] })
const result = await detectLedgerDuplicateVoucher(supabase, COMPANY, {
id: 'self', date: '2026-03-26', amount: 98565, cash_account_id: null,
})
expect(result).toEqual({
transaction_id: null,
journal_entry_id: 'je-2',
voucher_label: 'A2',
entry_date: '2026-03-30',
description: 'Inbetalning kundfaktura 2026001',
amount: 98565,
})
})
it('flags an outbound payout already booked as a 19xx credit voucher (salary case)', async () => {
const salaryLine = jel({
debit_amount: 0,
credit_amount: 16609,
journal_entry: {
id: 'je-3', entry_date: '2026-05-04', description: 'Lön 2026-05 — Nettolön',
voucher_series: 'A', voucher_number: 3, status: 'posted', source_type: 'salary',
},
})
const supabase = makeLedgerSupabase({ lines: [salaryLine] })
const result = await detectLedgerDuplicateVoucher(supabase, COMPANY, {
id: 'self', date: '2026-05-04', amount: -16609, cash_account_id: null,
})
expect(result?.journal_entry_id).toBe('je-3')
expect(result?.transaction_id).toBeNull()
expect(result?.amount).toBe(16609)
})
it('does NOT flag an inbound receipt against a credit-only voucher (wrong direction)', async () => {
// A 19xx CREDIT is a payout, not the receipt the inbound line is looking for.
const supabase = makeLedgerSupabase({ lines: [jel({ debit_amount: 0, credit_amount: 98565 })] })
const result = await detectLedgerDuplicateVoucher(supabase, COMPANY, {
id: 'self', date: '2026-03-26', amount: 98565, cash_account_id: null,
})
expect(result).toBeNull()
})
it('does NOT flag when the amount differs', async () => {
const supabase = makeLedgerSupabase({ lines: [jel({ debit_amount: 90000 })] })
const result = await detectLedgerDuplicateVoucher(supabase, COMPANY, {
id: 'self', date: '2026-03-26', amount: 98565, cash_account_id: null,
})
expect(result).toBeNull()
})
it('excludes a voucher already linked to a transaction', async () => {
const supabase = makeLedgerSupabase({ lines: [jel()], txLinks: [{ journal_entry_id: 'je-2' }] })
const result = await detectLedgerDuplicateVoucher(supabase, COMPANY, {
id: 'self', date: '2026-03-26', amount: 98565, cash_account_id: null,
})
expect(result).toBeNull()
})
it('excludes a voucher already linked to an invoice payment', async () => {
const supabase = makeLedgerSupabase({ lines: [jel()], payLinks: [{ journal_entry_id: 'je-2' }] })
const result = await detectLedgerDuplicateVoucher(supabase, COMPANY, {
id: 'self', date: '2026-03-26', amount: 98565, cash_account_id: null,
})
expect(result).toBeNull()
})
it('ignores storno/correction vouchers (valid second vouchers, not duplicates)', async () => {
const stornoLine = jel({ journal_entry: { ...jel().journal_entry, source_type: 'storno' } })
const supabase = makeLedgerSupabase({ lines: [stornoLine] })
const result = await detectLedgerDuplicateVoucher(supabase, COMPANY, {
id: 'self', date: '2026-03-26', amount: 98565, cash_account_id: null,
})
expect(result).toBeNull()
})
it('matches a numeric-string leg amount from PostgREST (öre)', async () => {
const supabase = makeLedgerSupabase({ lines: [jel({ debit_amount: '98565.00' })] })
const result = await detectLedgerDuplicateVoucher(supabase, COMPANY, {
id: 'self', date: '2026-03-26', amount: 98565, cash_account_id: null,
})
expect(result?.journal_entry_id).toBe('je-2')
})
it('returns null for a zero-amount target without querying', async () => {
const supabase = makeLedgerSupabase({ lines: [jel()] })
const result = await detectLedgerDuplicateVoucher(supabase, COMPANY, {
id: 'self', date: '2026-03-26', amount: 0, cash_account_id: null,
})
expect(result).toBeNull()
})
// ── Intra-batch exclusion (bulk-book false-positive fix) ────────────────
it('excludes a same-batch voucher whose journal_entry.id is in excludeJournalEntryIds', async () => {
const supabase = makeLedgerSupabase({ lines: [jel()] }) // jel() → journal_entry.id 'je-2'
const result = await detectLedgerDuplicateVoucher(
supabase,
COMPANY,
{ id: 'self', date: '2026-03-26', amount: 98565, cash_account_id: null },
{ excludeJournalEntryIds: ['je-2'] },
)
expect(result).toBeNull()
})
it('STILL flags a pre-existing voucher not in excludeJournalEntryIds (invariant preserved)', async () => {
const supabase = makeLedgerSupabase({ lines: [jel()] })
const result = await detectLedgerDuplicateVoucher(
supabase,
COMPANY,
{ id: 'self', date: '2026-03-26', amount: 98565, cash_account_id: null },
{ excludeJournalEntryIds: ['je-booked-this-batch'] },
)
expect(result?.journal_entry_id).toBe('je-2')
})
})
describe('detectBookingDuplicate (orchestrator)', () => {
it('returns the sibling transaction when one exists (voucher scan not needed)', async () => {
const supabase = makeLedgerSupabase({ transactionRows: [sibling()] })
const result = await detectBookingDuplicate(supabase, COMPANY, {
id: 'self', date: '2025-12-19', amount: -1616, cash_account_id: null,
})
expect(result?.transaction_id).toBe('sib-1')
})
it('falls through to the ledger voucher when there is no sibling transaction', async () => {
const supabase = makeLedgerSupabase({ transactionRows: [], lines: [jel()] })
const result = await detectBookingDuplicate(supabase, COMPANY, {
id: 'self', date: '2026-03-26', amount: 98565, cash_account_id: null,
})
expect(result?.transaction_id).toBeNull()
expect(result?.journal_entry_id).toBe('je-2')
})
it('returns null when neither a sibling nor a voucher matches', async () => {
const supabase = makeLedgerSupabase({ transactionRows: [], lines: [] })
const result = await detectBookingDuplicate(supabase, COMPANY, {
id: 'self', date: '2026-03-26', amount: 98565, cash_account_id: null,
})
expect(result).toBeNull()
})
it('propagates exclusions to BOTH the sibling scan and the ledger scan', async () => {
// A matching sibling AND a matching ledger voucher exist, but both belong to
// this same batch (excluded) → the orchestrator must report no duplicate.
const supabase = makeLedgerSupabase({
transactionRows: [sibling({ id: 'sib-batch', amount: 98565, journal_entry_id: 'je-sib' })],
lines: [jel()], // journal_entry.id 'je-2'
})
const result = await detectBookingDuplicate(
supabase,
COMPANY,
{ id: 'self', date: '2026-03-26', amount: 98565, cash_account_id: null },
{ excludeTransactionIds: ['sib-batch'], excludeJournalEntryIds: ['je-2'] },
)
expect(result).toBeNull()
})
})