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
+59 -6
View File
@@ -20,6 +20,7 @@ import BookingTemplatePicker from '@/components/bookkeeping/BookingTemplatePicke
import CreatePeriodDialog from '@/components/bookkeeping/CreatePeriodDialog'
import { ActivateAccountsDialog } from '@/components/bookkeeping/ActivateAccountsDialog'
import { AddAccountDialog } from '@/components/bookkeeping/AddAccountDialog'
import DuplicateBookingDialog from '@/components/transactions/DuplicateBookingDialog'
import { Skeleton } from '@/components/ui/skeleton'
import {
useSubmitWithAccountActivation,
@@ -32,6 +33,7 @@ import { useUnsavedChanges } from '@/lib/hooks/use-unsaved-changes'
import { useCompany } from '@/contexts/CompanyContext'
import type { UploadedFile } from '@/components/bookkeeping/DocumentUploadZone'
import type { CreateJournalEntryLineInput, FiscalPeriod, BASAccount, JournalEntrySourceType, Currency } from '@/types'
import type { BookedDuplicateCandidate } from '@/lib/transactions/booking-duplicate-detection'
const CURRENCIES: { value: Currency; label: string }[] = [
{ value: 'SEK', label: 'SEK' },
@@ -109,6 +111,13 @@ export default function JournalEntryForm({
const [voucherSeries, setVoucherSeries] = useState(initialVoucherSeries ?? 'A')
const [nextVoucherNumber, setNextVoucherNumber] = useState<number | null>(null)
const [isSubmitting, setIsSubmitting] = useState(false)
// Booking-time duplicate guard (TRANSACTION_BOOK_POSSIBLE_DUPLICATE): the
// /book endpoint flags an already-booked sibling sharing date+amount+account.
// Surface it and let the user book anyway. The override is bound to the
// reviewed candidate via a ref the next submit reads — force is sent ONLY on
// that retry, never on a normal submit or to the manual journal-entry endpoint.
const [duplicateCandidate, setDuplicateCandidate] = useState<BookedDuplicateCandidate | null>(null)
const forceDuplicateRef = useRef<{ force: true; expected_duplicate_journal_entry_id: string } | null>(null)
const [showReview, setShowReview] = useState(false)
const [isSavingDraft, setIsSavingDraft] = useState(false)
const saveAsDraftRef = useRef(false)
@@ -569,6 +578,10 @@ export default function JournalEntryForm({
voucher_series: voucherSeries || 'A',
notes: notes || undefined,
lines: entryLines,
// Set only when retrying past the booking-time duplicate guard (see
// handleBookAnyway). Stripped by schemas that don't declare it, so a
// stray value never reaches the manual journal-entry endpoint.
...(forceDuplicateRef.current ?? {}),
}),
})
return (await throwOnStructuredError(res)) as { data?: { id?: string; voucher_series?: string; voucher_number?: number }; journal_entry_id?: string }
@@ -628,18 +641,51 @@ export default function JournalEntryForm({
if (err instanceof Error && err.message === 'cancelled') {
// User dismissed the activation dialog — no toast needed
} else {
const anyErr = err as { body?: unknown; status?: number }
toast({
title: t('toast_create_failed'),
description: getErrorMessage(anyErr.body ?? err, { context: 'journal_entry', statusCode: anyErr.status }),
variant: 'destructive',
})
const anyErr = err as {
body?: { error?: { code?: string; details?: { candidate?: BookedDuplicateCandidate } } }
status?: number
}
const candidate = anyErr.body?.error?.details?.candidate
if (anyErr.body?.error?.code === 'TRANSACTION_BOOK_POSSIBLE_DUPLICATE' && candidate) {
// Soft duplicate guard fired — don't dead-end on a toast that merely
// says "book anyway". Open the dialog so the user can review the
// existing verifikat or confirm. handleBookAnyway re-submits with
// force bound to this candidate.
setDuplicateCandidate(candidate)
} else {
toast({
title: t('toast_create_failed'),
description: getErrorMessage(anyErr.body ?? err, { context: 'journal_entry', statusCode: anyErr.status }),
variant: 'destructive',
})
}
}
} finally {
setIsSubmitting(false)
}
}
// Retry the booking past the duplicate guard. force is bound to the reviewed
// candidate via the ref; cleared afterwards so a later normal submit can't
// inherit it. handleConfirm runs its full success path (toast, reset,
// onEntryCreated) exactly as a first-try booking would.
const handleBookAnyway = async () => {
const candidate = duplicateCandidate
if (!candidate) return
forceDuplicateRef.current = {
force: true,
// Bind on the voucher id — present on both a sibling-transaction candidate
// and a ledger-only voucher candidate (which has no transaction_id).
expected_duplicate_journal_entry_id: candidate.journal_entry_id,
}
setDuplicateCandidate(null)
try {
await handleConfirm()
} finally {
forceDuplicateRef.current = null
}
}
const handleSaveDraft = async () => {
if (!selectedPeriod || !description || !isBalanced || periodMismatch) return
setIsSavingDraft(true)
@@ -1420,6 +1466,13 @@ export default function JournalEntryForm({
</DialogFooter>
</DialogContent>
</Dialog>
<DuplicateBookingDialog
candidate={duplicateCandidate}
processing={isSubmitting}
onCancel={() => setDuplicateCandidate(null)}
onBookAnyway={handleBookAnyway}
/>
</div>
)
+28 -2
View File
@@ -5,7 +5,7 @@ import { useState } from 'react'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { BankNameCombobox } from '@/components/settings/BankNameCombobox'
import { validateBankgiroNumber, formatBankgiroNumber } from '@/lib/bankgiro/luhn'
import { validateBankgiroNumber, formatBankgiroNumber, validatePlusgiroNumber, formatPlusgiroNumber } from '@/lib/bankgiro/luhn'
import { normaliseSwish, isValidSwish } from '@/lib/payments/swish'
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
import type { CompanySettings } from '@/types'
@@ -17,6 +17,7 @@ interface BankDetailsFormProps {
export function BankDetailsForm({ settings }: BankDetailsFormProps) {
const t = useTranslations('settings_bank_details_form')
const [bankgiroError, setBankgiroError] = useState<string | null>(null)
const [plusgiroError, setPlusgiroError] = useState<string | null>(null)
const [clearingError, setClearingError] = useState<string | null>(null)
const [accountNumberError, setAccountNumberError] = useState<string | null>(null)
const [swishError, setSwishError] = useState<string | null>(null)
@@ -81,7 +82,7 @@ export function BankDetailsForm({ settings }: BankDetailsFormProps) {
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="space-y-2">
<Label htmlFor="bankgiro">{t('bankgiro_label')}</Label>
<Input
@@ -103,6 +104,27 @@ export function BankDetailsForm({ settings }: BankDetailsFormProps) {
{bankgiroError && <p className="text-xs text-destructive">{bankgiroError}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="plusgiro">{t('plusgiro_label')}</Label>
<Input
id="plusgiro"
name="plusgiro"
placeholder="XXXXXX-X"
defaultValue={settings.plusgiro || ''}
onBlur={(e) => {
const val = e.target.value.trim()
if (!val) { setPlusgiroError(null); return }
if (validatePlusgiroNumber(val)) {
e.target.value = formatPlusgiroNumber(val)
setPlusgiroError(null)
} else {
setPlusgiroError(t('plusgiro_error'))
}
}}
/>
{plusgiroError && <p className="text-xs text-destructive">{plusgiroError}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="swish">{t('swish_label')}</Label>
<Input
@@ -134,6 +156,7 @@ export function validateBankFields(formData: FormData): { field: string; message
const clearing = (formData.get('clearing_number') as string || '').trim()
const account = (formData.get('account_number') as string || '').trim()
const bankgiro = (formData.get('bankgiro') as string || '').trim()
const plusgiro = (formData.get('plusgiro') as string || '').trim()
const swish = normaliseSwish(formData.get('swish') as string)
if (clearing && !/^\d{4,5}$/.test(clearing)) {
@@ -145,6 +168,9 @@ export function validateBankFields(formData: FormData): { field: string; message
if (bankgiro && !validateBankgiroNumber(bankgiro)) {
errors.push({ field: 'bankgiro', message: 'Ogiltigt bankgironummer' })
}
if (plusgiro && !validatePlusgiroNumber(plusgiro)) {
errors.push({ field: 'plusgiro', message: 'Ogiltigt plusgironummer' })
}
if (swish && !isValidSwish(swish)) {
errors.push({ field: 'swish', message: 'Ogiltigt Swish-nummer (företagsnummer 123XXXXXXX eller mobilnummer 07XXXXXXXX)' })
}
@@ -11,6 +11,7 @@ import { SettingsLoadingSkeleton } from '@/components/settings/SettingsLoadingSk
import { useSettings } from '@/components/settings/useSettings'
import { useToast } from '@/components/ui/use-toast'
import { normaliseSwish } from '@/lib/payments/swish'
import { formatPlusgiroNumber } from '@/lib/bankgiro/luhn'
import type { CompanySettings } from '@/types'
export function InvoicingSettingsContent() {
@@ -37,6 +38,9 @@ export function InvoicingSettingsContent() {
clearing_number: formData.get('clearing_number') as string,
account_number: formData.get('account_number') as string,
bankgiro: (formData.get('bankgiro') as string) || null,
plusgiro: (formData.get('plusgiro') as string)?.trim()
? formatPlusgiroNumber((formData.get('plusgiro') as string).trim())
: null,
swish: normaliseSwish(formData.get('swish') as string) || null,
invoice_prefix: (formData.get('invoice_prefix') as string) || null,
next_invoice_number: parseInt(formData.get('next_invoice_number') as string) || 1,
@@ -0,0 +1,94 @@
'use client'
import { useTranslations } from 'next-intl'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { formatCurrency, formatDate } from '@/lib/utils'
import type { BookedDuplicateCandidate } from '@/lib/transactions/booking-duplicate-detection'
/**
* Soft warning shown when the booking-time duplicate guard fires
* (TRANSACTION_BOOK_POSSIBLE_DUPLICATE): another already-booked transaction
* shares this one's date + amount + bank account. Never a hard block —
* genuinely repeated same-day payments (e.g. several identical Swish transfers)
* are legitimate, so the user can review the existing verifikat or book anyway.
*
* Shared by the /transactions list (runCategorize) and the manual booking
* dialog (JournalEntryForm → /api/transactions/[id]/book). The caller owns the
* retry: "Bokför ändå" must re-issue the request with force=true bound to
* `candidate.journal_entry_id` via `expected_duplicate_journal_entry_id` — it
* is present on both candidate kinds (a sibling-transaction candidate and a
* ledger-only voucher candidate, which has no transaction_id), and the server
* re-detects it so a stale id can't wave the guard away.
*/
export default function DuplicateBookingDialog({
candidate,
processing = false,
onBookAnyway,
onCancel,
}: {
/** The already-booked sibling, or null to keep the dialog closed. */
candidate: BookedDuplicateCandidate | null
processing?: boolean
onBookAnyway: () => void
onCancel: () => void
}) {
const t = useTranslations('transactions')
return (
<Dialog
open={candidate !== null}
onOpenChange={(open) => {
if (!open) onCancel()
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('dialog_duplicate_title')}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<p className="text-sm text-muted-foreground">{t('dialog_duplicate_body')}</p>
{candidate && (
<div className="space-y-1 rounded-md border bg-muted/30 p-3">
<div className="flex items-center justify-between gap-3 text-sm">
<span className="font-medium">
{candidate.voucher_label
? t('dialog_duplicate_voucher_label', { label: candidate.voucher_label })
: t('dialog_duplicate_voucher_generic')}
</span>
<span className="tabular-nums">{formatCurrency(candidate.amount)}</span>
</div>
<div className="text-xs text-muted-foreground tabular-nums">
{formatDate(candidate.entry_date)}
</div>
{candidate.description && (
<div className="truncate text-xs text-muted-foreground">{candidate.description}</div>
)}
</div>
)}
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:items-center sm:justify-between">
{candidate && (
<Button asChild variant="ghost" size="sm" className="text-muted-foreground sm:mr-auto">
<a
href={`/bookkeeping/${candidate.journal_entry_id}`}
target="_blank"
rel="noopener noreferrer"
>
{t('dialog_duplicate_view_voucher')}
</a>
</Button>
)}
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<Button variant="outline" onClick={onCancel} disabled={processing}>
{t('dialog_duplicate_cancel')}
</Button>
<Button onClick={onBookAnyway} disabled={processing}>
{t('dialog_duplicate_book_anyway')}
</Button>
</div>
</div>
</div>
</DialogContent>
</Dialog>
)
}
+25 -10
View File
@@ -345,21 +345,36 @@ export default function TemplatePicker({
onSelect(template)
}
// Click a raw library card. Convertible → fast QuickReview path via onSelect.
// Non-convertible → route to manual booking dialog pre-filled via the new
// onPickLibraryTemplate callback. MRU is only bumped after we confirm the
// click will actually do something — otherwise consumers that omit the
// callback would corrupt MRU ordering for templates the user never applied.
// Click a raw library card. Always book a user's mall from its LITERAL lines
// via the journal-entry editor (onPickLibraryTemplate → applyTemplate → /book),
// for both convertible and non-convertible shapes.
//
// The old "convertible → onSelect(converted)" branch routed through the
// QuickReview fast path, which books a single category + one account_override
// and silently discards the template's chosen debit/credit. A kundinbetalning
// mall (D 1930 / K 1510) came out 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 the converter happened to infer from the business/settlement tags.
// Routing every library template through the editor books exactly the accounts
// the user defined, independent of those tags. See template-library.test.ts.
//
// MRU is only bumped once we know the click will do something — otherwise a
// consumer that omits onPickLibraryTemplate would reorder MRU for a template
// the user never actually applied.
const handleSelectLibraryRaw = (raw: BookingTemplateLibrary) => {
if (onPickLibraryTemplate) {
bumpLibraryMru(raw.id)
onPickLibraryTemplate(raw)
return
}
// Fallback only for consumers that didn't wire the editor path: fall back to
// the lossy converted shape rather than leaving the click dead. The single
// render site (the transactions page) always passes onPickLibraryTemplate,
// so this branch is not reached in the app today.
const converted = convertedById.get(raw.id) ?? null
if (converted) {
bumpLibraryMru(raw.id)
onSelect(converted)
return
}
if (onPickLibraryTemplate) {
bumpLibraryMru(raw.id)
onPickLibraryTemplate(raw)
}
}
+14 -9
View File
@@ -15,15 +15,20 @@ const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 overflow-hidden rounded-lg border border-border/60 bg-popover px-3 py-2 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className
)}
{...props}
/>
// Portal the content to document.body so the tooltip is never clipped by an
// ancestor with overflow (e.g. a scrollable DialogContent — the send-invoice
// and journal-review dialogs use overflow-y-auto, which otherwise crops it).
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 overflow-hidden rounded-lg border border-border/60 bg-popover px-3 py-2 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className
)}
{...props}
/>
</TooltipPrimitive.Portal>
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName