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
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user