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:
co-authored by
Claude Opus 4.8
parent
bc09cea07e
commit
9ed0b9515a
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user