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
@@ -109,6 +109,13 @@ vi.mock('@/lib/transactions/category-suggestions', () => ({
getSuggestedCategories: vi.fn(),
}))
// The categorize tool runs the booking-time duplicate guard before staging.
// These tests don't exercise that path, so stub it to "no duplicate" — otherwise
// its detection queries would consume the queued supabase mock results.
vi.mock('@/lib/transactions/booking-duplicate-detection', () => ({
detectBookingDuplicate: vi.fn().mockResolvedValue(null),
}))
vi.mock('@/lib/bookkeeping/counterparty-templates', () => ({
upsertCounterpartyTemplate: vi.fn(),
findCounterpartyTemplatesBatch: vi.fn(),
+53 -2
View File
@@ -65,6 +65,8 @@ import { generateSIEExport } from '@/lib/reports/sie-export'
import { generateFullArchive, estimateArchiveSize } from '@/lib/reports/full-archive-export'
import { bookkeepingErrorResponse } from '@/lib/bookkeeping/errors'
import { getSuggestedCategories } from '@/lib/transactions/category-suggestions'
import { detectBookingDuplicate } from '@/lib/transactions/booking-duplicate-detection'
import { findDuplicatePaymentCandidatesForInvoice } from '@/lib/invoices/duplicate-payment-candidates'
import { renderToBuffer } from '@react-pdf/renderer'
import { InvoicePDF } from '@/lib/invoices/pdf-template'
import { getEmailService } from '@/lib/email/service'
@@ -2675,6 +2677,7 @@ export const tools: McpTool[] = [
vat_treatment: { type: 'string', description: 'VAT treatment override. Defaults to standard_25 for business expenses. Set reverse_charge ONLY when the underlag confirms the seller did NOT charge VAT (omvänd skattskyldighet). An invoice with foreign VAT already debited is NOT reverse charge.', enum: [...VALID_VAT_TREATMENTS] },
vat_amount: { type: 'number', exclusiveMinimum: 0, description: 'The underlag\'s exact moms (> 0) when it differs from rate × belopp — e.g. dricks carries no VAT. Requires a rate-based vat_treatment. Swedish moms only — foreign VAT is never deductible. For a 0-moms document use vat_treatment="exempt".' },
notes: { type: 'string', description: 'Audit-trail context appended to the verifikation description. For category=representation use this to record deltagare + syfte ("Anna Andersson (Acme AB), kundmöte om Y"). For project work, include the project ref. Keep under 200 chars; pure metadata, not a re-description of the transaction.' },
allow_duplicate: { type: 'boolean', description: 'Override the duplicate-booking guard (default false). Set true ONLY after the user confirms this bank line is a genuinely separate event — the guard blocks a second verifikat for an event already booked (e.g. a paid invoice or a salary payout).' },
},
required: ['transaction_id', 'category'],
},
@@ -2711,11 +2714,34 @@ export const tools: McpTool[] = [
// Fetch transaction description (and date for period_status) for the title
const { data: tx } = await supabase
.from('transactions')
.select('description, merchant_name, amount, currency, date')
.select('description, merchant_name, amount, currency, date, cash_account_id')
.eq('id', args.transaction_id as string)
.eq('company_id', companyId)
.single()
// Booking-time duplicate guard — surface a likely double-booking to the
// agent NOW (before staging) so it can link to the existing verifikat
// instead of queuing a second one for approval. The commit executor
// re-checks as the hard gate; this is the early, actionable signal.
// Mirrors the web /categorize route's guard.
if (args.allow_duplicate !== true && tx) {
const dup = await detectBookingDuplicate(supabase, companyId, {
id: args.transaction_id as string,
date: tx.date,
amount: tx.amount,
cash_account_id: (tx as { cash_account_id?: string | null }).cash_account_id ?? null,
})
if (dup) {
const amountAbs = roundOre(Math.abs(Number(tx.amount)))
const voucher = dup.voucher_label ? `verifikat ${dup.voucher_label}` : 'en befintlig verifikation'
throw new 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. Anropa igen med allow_duplicate=true först om det är en genuint separat affärshändelse.`,
)
}
}
const txDesc = tx
? `${tx.merchant_name || tx.description || 'Transaktion'} ${tx.amount} ${tx.currency}`
: String(args.transaction_id)
@@ -2731,6 +2757,7 @@ export const tools: McpTool[] = [
notes: typeof args.notes === 'string' && args.notes.trim().length > 0
? (args.notes as string).trim()
: null,
allow_duplicate: args.allow_duplicate === true,
},
{
debit_account: result.debit_account,
@@ -3705,6 +3732,7 @@ export const tools: McpTool[] = [
properties: {
invoice_id: { type: 'string', description: 'UUID of the invoice' },
payment_date: { type: 'string', description: 'Payment date YYYY-MM-DD (default: today)' },
allow_duplicate: { type: 'boolean', description: 'Override the duplicate-payment guard (default false). Set true ONLY after the user confirms; the guard blocks marking paid when an unlinked bank transaction already looks like this invoice\'s payment — match that transaction instead.' },
},
required: ['invoice_id'],
},
@@ -3733,9 +3761,32 @@ export const tools: McpTool[] = [
const paymentDate = (args.payment_date as string) || new Date().toISOString().split('T')[0]
// Duplicate-payment guard — surface a likely existing bank payment to the
// agent before staging, so it matches the transaction to the invoice
// instead of booking a parallel payment voucher (the orphan that later
// double-counts the receipt). The commit executor re-checks as the hard
// gate. Mirrors the web mark-paid route's guard.
if (args.allow_duplicate !== true && invoice.customer?.name) {
const remainingAmount =
(invoice as { remaining_amount?: number }).remaining_amount ?? invoice.total
const candidates = await findDuplicatePaymentCandidatesForInvoice(supabase, {
companyId,
invoice: { invoice_number: invoice.invoice_number, customer_name: invoice.customer.name },
paymentAmount: remainingAmount,
paymentDate,
})
if (candidates.length > 0) {
throw new Error(
`Möjlig dubbelbetalning: en obokförd banktransaktion ser ut att vara betalningen för faktura ` +
`${invoice.invoice_number}. Matcha banktransaktionen mot fakturan med gnubok_match_transaction_to_invoice ` +
`i stället. Anropa igen med allow_duplicate=true om det verkligen är en separat betalning.`,
)
}
}
return stagePendingOperation(supabase, companyId, userId, 'mark_invoice_paid',
`Betald: ${invoice.invoice_number} ${invoice.customer?.name || ''} ${invoice.total} ${invoice.currency}`,
{ invoice_id: invoiceId, payment_date: paymentDate },
{ invoice_id: invoiceId, payment_date: paymentDate, allow_duplicate: args.allow_duplicate === true },
{
invoice_number: invoice.invoice_number,
customer_name: invoice.customer?.name,