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
+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()