Fix/invoice booking vat fixes (#778)

* 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>
This commit is contained in:
Mattsson
2026-06-25 15:54:35 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent bc09cea07e
commit 9ed0b9515a
25 changed files with 1424 additions and 63 deletions
+13 -8
View File
@@ -748,14 +748,18 @@ export const CategorizeTransactionSchema = z
confirm_no_match: z.boolean().optional(),
// Booking-time duplicate guard (TRANSACTION_BOOK_POSSIBLE_DUPLICATE). force
// bypasses it after the user reviews the candidate; the bypass is bound to
// the specific already-booked sibling via expected_duplicate_transaction_id
// (re-detected server-side, so a guessed id can't wave the guard away).
// the specific already-booked candidate (re-detected server-side, so a
// guessed id can't wave the guard away). The candidate is either a sibling
// transaction (expected_duplicate_transaction_id) or a ledger-only voucher
// with no transaction behind it (expected_duplicate_journal_entry_id) — both
// carry a journal_entry_id, so new callers bind on that.
force: z.boolean().optional(),
expected_duplicate_transaction_id: uuid.optional(),
expected_duplicate_journal_entry_id: uuid.optional(),
})
.refine((v) => !v.force || !!v.expected_duplicate_transaction_id, {
message: 'expected_duplicate_transaction_id is required when force=true',
path: ['expected_duplicate_transaction_id'],
.refine((v) => !v.force || !!v.expected_duplicate_transaction_id || !!v.expected_duplicate_journal_entry_id, {
message: 'expected_duplicate_transaction_id or expected_duplicate_journal_entry_id is required when force=true',
path: ['expected_duplicate_journal_entry_id'],
})
export const BookTransactionSchema = z
@@ -767,10 +771,11 @@ export const BookTransactionSchema = z
// Booking-time duplicate guard — see CategorizeTransactionSchema.
force: z.boolean().optional(),
expected_duplicate_transaction_id: uuid.optional(),
expected_duplicate_journal_entry_id: uuid.optional(),
})
.refine((v) => !v.force || !!v.expected_duplicate_transaction_id, {
message: 'expected_duplicate_transaction_id is required when force=true',
path: ['expected_duplicate_transaction_id'],
.refine((v) => !v.force || !!v.expected_duplicate_transaction_id || !!v.expected_duplicate_journal_entry_id, {
message: 'expected_duplicate_transaction_id or expected_duplicate_journal_entry_id is required when force=true',
path: ['expected_duplicate_journal_entry_id'],
})
/**
+64
View File
@@ -3,6 +3,8 @@ import {
luhnValidate,
validateBankgiroNumber,
formatBankgiroNumber,
validatePlusgiroNumber,
formatPlusgiroNumber,
generateOcrReference,
validateOcrReference,
} from '../luhn'
@@ -92,6 +94,68 @@ describe('formatBankgiroNumber', () => {
})
})
// -- Plusgiro --
describe('validatePlusgiroNumber', () => {
it('validates plusgiro with hyphen', () => {
// 4567 → Luhn check digit 4 → "4567-4"
expect(validatePlusgiroNumber('4567-4')).toBe(true)
})
it('validates raw digits without hyphen', () => {
expect(validatePlusgiroNumber('45674')).toBe(true)
expect(validatePlusgiroNumber('1234566')).toBe(true)
})
it('validates short (2-digit) plusgiro', () => {
// "0" → check digit 0 → "00"
expect(validatePlusgiroNumber('00')).toBe(true)
})
it('validates full 8-digit plusgiro', () => {
expect(validatePlusgiroNumber('55555551')).toBe(true)
})
it('rejects wrong check digit', () => {
expect(validatePlusgiroNumber('4567-5')).toBe(false)
})
it('rejects too long (>8 digits)', () => {
expect(validatePlusgiroNumber('123456789')).toBe(false)
})
it('rejects single-digit input', () => {
expect(validatePlusgiroNumber('5')).toBe(false)
})
it('rejects non-numeric input', () => {
expect(validatePlusgiroNumber('abc-d')).toBe(false)
})
it('handles spaces', () => {
expect(validatePlusgiroNumber('4567 4')).toBe(true)
})
})
describe('formatPlusgiroNumber', () => {
it('places hyphen before the check digit', () => {
expect(formatPlusgiroNumber('45674')).toBe('4567-4')
})
it('formats 8-digit as XXXXXXX-X', () => {
expect(formatPlusgiroNumber('55555551')).toBe('5555555-1')
})
it('handles already-formatted input', () => {
expect(formatPlusgiroNumber('4567-4')).toBe('4567-4')
})
it('returns input unchanged for invalid lengths', () => {
expect(formatPlusgiroNumber('5')).toBe('5')
expect(formatPlusgiroNumber('123456789')).toBe('123456789')
})
})
// -- OCR reference --
describe('generateOcrReference', () => {
+24
View File
@@ -55,6 +55,30 @@ export function formatBankgiroNumber(input: string): string {
return input
}
// -- Plusgiro --
/**
* Validate a Swedish Plusgiro number (2-8 digits, Luhn check digit).
* The final digit is the Luhn check digit. Accepts formats:
* "XXXXXXX-X", spaced, or raw digits.
*/
export function validatePlusgiroNumber(input: string): boolean {
const digits = input.replace(/[-\s]/g, '')
if (!/^\d+$/.test(digits)) return false
if (digits.length < 2 || digits.length > 8) return false
return luhnValidate(digits)
}
/**
* Format a Plusgiro number with the standard hyphen before the check digit.
* e.g. "45674" → "4567-4". Returns input unchanged for invalid lengths.
*/
export function formatPlusgiroNumber(input: string): string {
const digits = input.replace(/[-\s]/g, '')
if (digits.length < 2 || digits.length > 8) return input
return digits.slice(0, -1) + '-' + digits.slice(-1)
}
// -- OCR reference --
/**
@@ -245,3 +245,52 @@ describe('applyTemplate on shapes the converter rejects', () => {
expect(result[1].credit_amount).toBe('250.00')
})
})
describe('library mall books its literal accounts (regression)', () => {
// A user's "Inbetalning från kund" mall is D 1930 (bank) / K 1510
// (kundfordran). The QuickReview fast path used to reduce a library template
// to a category + one account_override and book D 6991 / K 1930 — or, with a
// VAT line, D 1930 / K 1930 / K 2611 — silently dropping the chosen accounts.
// The transaction picker now routes EVERY library template through the
// journal-entry editor, whose lines come from applyTemplate. These tests pin
// the guarantee the editor path relies on: applyTemplate books exactly the
// accounts/sides the user defined, and never re-derives a counter account.
const AMOUNT = 5000
const customerPaymentLines: BookingTemplateLibraryLine[] = [
{ account: '1930', label: 'Inbetalning', side: 'debit', type: 'settlement', ratio: 1 },
{ account: '1510', label: 'Kundfordran', side: 'credit', type: 'business', ratio: 1 },
]
it('books exactly D 1930 / K 1510 with no re-derived accounts', () => {
const result = applyTemplate(customerPaymentLines, AMOUNT)
expect(result).toEqual([
{ account_number: '1930', debit_amount: '5000.00', credit_amount: '', line_description: 'Inbetalning' },
{ account_number: '1510', debit_amount: '', credit_amount: '5000.00', line_description: 'Kundfordran' },
])
// The accounts the lossy fast path used to inject must never appear.
const accounts = result.map((l) => l.account_number)
expect(accounts).not.toContain('6991')
expect(accounts).not.toContain('3001')
expect(accounts).not.toContain('2611')
})
it('is blind to business/settlement tagging — same accounts either way', () => {
// The old converter keyed "direction" (and thus the whole booking) off which
// leg was tagged business vs settlement. applyTemplate must not: swapping the
// tags leaves the same accounts on the same sides.
const swappedTags: BookingTemplateLibraryLine[] = [
{ account: '1930', label: 'Inbetalning', side: 'debit', type: 'business', ratio: 1 },
{ account: '1510', label: 'Kundfordran', side: 'credit', type: 'settlement', ratio: 1 },
]
expect(applyTemplate(swappedTags, AMOUNT)).toEqual(applyTemplate(customerPaymentLines, AMOUNT))
})
it('stays balanced (sum debit === sum credit)', () => {
const result = applyTemplate(customerPaymentLines, 4999.99)
const sumDebit = result.reduce((s, l) => s + (parseFloat(l.debit_amount || '0') || 0), 0)
const sumCredit = result.reduce((s, l) => s + (parseFloat(l.credit_amount || '0') || 0), 0)
expect(sumDebit).toBeCloseTo(sumCredit, 2)
expect(sumDebit).toBeCloseTo(4999.99, 2)
})
})
+4 -4
View File
@@ -298,16 +298,16 @@ const TRANSACTIONS: Record<string, StructuredErrorEntry> = {
TRANSACTION_BOOK_POSSIBLE_DUPLICATE: {
httpStatus: 409,
message_sv:
'En annan transaktion på samma datum och belopp är redan bokförd. Det här ser ut som en dubblett — bokför inte samma affärshändelse två gånger. Granska den befintliga verifikationen, eller bokför ändå om transaktionerna inte hör ihop.',
'Den här affärshändelsen ser redan ut att vara bokförd — antingen en annan transaktion på samma datum och belopp, eller en verifikation som redan bokar samma belopp på bankkontot (t.ex. en betald faktura eller en lönekörning). Bokför inte samma affärshändelse två gånger. Granska den befintliga verifikationen och länka transaktionen till den, eller bokför ändå om de inte hör ihop.',
message_en:
'Another transaction with the same date and amount is already booked. This looks like a duplicate — do not book the same business event twice. Review the existing voucher, or pass force=true to book it anyway if they are genuinely unrelated.',
'This business event already appears to be booked — either another transaction with the same date and amount, or a voucher that already books the same amount on the bank account (e.g. a paid invoice or a salary run). Do not book the same business event twice. Review the existing voucher and link this transaction to it, or pass force=true to book it anyway if they are genuinely unrelated.',
},
TRANSACTION_BOOK_FORCE_CANDIDATE_MISMATCH: {
httpStatus: 409,
message_sv:
'Den möjliga dubbletten som visades matchar inte längre. Ladda om och försök igen så att rätt transaktion visas.',
'Den möjliga dubbletten som visades matchar inte längre. Ladda om och försök igen så att rätt kandidat visas.',
message_en:
'The duplicate transaction echoed in expected_duplicate_transaction_id no longer matches the one detected at request time. Re-run the booking pre-flight to obtain the current candidate, then retry.',
'The duplicate candidate echoed in expected_duplicate_transaction_id / expected_duplicate_journal_entry_id no longer matches the one detected at request time. Re-run the booking pre-flight to obtain the current candidate, then retry.',
},
TX_CATEGORIZE_TX_NOT_FOUND: {
httpStatus: 404,
@@ -0,0 +1,237 @@
/**
* The agent/MCP commit path (lib/pending-operations/commit.ts) must run the same
* duplicate guards as the web routes — it previously bypassed them entirely,
* which let an approved staged op double-book an affärshändelse already in the
* ledger (the production case: a bank line booked on top of an invoice
* "markera som betald" voucher or a salary payout).
*
* These tests drive the public `commitPendingOperation` dispatcher (the executor
* functions are private) and assert the op is auto-rejected (409) when a
* duplicate is detected. The detection functions themselves are unit-tested in
* lib/transactions/__tests__/booking-duplicate-detection.test.ts and
* lib/invoices/__tests__/duplicate-payment-detection.test.ts.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { eventBus } from '@/lib/events/bus'
import type { PendingOperation } from '@/types'
const mockDetectBookingDuplicate = vi.fn()
vi.mock('@/lib/transactions/booking-duplicate-detection', () => ({
detectBookingDuplicate: (...args: unknown[]) => mockDetectBookingDuplicate(...args),
}))
const mockFindDupPayments = vi.fn()
vi.mock('@/lib/invoices/duplicate-payment-candidates', () => ({
findDuplicatePaymentCandidatesForInvoice: (...args: unknown[]) => mockFindDupPayments(...args),
}))
const mockAppendProcessingHistory = vi.fn()
vi.mock('@/lib/processing-history/append', () => ({
appendProcessingHistory: (...args: unknown[]) => mockAppendProcessingHistory(...args),
}))
import { commitPendingOperation } from '../commit'
/** Queue-based supabase mock: each `from()` resolves to the next queued result. */
function queuedSupabase(results: Array<{ data?: unknown; error?: unknown }>) {
const queue = [...results]
const from = vi.fn(() => {
const raw = queue.shift() ?? { data: null, error: null }
const result = { data: raw.data ?? null, error: raw.error ?? null }
const chain: object = new Proxy(
{},
{
get(_t, prop) {
if (prop === 'then') return (resolve: (v: unknown) => void) => resolve(result)
return () => chain
},
},
)
return chain
})
return { from } as never
}
function makePendingOp(overrides: Partial<PendingOperation>): PendingOperation {
return {
id: 'op-1',
user_id: 'user-1',
company_id: 'company-1',
operation_type: 'categorize_transaction',
status: 'pending',
title: 'test',
params: {},
preview_data: {},
result_data: null,
actor_type: 'user',
actor_id: null,
actor_label: null,
risk_level: 'medium',
created_at: '2026-05-03T00:00:00Z',
resolved_at: null,
updated_at: '2026-05-03T00:00:00Z',
...overrides,
} as PendingOperation
}
const voucherCandidate = {
transaction_id: null,
journal_entry_id: 'je-existing',
voucher_label: 'A2',
entry_date: '2026-03-30',
description: 'Inbetalning kundfaktura 2026001',
amount: 98565,
}
beforeEach(() => {
vi.clearAllMocks()
eventBus.clear()
})
describe('commit duplicate guard: categorize_transaction (reverse / book the bank line)', () => {
it('auto-rejects (409) when a ledger voucher already books this movement', async () => {
mockDetectBookingDuplicate.mockResolvedValue(voucherCandidate)
// claim → transaction fetch → reject update
const supabase = queuedSupabase([
{ data: { id: 'op-1' } },
{ data: { id: 'tx-1', date: '2026-03-26', amount: 98565, cash_account_id: null, journal_entry_id: null } },
{ data: null },
])
const op = makePendingOp({
operation_type: 'categorize_transaction',
params: { transaction_id: 'tx-1', category: 'income' },
})
const result = await commitPendingOperation(supabase, 'user-1', 'company-1', op)
expect(mockDetectBookingDuplicate).toHaveBeenCalledTimes(1)
expect(result.status).toBe('rejected')
expect(result.http_status).toBe(409)
})
it('does not enforce the guard when allow_duplicate=true, but records the dismissal to behandlingshistorik', async () => {
mockDetectBookingDuplicate.mockResolvedValue(voucherCandidate)
// The booking proceeds past the guard (not auto-rejected); the downstream
// booking is allowed to fail against the bare mock. Before that, the bypass
// must leave a durable BankTransactionDuplicateDismissed record so an
// auditor can reconstruct why the duplicate was allowed (BFNAR 2013:2 kap 8).
const supabase = queuedSupabase([
{ data: { id: 'op-1' } },
{ data: { id: 'tx-1', date: '2026-03-26', amount: 98565, cash_account_id: null, journal_entry_id: null } },
{ data: { entity_type: 'aktiebolag', fiscal_year_start_month: 1 } },
{ data: [] },
])
const op = makePendingOp({
operation_type: 'categorize_transaction',
params: { transaction_id: 'tx-1', category: 'income', allow_duplicate: true },
})
const result = await commitPendingOperation(supabase, 'user-1', 'company-1', op)
// Guard not enforced: the op is not auto-rejected at the duplicate guard.
expect(result.status).not.toBe('rejected')
// Detection still runs once — to capture the dismissed candidate for audit.
expect(mockDetectBookingDuplicate).toHaveBeenCalledTimes(1)
expect(mockAppendProcessingHistory).toHaveBeenCalledTimes(1)
const event = mockAppendProcessingHistory.mock.calls[0][0]
expect(event).toMatchObject({
companyId: 'company-1',
aggregateType: 'BankTransaction',
aggregateId: 'tx-1',
eventType: 'BankTransactionDuplicateDismissed',
actor: { type: 'user', id: 'user-1' },
})
expect(event.payload).toMatchObject({
transaction_id: 'tx-1',
dismissed_journal_entry_id: 'je-existing',
via: 'allow_duplicate',
})
})
it('records no dismissal when allow_duplicate=true but no duplicate is actually present', async () => {
mockDetectBookingDuplicate.mockResolvedValue(null)
const supabase = queuedSupabase([
{ data: { id: 'op-1' } },
{ data: { id: 'tx-1', date: '2026-03-26', amount: 98565, cash_account_id: null, journal_entry_id: null } },
{ data: { entity_type: 'aktiebolag', fiscal_year_start_month: 1 } },
{ data: [] },
])
const op = makePendingOp({
operation_type: 'categorize_transaction',
params: { transaction_id: 'tx-1', category: 'income', allow_duplicate: true },
})
await commitPendingOperation(supabase, 'user-1', 'company-1', op)
expect(mockDetectBookingDuplicate).toHaveBeenCalledTimes(1)
expect(mockAppendProcessingHistory).not.toHaveBeenCalled()
})
})
describe('commit duplicate guard: mark_invoice_paid (forward / book the payment)', () => {
it('auto-rejects (409) when an unlinked bank transaction already looks like the payment', async () => {
mockFindDupPayments.mockResolvedValue([
{ id: 'tx-9', date: '2026-03-26', amount: 98565, description: '2026001', merchant_name: null, reference: null, match_reason: 'ocr_exact', match_confidence: 0.99 },
])
// claim → invoice fetch → reject update
const supabase = queuedSupabase([
{ data: { id: 'op-1' } },
{ data: { id: 'inv-1', invoice_number: '2026001', status: 'sent', total: 98565, remaining_amount: 98565, customer: { name: 'Arcim Technology AB' } } },
{ data: null },
])
const op = makePendingOp({
operation_type: 'mark_invoice_paid',
params: { invoice_id: 'inv-1', payment_date: '2026-03-30' },
})
const result = await commitPendingOperation(supabase, 'user-1', 'company-1', op)
expect(mockFindDupPayments).toHaveBeenCalledTimes(1)
expect(result.status).toBe('rejected')
expect(result.http_status).toBe(409)
})
it('does not enforce the guard when allow_duplicate=true, but records the dismissal to behandlingshistorik', async () => {
mockFindDupPayments.mockResolvedValue([
{ id: 'tx-9', date: '2026-03-26', amount: 98565, description: '2026001', merchant_name: null, reference: null, match_reason: 'ocr_exact', match_confidence: 0.99 },
])
// claim → invoice fetch → company_settings → bare downstream (allowed to fail)
const supabase = queuedSupabase([
{ data: { id: 'op-1' } },
{ data: { id: 'inv-1', invoice_number: '2026001', status: 'sent', total: 98565, remaining_amount: 98565, customer: { name: 'Arcim Technology AB' } } },
{ data: { accounting_method: 'accrual', entity_type: 'aktiebolag' } },
])
const op = makePendingOp({
operation_type: 'mark_invoice_paid',
params: { invoice_id: 'inv-1', payment_date: '2026-03-30', allow_duplicate: true },
})
const result = await commitPendingOperation(supabase, 'user-1', 'company-1', op)
// Guard not enforced: not auto-rejected at the duplicate-payment guard.
expect(result.status).not.toBe('rejected')
expect(mockFindDupPayments).toHaveBeenCalledTimes(1)
expect(mockAppendProcessingHistory).toHaveBeenCalledTimes(1)
const event = mockAppendProcessingHistory.mock.calls[0][0]
expect(event).toMatchObject({
companyId: 'company-1',
aggregateType: 'System',
aggregateId: 'inv-1',
eventType: 'InvoiceDuplicatePaymentDismissed',
actor: { type: 'user', id: 'user-1' },
})
expect(event.payload).toMatchObject({
invoice_id: 'inv-1',
dismissed_transaction_ids: ['tx-9'],
candidate_count: 1,
via: 'allow_duplicate',
})
// PII-safe: no customer or merchant name in the payload.
expect(JSON.stringify(event.payload)).not.toContain('Arcim')
})
})
+151
View File
@@ -43,6 +43,8 @@ import {
} from '@/lib/bookkeeping/supplier-invoice-entries'
import { linkInvoiceToVoucher } from '@/lib/invoices/voucher-matching'
import { planInvoicePayment } from '@/lib/invoices/apply-invoice-payment'
import { detectBookingDuplicate } from '@/lib/transactions/booking-duplicate-detection'
import { findDuplicatePaymentCandidatesForInvoice } from '@/lib/invoices/duplicate-payment-candidates'
import { linkSupplierInvoiceToVoucher } from '@/lib/invoices/supplier-voucher-matching'
import { linkTransactionToJournalEntry } from '@/lib/transactions/link-journal-entry'
import { getErrorEntry } from '@/lib/errors/structured-errors'
@@ -69,6 +71,7 @@ import { prepareInvoicePdfRender, buildSwishQrDataUrl } from '@/lib/invoices/pdf
import { ensureInvoiceNumber } from '@/lib/invoices/ensure-invoice-number'
import { createLogger } from '@/lib/logger'
import { appendProcessingHistory } from '@/lib/processing-history/append'
import { roundOre } from '@/lib/money'
import { CreateSupplierParamsSchema } from '@/lib/pending-operations/schemas/create-supplier'
import { CreateArticleParamsSchema, UpdateArticleParamsSchema } from '@/lib/pending-operations/schemas/article'
import { ensureArticleNumber } from '@/lib/articles/ensure-article-number'
@@ -274,6 +277,78 @@ async function commitCategorizeTransaction(
return { error: 'Transaction already has a journal entry — it was categorized in the meantime.', status: 409 }
}
// Booking-time duplicate guard — parity with the web /categorize route, which
// the agent path otherwise bypassed entirely. Refuse to mint a second
// verifikat for an affärshändelse already in the ledger: an already-booked
// sibling transaction, OR an unlinked voucher that already books this amount
// on the bank account (invoice "markera som betald", the salary run's net-wage
// payout, a manual verifikat). The agent has no interactive "Bokför ändå", so
// it fails closed; re-stage with allow_duplicate=true after the user confirms
// in chat that the bank line is a genuinely separate event. Fail-open on a
// detection error so a transient query failure never blocks a real booking.
if (params.allow_duplicate !== true) {
let dup = null
try {
dup = await detectBookingDuplicate(supabase, companyId, {
id: txId,
date: transaction.date,
amount: transaction.amount,
cash_account_id: transaction.cash_account_id ?? null,
})
} catch (err) {
log.warn('booking-time duplicate detection failed (continuing)', err)
}
if (dup) {
const amountAbs = roundOre(Math.abs(Number(transaction.amount)))
const voucher = dup.voucher_label ? `verifikat ${dup.voucher_label}` : 'en befintlig verifikation'
return {
error:
`Möjlig dubblettbokföring: ${voucher} (${dup.entry_date}) bokför redan ${amountAbs} kr på bankkontot. ` +
`Den här affärshändelsen ser redan ut att vara bokförd — länka transaktionen till den befintliga ` +
`verifikationen i stället för att bokföra den igen. Om banktransaktionen verkligen är en separat ` +
`affärshändelse, kör om med allow_duplicate=true.`,
status: 409,
}
}
} else {
// allow_duplicate=true bypassed the guard. Booking over a possible
// double-booking is a bookkeeping act that must leave a durable
// behandlingshistorik record (BFNAR 2013:2 kap 8) — the web /book and
// /categorize routes log BankTransactionDuplicateDismissed, and the agent
// commit path must reach parity so an auditor can reconstruct why the
// duplicate was allowed. Re-detect to capture the dismissed candidate;
// best-effort, a logging failure must never block a legitimate booking.
try {
const dismissed = await detectBookingDuplicate(supabase, companyId, {
id: txId,
date: transaction.date,
amount: transaction.amount,
cash_account_id: transaction.cash_account_id ?? null,
})
if (dismissed) {
await appendProcessingHistory({
companyId,
correlationId: txId,
aggregateType: 'BankTransaction',
aggregateId: txId,
eventType: 'BankTransactionDuplicateDismissed',
payload: {
transaction_id: txId,
dismissed_transaction_id: dismissed.transaction_id,
dismissed_journal_entry_id: dismissed.journal_entry_id,
amount_ore: Math.round(dismissed.amount * 100),
entry_date: dismissed.entry_date,
via: 'allow_duplicate',
},
actor: { type: 'user', id: userId },
occurredAt: new Date(),
})
}
} catch (logErr) {
log.warn('failed to record duplicate-dismissal behandlingshistorik', logErr)
}
}
const isBusiness = category !== 'private'
const { data: settings } = await supabase
@@ -851,6 +926,82 @@ async function commitMarkInvoicePaid(
return { error: 'Invoice can only be marked as paid when status is "sent" or "overdue"', status: 409 }
}
// Duplicate-payment guard — parity with the web mark-paid route, which the
// agent path otherwise bypassed. If an unlinked inbound bank transaction
// already looks like this invoice's payment, booking a parallel payment
// voucher here creates exactly the orphan that later double-counts the
// receipt. Fail closed; the agent re-stages with allow_duplicate=true (after
// the user confirms) or, better, matches the transaction to the invoice
// instead. Fail-open on a detection error so it never blocks a real payment.
if (params.allow_duplicate !== true) {
const customerName = (invoice as { customer?: { name?: string } }).customer?.name
if (customerName) {
const remainingAmount =
(invoice as { remaining_amount?: number }).remaining_amount ?? invoice.total
let candidates: Awaited<ReturnType<typeof findDuplicatePaymentCandidatesForInvoice>> = []
try {
candidates = await findDuplicatePaymentCandidatesForInvoice(supabase, {
companyId,
invoice: { invoice_number: invoice.invoice_number, customer_name: customerName },
paymentAmount: remainingAmount,
paymentDate,
})
} catch (err) {
log.warn('duplicate-payment detection failed (continuing)', err)
}
if (candidates.length > 0) {
return {
error:
`Möjlig dubbelbetalning: en obokförd banktransaktion ser ut att vara betalningen för faktura ` +
`${invoice.invoice_number}. Matcha banktransaktionen mot fakturan (gnubok_match_transaction_to_invoice) ` +
`i stället för att bokföra en separat betalning. Om det verkligen rör sig om en annan betalning, ` +
`kör om med allow_duplicate=true.`,
status: 409,
}
}
}
} else {
// allow_duplicate=true bypassed the duplicate-payment guard. The decision
// to book a payment over a possible existing one must leave a durable
// behandlingshistorik record (BFNAR 2013:2 kap 8) so an auditor can see why
// the duplicate was allowed. Re-detect to capture the dismissed candidate;
// best-effort, never blocks the payment. Payload stays PII-safe
// (ids/amounts/dates only — no customer or merchant name).
const customerName = (invoice as { customer?: { name?: string } }).customer?.name
if (customerName) {
try {
const remainingAmount =
(invoice as { remaining_amount?: number }).remaining_amount ?? invoice.total
const dismissed = await findDuplicatePaymentCandidatesForInvoice(supabase, {
companyId,
invoice: { invoice_number: invoice.invoice_number, customer_name: customerName },
paymentAmount: remainingAmount,
paymentDate,
})
if (dismissed.length > 0) {
await appendProcessingHistory({
companyId,
correlationId: invoiceId,
aggregateType: 'System',
aggregateId: invoiceId,
eventType: 'InvoiceDuplicatePaymentDismissed',
payload: {
invoice_id: invoiceId,
payment_date: paymentDate,
dismissed_transaction_ids: dismissed.map((c) => c.id),
candidate_count: dismissed.length,
via: 'allow_duplicate',
},
actor: { type: 'user', id: userId },
occurredAt: new Date(),
})
}
} catch (logErr) {
log.warn('failed to record duplicate-payment-dismissal behandlingshistorik', logErr)
}
}
}
const { data: settings } = await supabase
.from('company_settings').select('accounting_method, entity_type').eq('company_id', companyId).single()
@@ -6,7 +6,11 @@
* label from `journal_entries`. The mock returns the rows each query yields.
*/
import { describe, it, expect } from 'vitest'
import { detectBookedDuplicateTransaction } from '../booking-duplicate-detection'
import {
detectBookedDuplicateTransaction,
detectLedgerDuplicateVoucher,
detectBookingDuplicate,
} from '../booking-duplicate-detection'
type TxRow = {
id: string
@@ -126,3 +130,206 @@ describe('detectBookedDuplicateTransaction', () => {
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()
})
})
+186 -4
View File
@@ -24,16 +24,21 @@
* sibling TRANSACTION rather than a manually-posted journal entry.
*/
import type { SupabaseClient } from '@supabase/supabase-js'
import { roundOre } from '@/lib/money'
/** Integer öre — representation-agnostic amount key (mirrors the ingest dedup). */
function toOre(amount: number | string): number {
return Math.round(Number(amount) * 100)
}
/** An already-booked transaction that looks like the same real movement. */
/** An already-booked transaction OR voucher that looks like the same real movement. */
export interface BookedDuplicateCandidate {
/** The sibling transaction that is already booked. */
transaction_id: string
/**
* The sibling transaction that is already booked, or `null` when the duplicate
* is a ledger-only voucher (a payment/payout booked straight to the cash
* account with no transaction row behind it — see detectLedgerDuplicateVoucher).
*/
transaction_id: string | null
/** Its verifikat. */
journal_entry_id: string
/** Human label, e.g. "A142" (voucher_series + voucher_number). */
@@ -129,6 +134,183 @@ export async function detectBookedDuplicateTransaction(
voucher_label: voucherLabel,
entry_date: entryDate,
description: best.description,
amount: Math.round(Number(best.amount) * 100) / 100,
amount: roundOre(Number(best.amount)),
}
}
/** ± days around the bank-tx date a voucher may be dated and still be "the same" movement. */
const VOUCHER_DUPLICATE_DATE_WINDOW_DAYS = 7
/** BAS "kassa och bank" range. 1910-1919 = kassa, 1920-1949 = bank/giro. */
const BANK_ACCOUNT_LOW = 1910
const BANK_ACCOUNT_HIGH = 1949
/**
* Find an unlinked posted voucher whose bank/cash (19xx) leg already books this
* exact bank movement — the ledger-only twin of the bank line.
*
* This is the second half of the booking-time duplicate guard. The first half
* (detectBookedDuplicateTransaction) only finds an already-booked SIBLING
* TRANSACTION. But the most damaging orphan has NO sibling transaction at all:
* the affärshändelse was booked through a flow that posts straight to the ledger
* and never creates or links a bank-transaction row — invoice "markera som
* betald" (Dr 19xx / Cr 1510), the salary run's net-wage payout (Cr 19xx), a
* hand-posted verifikat. Booking the bank line on top of that double-counts the
* movement on the cash account: two verifikationer for one affärshändelse,
* felaktig bokföring per BFL. Because the import dedup and the sibling guard
* both only see the `transactions` table, neither catches this — only matching
* the bank line against the ledger does.
*
* Direction-aware so it works both ways:
* - inbound (target.amount > 0, money in) → a 19xx DEBIT of the same amount
* - outbound (target.amount < 0, money out) → a 19xx CREDIT of the same amount
*
* Account-aware: when the bank line knows its cash account, the matching leg
* must be on that account's ledger account; otherwise any 19xx leg matches
* (single-account companies, legacy rows with no cash_account_id).
*
* Excludes vouchers already linked to a transaction or an invoice_payment (those
* are reconciled, not orphans) and storno/correction entries (valid second
* vouchers, not duplicates). Fail-open: a query error returns null so a
* detection failure never blocks a legitimate booking. The pick is deterministic
* (closest date, then lowest journal_entry id) so a force re-detect is stable.
*/
export async function detectLedgerDuplicateVoucher(
supabase: SupabaseClient,
companyId: string,
target: BookingTarget,
): Promise<BookedDuplicateCandidate | null> {
const targetOre = toOre(target.amount)
if (targetOre === 0 || Number.isNaN(targetOre)) return null
const targetAmount = roundOre(Math.abs(Number(target.amount)))
const inbound = targetOre > 0
const dateMs = new Date(target.date).getTime()
if (Number.isNaN(dateMs)) return null
const windowMs = VOUCHER_DUPLICATE_DATE_WINDOW_DAYS * 24 * 3600 * 1000
const lowDate = new Date(dateMs - windowMs).toISOString().split('T')[0]
const highDate = new Date(dateMs + windowMs).toISOString().split('T')[0]
// Resolve the bank line's settlement ledger account, when known, so a movement
// on one bank account never deduplicates a voucher on a different account of
// the same company (the 19xx leg below is matched against it).
let settlementAccount: string | null = null
if (target.cash_account_id) {
const { data: ca } = await supabase
.from('cash_accounts')
.select('ledger_account')
.eq('company_id', companyId)
.eq('id', target.cash_account_id)
.maybeSingle()
settlementAccount = ((ca as { ledger_account?: string } | null)?.ledger_account) ?? null
}
const amountColumn = inbound ? 'debit_amount' : 'credit_amount'
let query = supabase
.from('journal_entry_lines')
.select(
`account_number,
debit_amount,
credit_amount,
journal_entry:journal_entries!inner(
id,
entry_date,
description,
voucher_series,
voucher_number,
status,
source_type,
company_id
)`,
)
.eq('journal_entry.company_id', companyId)
.eq('journal_entry.status', 'posted')
.gte('journal_entry.entry_date', lowDate)
.lte('journal_entry.entry_date', highDate)
.gt(amountColumn, 0)
query = settlementAccount
? query.eq('account_number', settlementAccount)
: query.gte('account_number', String(BANK_ACCOUNT_LOW)).lte('account_number', String(BANK_ACCOUNT_HIGH))
const { data: lines, error } = await query.limit(50)
if (error || !lines || lines.length === 0) return null
type LineRow = {
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
}
}
const candidates = (lines as unknown as LineRow[])
.filter((l) => {
const legAmount = roundOre(Number(inbound ? l.debit_amount : l.credit_amount))
return Math.abs(legAmount - targetAmount) < 0.01
})
// Reversals/corrections are valid second vouchers, not duplicate bookings.
.filter((l) => l.journal_entry.source_type !== 'storno' && l.journal_entry.source_type !== 'correction')
if (candidates.length === 0) return null
// Drop vouchers already reconciled to a transaction or an invoice payment —
// those aren't orphans. Both lookups are filtered by company_id (defense in
// depth alongside RLS).
const entryIds = candidates.map((l) => l.journal_entry.id)
const [{ data: txLinks }, { data: payLinks }] = await Promise.all([
supabase.from('transactions').select('journal_entry_id').eq('company_id', companyId).in('journal_entry_id', entryIds),
supabase.from('invoice_payments').select('journal_entry_id').eq('company_id', companyId).in('journal_entry_id', entryIds),
])
const linked = new Set<string>()
for (const r of (txLinks ?? []) as { journal_entry_id: string | null }[]) {
if (r.journal_entry_id) linked.add(r.journal_entry_id)
}
for (const r of (payLinks ?? []) as { journal_entry_id: string | null }[]) {
if (r.journal_entry_id) linked.add(r.journal_entry_id)
}
const unlinked = candidates.filter((l) => !linked.has(l.journal_entry.id))
if (unlinked.length === 0) return null
unlinked.sort((a, b) => {
const ad = Math.abs(new Date(a.journal_entry.entry_date).getTime() - dateMs)
const bd = Math.abs(new Date(b.journal_entry.entry_date).getTime() - dateMs)
if (ad !== bd) return ad - bd
return a.journal_entry.id.localeCompare(b.journal_entry.id)
})
const best = unlinked[0]
return {
transaction_id: null,
journal_entry_id: best.journal_entry.id,
voucher_label: `${best.journal_entry.voucher_series ?? 'A'}${best.journal_entry.voucher_number ?? ''}`,
entry_date: best.journal_entry.entry_date,
description: best.journal_entry.description,
amount: roundOre(Number(inbound ? best.debit_amount : best.credit_amount)),
}
}
/**
* Unified booking-time duplicate guard. Returns the single best already-booked
* candidate for this bank line — a sibling transaction first (the cheaper,
* higher-confidence signal), then a ledger-only voucher. Null when neither
* fires. This is the function every booking chokepoint should call (web /book +
* /categorize routes and the agent commit executors) so all paths reject the
* same double-bookings.
*/
export async function detectBookingDuplicate(
supabase: SupabaseClient,
companyId: string,
target: BookingTarget,
): Promise<BookedDuplicateCandidate | null> {
const sibling = await detectBookedDuplicateTransaction(supabase, companyId, target)
if (sibling) return sibling
return detectLedgerDuplicateVoucher(supabase, companyId, target)
}