* feat(invoices): add Plusgiro input to bank details settings Plusgiro was already persisted, validated by the API schema, rendered on the invoice PDF and toggleable via "Visa plusgiro" — but the settings UI had no field to enter the number, so plusgiro-only users could not fill it in. Add the input next to Bankgiro with Luhn validation and hyphen formatting, include it in the save payload (normalised on save so raw digits still match the dashed schema format), and add sv/en strings. Adds validatePlusgiroNumber/formatPlusgiroNumber helpers + tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(invoices): respect non-VAT-registered seller in PDF preview + portal tooltips Two user-reported bugs: - PDF preview (/api/invoices/preview-pdf) ignored company.vat_registered and fell back to the customer-driven 25% rate, so a non-momsregistrerad seller saw VAT in the review step even though the created invoice books none. Mirror the server-side write gate (build-invoice-write.ts): force 0% when vat_registered is false (delivery notes excepted). - InfoTooltip rendered TooltipContent without a Portal, so tooltips were clipped by the scrollable DialogContent (overflow-y-auto) in the send-invoice journal-entry review. Wrap in TooltipPrimitive.Portal. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(transactions): book library mall from its literal lines, not a lossy fallback Booking a bank transaction with a user-created booking-template (mall) via the convertible "QuickReview" fast path reduced the template to a single category + one account_override, silently discarding the chosen debit/credit. A kundinbetalning mall (D 1930 / K 1510) booked as a generic cost (D 6991 / K 1930), or with a VAT line as D 1930 / K 1930 / K 2611 — and the result flipped with the direction inferred from the business/settlement line tags, so visually-identical templates produced different verifikationer. Route every library template through the journal-entry editor (applyTemplate -> /book), which posts the literal lines, regardless of convertibility. Add regression tests locking the contract. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(bookkeeping): make the booking-time duplicate guard bypassable TRANSACTION_BOOK_POSSIBLE_DUPLICATE told users they could "book anyway" but the UI dead-ended on a toast with no way to do so. Add a shared DuplicateBookingDialog that surfaces the already-booked sibling and lets the user review it or book anyway (force bound to the reviewed candidate, which the server re-detects so a stale id cannot wave the guard away). - Wire the dialog into the /transactions categorize flow and the manual booking dialog (JournalEntryForm -> /api/transactions/[id]/book) - Bind the override to expected_duplicate_transaction_id OR expected_duplicate_journal_entry_id so ledger-only vouchers (paid invoice, salary run) can be confirmed too - Extend the guard to the pending-operations commit path and the MCP server - Tests for book/categorize routes, detection, and the commit guard Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(bookkeeping): log duplicate-guard bypass to behandlingshistorik in the agent commit path The web /book and /categorize routes append a durable BankTransactionDuplicateDismissed event when a user books over a detected possible double-booking. The agent commit path (commitCategorizeTransaction, commitMarkInvoicePaid) skipped the guard silently on allow_duplicate=true, leaving no behandlingshistorik — an auditor could not reconstruct why the duplicate was allowed (BFNAR 2013:2 kap 8). When allow_duplicate=true, re-detect the candidate and append the dismissal event (BankTransactionDuplicateDismissed for the bank-line path, InvoiceDuplicatePaymentDismissed for mark-paid). Best-effort — a logging failure never blocks a legitimate booking. Payloads stay PII-safe (ids, amounts, dates only — no customer or merchant name). Also fix the misleading DuplicateBookingDialog JSDoc: the retry binds expected_duplicate_journal_entry_id, not candidate.transaction_id, so the systemdokumentation matches the actual control (BFL 7 kap). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(mcp-server): stub booking-duplicate guard in receipt-matcher categorize tests The gnubok_categorize_transaction tool runs the booking-time duplicate guard before staging; its detection queries consumed the queued supabase mock results, so the staging assertions saw a thrown duplicate error instead of a staged op. Mock detectBookingDuplicate to "no duplicate" since these tests don't exercise that path. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(transactions): use roundOre for duplicate-guard öre rounding Replace naive Math.round(x*100)/100 with roundOre() from @/lib/money in the booking-time duplicate guard (detection lib, commit executor, MCP categorize tool), satisfying the no-new-antipatterns ratchet guard. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
336 lines
12 KiB
TypeScript
336 lines
12 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')
|
|
})
|
|
})
|
|
|
|
// ── 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()
|
|
})
|
|
})
|
|
|
|
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()
|
|
})
|
|
})
|