* 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>
399 lines
15 KiB
TypeScript
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()
|
|
})
|
|
})
|