Files
accounted/components/transactions/QuickReviewDialog.tsx
T
Jakob Wennberg 858ad49852 fix(booking): explicit Ingen moms in quick review books exempt, not the derived default (#1450)
QuickReviewDialog collapsed 'none' to undefined on the wire, so the server
re-derived the category default and booked 25% moms against an explicit
no-VAT choice while the preview showed no VAT line. 'none' now maps to
'exempt' only when it deviates from the seeded default: untouched bookings
on exempt-default categories (bank fees) stay byte-identical, an explicit
Ingen moms books no VAT line with the classification the momsdeklaration
should see, and the class-2 account auto-'none' stops booking a stray moms
line. Same gap the swedish-compliance bot flagged on #1443.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 14:58:46 +02:00

649 lines
27 KiB
TypeScript

'use client'
import { useState, useEffect, useCallback } from 'react'
import { useRouter } from 'next/navigation'
import { useTranslations } from 'next-intl'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'
import { useToast } from '@/components/ui/use-toast'
import { ToastAction } from '@/components/ui/toast'
import { formatCurrency, formatDate } from '@/lib/utils'
import { linkDocuments, formatFailedDocumentNames } from '@/lib/documents/link-documents'
import { ArrowUpRight, ArrowDownRight, Check, Paperclip, ChevronDown, ChevronUp, AlertTriangle } from 'lucide-react'
import { getDefaultAccountForCategory } from '@/lib/bookkeeping/category-mapping'
import { isCounterpartyTemplateId } from '@/lib/bookkeeping/counterparty-templates'
import { getVatRate } from '@/lib/bookkeeping/vat-entries'
import type { ReviewTemplate } from '@/lib/transactions/quick-review-defaults'
import { resolveExplicitVat } from '@/lib/transactions/quick-review-defaults'
import { resolveSekAmount } from '@/lib/bookkeeping/currency-utils'
import { formatAccountWithName } from '@/lib/bookkeeping/client-account-names'
import JournalEntryPreview from './JournalEntryPreview'
import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
import LineDimensionFields from '@/components/dimensions/LineDimensionFields'
import DocumentUploadZone from '@/components/bookkeeping/DocumentUploadZone'
import DocumentViewerPane from '@/components/bookkeeping/DocumentViewerPane'
import type { UploadedFile } from '@/components/bookkeeping/DocumentUploadZone'
import VatTreatmentSelect from './VatTreatmentSelect'
import { VAT_TREATMENT_OPTIONS } from './transaction-types'
import type { TransactionWithInvoice } from './transaction-types'
import type { TransactionCategory, VatTreatment, BASAccount, EntityType, LinePatternEntry } from '@/types'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
interface QuickReviewDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
transaction: TransactionWithInvoice | null
category: TransactionCategory | null
categoryLabel: string
/** Empty string when there is no sensible default: never undefined. */
defaultAccount: string
defaultVat: VatTreatment | 'none'
entityType?: EntityType
template?: ReviewTemplate | null
templateId?: string
counterpartyLinePattern?: LinePatternEntry[] | null
/**
* Learned bag from the counterparty template (default_dimensions): prefills
* the picker so the user sees, and can change, what the booking will be
* tagged with.
*/
counterpartyDefaultDimensions?: Record<string, string> | null
onConfirm: (
id: string,
category: TransactionCategory,
vatTreatment: VatTreatment | undefined,
accountOverride: string | undefined,
templateId?: string,
dimensions?: Record<string, string>
) => Promise<string | null>
onChangeTemplate?: () => void
}
export default function QuickReviewDialog({
open,
onOpenChange,
transaction,
category,
categoryLabel,
defaultAccount,
defaultVat,
entityType,
template,
templateId,
counterpartyLinePattern,
counterpartyDefaultDimensions,
onConfirm,
onChangeTemplate,
}: QuickReviewDialogProps) {
const t = useTranslations('tx_quick_review')
const tCat = useTranslations('tx_categories')
const { toast } = useToast()
const router = useRouter()
// `?? ''` is deliberate belt-and-braces: the prop is a required string, but
// a caller that hands over a template-shaped object missing debit_account
// used to make this undefined and take the whole page down on the
// .startsWith() below. An empty account disables the confirm button; it
// never throws.
const [accountOverride, setAccountOverride] = useState(defaultAccount ?? '')
const [vatTreatment, setVatTreatment] = useState<VatTreatment | 'none'>(defaultVat)
const [accounts, setAccounts] = useState<BASAccount[]>([])
const [isProcessing, setIsProcessing] = useState(false)
const [error, setError] = useState<string | null>(null)
const [uploadedFiles, setUploadedFiles] = useState<UploadedFile[]>([])
const [showUploadZone, setShowUploadZone] = useState(false)
const [showVatDropdown, setShowVatDropdown] = useState(false)
// Mirror of `transaction` so we can patch in a freshly-fetched SEK conversion
// before the user confirms: the verifikation must always be in SEK and the
// engine reads these fields straight off the transaction row.
const [enrichedTx, setEnrichedTx] = useState<TransactionWithInvoice | null>(transaction)
const [rateLoading, setRateLoading] = useState(false)
const [rateError, setRateError] = useState<string | null>(null)
// Dimension tagging (kostnadsställe/projekt): the picker renders only when
// company_settings.dimensions_enabled, same gate as BulkBookDialog. Seeded
// from the counterparty template's learned bag so the user sees what the
// booking will carry and can change it.
const [dimensionsEnabled, setDimensionsEnabled] = useState(false)
const [dims, setDims] = useState<Record<string, string>>(
() => ({ ...(counterpartyDefaultDimensions ?? {}) }),
)
const preAttachedDocumentId = transaction?.document_id ?? null
// Handle account changes: clear VAT for liability/equity accounts (class 2)
const handleAccountChange = useCallback((account: string) => {
setAccountOverride(account ?? '')
if (account?.startsWith('2')) {
setVatTreatment('none')
}
}, [])
// Fetch accounts on mount
useEffect(() => {
async function fetchAccounts() {
try {
const res = await fetch('/api/bookkeeping/accounts')
const { data } = await res.json()
if (data) {
setAccounts(data)
}
} catch {
// Non-critical
}
}
fetchAccounts()
}, [])
// Reset local mirror whenever the underlying transaction changes (the parent
// reuses the dialog instance across rows).
useEffect(() => {
setEnrichedTx(transaction)
setRateError(null)
setDims({ ...(counterpartyDefaultDimensions ?? {}) })
// Re-seeding on counterpartyDefaultDimensions alone would clobber in-
// flight edits; the bag only changes together with the transaction.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [transaction])
// Company settings gate the dimension affordance (dimensions_enabled).
// Fetched once per open; on failure the picker simply stays hidden.
useEffect(() => {
if (!open) return
let cancelled = false
fetch('/api/settings')
.then((r) => r.json())
.then(({ data }) => {
if (!cancelled) setDimensionsEnabled(data?.dimensions_enabled === true)
})
.catch(() => {
if (!cancelled) setDimensionsEnabled(false)
})
return () => { cancelled = true }
}, [open])
// Backfill the SEK conversion on demand. resolveSekAmount silently falls
// back to the raw foreign amount when amount_sek/exchange_rate are null,
// which means the user would see misleading "kr" values in the verifikation
// and the engine would post the wrong number to the books.
useEffect(() => {
if (!open || !transaction) return
const needsRate =
!!transaction.currency &&
transaction.currency !== 'SEK' &&
(transaction.amount_sek == null || transaction.exchange_rate == null)
if (!needsRate) return
let cancelled = false
setRateLoading(true)
setRateError(null)
;(async () => {
try {
const res = await fetch(`/api/transactions/${transaction.id}/refresh-exchange-rate`, {
method: 'POST',
})
const json = await res.json()
if (cancelled) return
if (!res.ok) {
setRateError(getUserErrorMessage(json?.error) || t('exchange_rate_fetch_failed'))
return
}
if (json?.data) {
setEnrichedTx({ ...json.data, ...{
potential_invoice: transaction.potential_invoice,
potential_supplier_invoice: transaction.potential_supplier_invoice,
} })
}
} catch {
if (!cancelled) setRateError(t('exchange_rate_fetch_failed'))
} finally {
if (!cancelled) setRateLoading(false)
}
})()
return () => {
cancelled = true
}
}, [open, transaction, t])
if (!transaction || !category) return null
const tx = enrichedTx ?? transaction
const isIncome = tx.amount > 0
// Keyed off the template ID, not off the presence of a line pattern: a
// *learned* counterparty (one business line, no pattern) is still booked
// server-side from counterparty_template_id, so its accounts and VAT come
// from the stored template. Deciding this from counterparties that happen
// to have a multi-line pattern made single-line ones fall through to the
// category branch, which previewed the wrong accounts and offered an
// account/VAT editor whose values the categorize route discards.
const isCounterpartyTemplate = !!template?.id && isCounterpartyTemplateId(template.id)
const hasCounterpartyPattern = !!(counterpartyLinePattern && counterpartyLinePattern.length > 0)
const isTemplateBooking = !!templateId || isCounterpartyTemplate
const isLiabilityAccount = accountOverride?.startsWith('2') ?? false
// For non-SEK transactions, the verifikation and the headline must show
// the SEK-converted total: the mall/category booking always posts in SEK.
const sekAmount = resolveSekAmount(
tx.amount,
tx.amount_sek,
tx.currency,
tx.exchange_rate
)
const isForeign = !!(tx.currency && tx.currency !== 'SEK')
const sekConversionMissing = isForeign && (tx.amount_sek == null || tx.exchange_rate == null)
// Dimensions carried by the counterparty template's line pattern (dimensions
// PR7). Business lines may each carry a {sie_dim_no: code} bag: merge them
// into one compact display label ("KS01 · P001", dim-number order). This is
// display-only: booking applies the pattern's bags server-side.
const patternDims: Record<string, string> = {}
for (const line of counterpartyLinePattern ?? []) {
if (line.dimensions) Object.assign(patternDims, line.dimensions)
}
const patternDimsLabel = Object.entries(patternDims)
.filter(([, code]) => code)
.sort(([a], [b]) => Number(a) - Number(b))
.map(([, code]) => code)
.join(' · ')
async function handleConfirm() {
if (!category || !transaction) return
setIsProcessing(true)
setError(null)
try {
// 'none' as the seeded default stays off the wire (server derives, no
// VAT line); 'none' as a user deviation goes as explicit 'exempt'. The
// old unconditional collapse re-derived the default server-side and
// booked 25% moms against an explicit "Ingen moms" while the preview
// showed none. See resolveExplicitVat.
const resolvedVat = resolveExplicitVat(vatTreatment, defaultVat)
const catDefault = getDefaultAccountForCategory(category)
const override = accountOverride && accountOverride !== catDefault
? accountOverride
: undefined
// Cleared combobox values leave empty strings behind; strip them so an
// untouched picker sends no bag at all (learned template bags then apply
// server-side unchanged).
const cleanedDims = Object.fromEntries(
Object.entries(dims).filter(([, code]) => code && code.trim().length > 0),
)
const journalEntryId = await onConfirm(
transaction.id,
category,
resolvedVat,
override,
templateId,
Object.keys(cleanedDims).length > 0 ? cleanedDims : undefined,
)
// Attach the uploaded underlag to the verifikat the booking just created.
// BFL 5 kap 7 § requires the verifikation to reference its underlag and
// BFL 7 kap requires that underlag to be archived with it; the verifikat
// is already committed here, so a failed link can only be reported, not
// undone. The parent's "Bokförd" toast must not be the last word when a
// receipt never made it onto the books.
if (journalEntryId && uploadedFiles.length > 0) {
const targets = uploadedFiles
.filter((f) => f.status === 'uploaded' && f.id)
.map((f) => ({ documentId: f.id as string, fileName: f.fileName }))
const { failed } = await linkDocuments(targets, journalEntryId)
if (failed.length > 0) {
toast({
title: t('doc_link_failed_booked_title'),
description: t('doc_link_failed_booked_description', {
count: failed.length,
files: formatFailedDocumentNames(failed),
}),
variant: 'destructive',
action: (
<ToastAction
altText={t('doc_link_open_entry')}
onClick={() => router.push(`/bookkeeping/${journalEntryId}`)}
>
{t('doc_link_open_entry')}
</ToastAction>
),
})
// By this point the parent has already closed the dialog (onConfirm
// resolved before linkDocuments did), so this component's file state
// is invisible either way: the toast above, with its open-entry
// action, is the user's actual pointer to the underlag that did not
// attach. The early return just skips the redundant cleanup below.
return
}
}
setUploadedFiles([])
setShowUploadZone(false)
} catch {
setError(t('generic_error'))
} finally {
// Always reset isProcessing: without this, an onConfirm that resolves
// with null (e.g. server returned a structured 4xx error like
// ACCOUNTS_NOT_IN_CHART) leaves the dialog frozen because the
// <Dialog onOpenChange> below disables backdrop/ESC while processing.
setIsProcessing(false)
}
}
return (
<Dialog open={open} onOpenChange={isProcessing ? undefined : (o) => {
if (!o) {
setUploadedFiles([])
setShowUploadZone(false)
}
onOpenChange(o)
}}>
<DialogContent className={preAttachedDocumentId ? 'max-w-6xl max-h-[90vh] overflow-y-auto' : 'max-w-md sm:max-w-lg max-h-[85vh] overflow-y-auto'}>
<DialogHeader>
<DialogTitle>{t('title')}</DialogTitle>
<DialogDescription>
{isTemplateBooking ? t('description_template') : t('description_default')}
</DialogDescription>
</DialogHeader>
{/* When a document is pre-attached, show it side-by-side (receipt left,
review right). With no document the wrappers use display:contents so
the dialog collapses to the original single-column layout. */}
<div className={preAttachedDocumentId ? 'grid grid-cols-1 gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,520px)]' : 'contents'}>
{preAttachedDocumentId && (
<div className="h-[45vh] lg:sticky lg:top-0 lg:h-[72vh] lg:self-start">
<DocumentViewerPane documentId={preAttachedDocumentId} className="h-full" />
</div>
)}
<div className={preAttachedDocumentId ? 'space-y-4' : 'contents'}>
{/* Transaction summary */}
<div className="flex items-center gap-3 rounded-lg border p-3">
<div
className={`h-9 w-9 rounded-full flex items-center justify-center flex-shrink-0 ${isIncome ? 'text-success' : 'text-destructive'}`}
>
{isIncome ? (
<ArrowUpRight className="h-4 w-4" />
) : (
<ArrowDownRight className="h-4 w-4" />
)}
</div>
<div className="flex-1 min-w-0">
<p className="font-medium text-sm break-all">{tx.description}</p>
<p className="text-xs text-muted-foreground">{formatDate(tx.date)}</p>
</div>
<div className="text-right flex-shrink-0">
{isForeign ? (
<>
<p className={`font-medium text-sm tabular-nums ${isIncome ? 'text-success' : ''}`}>
{isIncome ? '+' : ''}
{formatCurrency(tx.amount, tx.currency)}
</p>
<p className="text-xs text-muted-foreground tabular-nums">
{rateLoading || sekConversionMissing
? t('amount_loading')
: t('amount_approx', { sign: isIncome ? '+' : '', sek: formatCurrency(sekAmount, 'SEK') })}
</p>
</>
) : (
<p className={`font-medium text-sm tabular-nums ${isIncome ? 'text-success' : ''}`}>
{isIncome ? '+' : ''}
{formatCurrency(sekAmount, 'SEK')}
</p>
)}
</div>
</div>
{isForeign && tx.exchange_rate != null && tx.exchange_rate_date && !sekConversionMissing && (
<p className="text-xs text-muted-foreground -mt-1">
{t('rate_footnote', {
rate: formatCurrency(tx.exchange_rate, 'SEK'),
currency: tx.currency,
date: formatDate(tx.exchange_rate_date),
})}
</p>
)}
{rateError && (
<div className="rounded-lg border border-destructive/30 bg-destructive/[0.05] px-3 py-2">
<p className="text-xs text-destructive leading-snug">{rateError}</p>
</div>
)}
{/* Template or Category */}
<div>
<label className="text-sm font-medium text-muted-foreground">
{isCounterpartyTemplate ? t('label_counterparty_template') : template ? t('label_template') : t('label_category')}
</label>
<div className="mt-1 flex items-center gap-2">
<span className="text-sm font-medium text-foreground">
{template ? template.name_sv : categoryLabel}
</span>
{patternDimsLabel && (
<Badge variant="secondary" className="font-mono tabular-nums">
{patternDimsLabel}
</Badge>
)}
{onChangeTemplate && !hasCounterpartyPattern && (
<button
type="button"
className="text-xs text-primary hover:underline"
onClick={onChangeTemplate}
>
{t('change_template')}
</button>
)}
</div>
{/* Only when there IS a single debit/credit pair to show: a
multi-line counterparty pattern has none, and a template that
never carried accounts would render "D: → K: ". */}
{!hasCounterpartyPattern && template?.debit_account && template?.credit_account && (
<p className="mt-1.5 text-xs font-mono text-muted-foreground">
D: {formatAccountWithName(template.debit_account)} K: {formatAccountWithName(template.credit_account)}
</p>
)}
</div>
{/* Template special rules */}
{template?.special_rules_sv && (
<div className="rounded-lg border border-warning/30 bg-warning/[0.03] px-3 py-2">
<p className="text-xs text-warning-foreground leading-snug">
{template.special_rules_sv}
</p>
</div>
)}
{/* Deductibility note */}
{template?.deductibility_note_sv && (
<div className="rounded-lg border border-primary/20 bg-primary/[0.03] px-3 py-2">
<p className="text-xs text-foreground leading-snug">
{template.deductibility_note_sv}
</p>
</div>
)}
{/* Reverse charge warning */}
{template?.requires_vat_registration_data && (
<div className="rounded-lg border border-warning/30 bg-warning/[0.03] px-3 py-2">
<div className="flex items-start gap-2">
<AlertTriangle className="h-3.5 w-3.5 text-warning-foreground flex-shrink-0 mt-0.5" />
<p className="text-xs text-warning-foreground leading-snug">
{t('reverse_charge_warning')}
</p>
</div>
</div>
)}
{/* Journal entry preview: hidden until we have a SEK conversion;
otherwise we'd render a verifikation in the wrong currency. */}
{!sekConversionMissing && !rateLoading && (
<JournalEntryPreview
amount={tx.amount}
amountSek={sekAmount}
{...(hasCounterpartyPattern
? { linePattern: counterpartyLinePattern ?? undefined }
: isTemplateBooking && template?.debit_account && template?.credit_account
? {
templateDebitAccount: template.debit_account,
templateCreditAccount: template.credit_account,
// A counterparty template carries a treatment but no rate,
// and its legacy booking path emits an input-VAT leg from
// that treatment only (no basbelopp pair), so it gets the
// rate alone: passing the treatment too would preview
// reverse-charge lines the engine never books.
templateVatRate: isCounterpartyTemplate
? (template.vat_treatment ? getVatRate(template.vat_treatment) : 0)
: template.vat_rate,
...(isCounterpartyTemplate
? {}
: {
templateVatTreatment: template.vat_treatment,
templateSupplierType: template.reverse_charge_supplier_type,
}),
}
: { category, vatTreatment: isLiabilityAccount ? 'none' : vatTreatment, accountOverride, entityType }
)}
/>
)}
{/* Account & VAT: hidden for template bookings (accounts defined by the template) */}
{!isTemplateBooking && (
<>
<div>
<label className="text-sm font-medium text-muted-foreground">{t('label_account')}</label>
<div className="mt-1">
<AccountCombobox
value={accountOverride}
accounts={accounts}
onChange={handleAccountChange}
/>
</div>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">{t('label_vat_treatment')}</label>
<div className="mt-1">
{isLiabilityAccount ? (
<p className="text-sm text-muted-foreground">
{t('no_vat_liability_account')}
</p>
) : showVatDropdown ? (
<VatTreatmentSelect
value={vatTreatment}
onValueChange={setVatTreatment}
/>
) : (
<p className="text-sm">
{(() => {
const opt = VAT_TREATMENT_OPTIONS.find(o => o.value === vatTreatment)
return opt ? tCat(opt.labelKey) : t('no_vat_default')
})()}
{' '}
<button
type="button"
className="text-xs text-primary hover:underline"
onClick={() => setShowVatDropdown(true)}
>
{t('change')}
</button>
</p>
)}
</div>
</div>
</>
)}
{/* Dimension tags (kostnadsställe/projekt): rendered for category,
library-template and legacy counterparty bookings. Multi-line
counterparty patterns are excluded: their per-line bags are
authoritative server-side and an edit here would be ignored. */}
{dimensionsEnabled && !hasCounterpartyPattern && (
<div>
<label className="text-sm font-medium text-muted-foreground">{t('label_dimensions')}</label>
<div className="mt-1">
<LineDimensionFields
dimensions={dims}
onChange={(sieDimNo, code) => {
setDims((prev) => {
const next = { ...prev }
if (code) next[sieDimNo] = code
else delete next[sieDimNo]
return next
})
}}
inputClassName="h-8"
/>
</div>
</div>
)}
{/* No pre-attached document: let the user upload one. (When a document
IS pre-attached it's shown in the left preview column instead.) */}
{!preAttachedDocumentId && (
<div className="rounded-lg border">
<button
type="button"
onClick={() => setShowUploadZone(!showUploadZone)}
className="flex items-center justify-between w-full px-3 py-2.5 text-sm hover:bg-muted/50 transition-colors"
>
<div className="flex items-center gap-2">
<Paperclip className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">{t('doc_label')}</span>
{uploadedFiles.filter((f) => f.status === 'uploaded').length > 0 && (
<span className="text-xs text-muted-foreground">
{t('doc_attached_count', { count: uploadedFiles.filter((f) => f.status === 'uploaded').length })}
</span>
)}
</div>
{showUploadZone ? (
<ChevronUp className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
)}
</button>
{showUploadZone && (
<div className="px-3 pb-3">
<DocumentUploadZone
files={uploadedFiles}
onFilesChange={setUploadedFiles}
compact
/>
</div>
)}
</div>
)}
{error && (
<div className="p-3 rounded-lg bg-destructive/10 text-destructive text-sm">
{error}
</div>
)}
{/* Actions */}
<div className="flex gap-2 pt-2">
<Button
variant="outline"
className="flex-1"
onClick={() => onOpenChange(false)}
disabled={isProcessing}
>
{t('cancel')}
</Button>
<Button
className="flex-1"
onClick={handleConfirm}
disabled={
isProcessing ||
(!isTemplateBooking && !accountOverride) ||
rateLoading ||
sekConversionMissing
}
>
<Check className="mr-2 h-4 w-4" />
{isProcessing ? t('booking') : rateLoading ? t('fetching_rate') : t('book')}
</Button>
</div>
</div>
</div>
</DialogContent>
</Dialog>
)
}