Feat/voucher docs (#664)

* feat: implement inbox document picker and linking functionality

* feat: implement self-billing invoice functionality

- Added support for registering self-billed invoices received from customers.
- Updated the invoice schema to include fields for self-billing metadata such as `is_self_billed`, `external_invoice_number`, `self_billing_agreement_ref`, and `received_date`.
- Created API route for handling self-billed invoice submissions, including validation and error handling.
- Implemented database migrations to add necessary columns and constraints for self-billing invoices.
- Developed tests to ensure correct behavior of self-billing invoice creation and validation rules.
- Updated Swedish localization files to include new terms related to self-billing.

* feat: enforce SIE import requirement for non-Fortnox providers in migration process

* feat: streamline invoice processing and enhance error logging across APIs
This commit is contained in:
Mattsson
2026-06-04 13:14:57 +02:00
committed by GitHub
parent c1be9f15dd
commit 3e42fc6f32
45 changed files with 2675 additions and 529 deletions
@@ -0,0 +1,329 @@
'use client'
import { useState, useEffect, useMemo } from 'react'
import { useTranslations } from 'next-intl'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { Input } from '@/components/ui/input'
import { useToast } from '@/components/ui/use-toast'
import { cn, formatCurrency, formatDate } from '@/lib/utils'
import { FileText, ImageIcon, Loader2, Search, Inbox, Eye } from 'lucide-react'
// InboxDocumentPicker
//
// Opens from JournalEntryAttachments ("Välj från inkorgen"). Lists invoice-inbox
// documents that have not yet been consumed (no supplier invoice, no journal
// entry, not matched to a transaction, document not already linked) so the user
// can attach one as underlag to the current verifikat. Picking one links the
// document to the journal entry AND stamps the inbox item so it drops out of the
// active inbox — see app/api/documents/[id]/link/route.ts.
//
// Each row carries a preview button (eye) that opens a quick dialog rendering
// the document inline, so the user can confirm the right file before attaching.
// Attaching is the row's primary click (fast path) and is also offered from
// inside the preview dialog (preview → confirm).
interface AvailableInboxDoc {
inbox_item_id: string
document_id: string
file_name: string
mime_type: string | null
file_size_bytes: number
source: string | null
created_at: string
supplier_name: string | null
amount: number | null
currency: string | null
invoice_date: string | null
}
interface Props {
open: boolean
onClose: () => void
journalEntryId: string
/** Called after a successful link so the parent can refresh its document list. */
onLinked: () => void
}
function isImageType(type: string | null): boolean {
return type?.startsWith('image/') ?? false
}
function isPdfType(type: string | null): boolean {
return type === 'application/pdf'
}
function DocIcon({ mime }: { mime: string | null }) {
if (isImageType(mime)) {
return <ImageIcon className="h-4 w-4 text-muted-foreground shrink-0" />
}
return <FileText className="h-4 w-4 text-muted-foreground shrink-0" />
}
export default function InboxDocumentPicker({ open, onClose, journalEntryId, onLinked }: Props) {
const t = useTranslations('journal_attachments')
const { toast } = useToast()
const [loading, setLoading] = useState(true)
const [items, setItems] = useState<AvailableInboxDoc[]>([])
const [search, setSearch] = useState('')
const [linkingId, setLinkingId] = useState<string | null>(null)
const [previewItem, setPreviewItem] = useState<AvailableInboxDoc | null>(null)
// Reset + fetch each time the dialog opens.
useEffect(() => {
if (!open) return
setSearch('')
setItems([])
setPreviewItem(null)
setLoading(true)
let cancelled = false
;(async () => {
try {
const res = await fetch('/api/documents/inbox-available')
const json = (await res.json().catch(() => ({}))) as { data?: AvailableInboxDoc[] }
if (cancelled) return
setItems(json.data ?? [])
} catch {
if (!cancelled) setItems([])
} finally {
if (!cancelled) setLoading(false)
}
})()
return () => {
cancelled = true
}
}, [open])
const filtered = useMemo(() => {
const q = search.trim().toLowerCase()
if (!q) return items
return items.filter((it) =>
`${it.supplier_name ?? ''} ${it.file_name}`.toLowerCase().includes(q),
)
}, [items, search])
async function handlePick(item: AvailableInboxDoc) {
setLinkingId(item.document_id)
try {
const res = await fetch(`/api/documents/${item.document_id}/link`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
journal_entry_id: journalEntryId,
inbox_item_id: item.inbox_item_id,
}),
})
if (!res.ok) {
const json = (await res.json().catch(() => ({}))) as {
error?: string | { message?: string }
}
const description =
typeof json.error === 'string' ? json.error : json.error?.message
toast({ title: t('picker_link_failed'), description, variant: 'destructive' })
return
}
toast({ title: t('picker_linked') })
onLinked()
onClose()
} catch {
toast({ title: t('picker_link_failed'), variant: 'destructive' })
} finally {
setLinkingId(null)
}
}
const hasSearch = search.trim().length > 0
const previewSrc = previewItem ? `/api/documents/${previewItem.document_id}/inline` : null
return (
<>
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>{t('picker_title')}</DialogTitle>
<DialogDescription>{t('picker_description')}</DialogDescription>
</DialogHeader>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t('picker_search_placeholder')}
className="pl-9"
/>
</div>
{!loading && filtered.length > 0 && (
<div className="flex justify-end px-1 text-[11px] text-muted-foreground tabular-nums">
{t('picker_results', { count: filtered.length })}
</div>
)}
<div className="max-h-[55vh] overflow-y-auto -mx-6 px-6 divide-y">
{loading ? (
<div className="space-y-3 py-3">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
) : filtered.length === 0 ? (
<div className="py-10 flex flex-col items-center gap-2 text-center">
<Inbox className="h-6 w-6 text-muted-foreground" />
<p className="text-sm text-muted-foreground">
{hasSearch ? t('picker_empty_search', { query: search.trim() }) : t('picker_empty')}
</p>
</div>
) : (
filtered.map((it) => {
const isLinking = linkingId === it.document_id
const sourceLabel =
it.source === 'email' ? t('picker_source_email') : t('picker_source_upload')
return (
<div key={it.inbox_item_id} className="flex items-center gap-1">
<button
type="button"
onClick={() => void handlePick(it)}
disabled={!!linkingId}
className={cn(
'flex-1 min-w-0 text-left flex items-center gap-3 py-3 px-2 -ml-2 rounded transition-colors hover:bg-secondary/60',
linkingId && !isLinking && 'opacity-50',
)}
>
<DocIcon mime={it.mime_type} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 min-w-0">
<span className="text-sm font-medium truncate">
{it.supplier_name ?? it.file_name}
</span>
<Badge variant="outline" className="shrink-0 text-[10px]">
{sourceLabel}
</Badge>
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground tabular-nums">
{it.invoice_date && <span>{formatDate(it.invoice_date)}</span>}
{it.supplier_name && (
<span className="truncate font-normal">{it.file_name}</span>
)}
</div>
</div>
{it.amount != null && (
<span className="text-sm font-medium tabular-nums shrink-0">
{formatCurrency(it.amount, it.currency ?? 'SEK')}
</span>
)}
{isLinking && (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground shrink-0" />
)}
</button>
<Button
variant="ghost"
size="icon"
className="h-10 w-10 shrink-0"
aria-label={t('picker_preview')}
title={t('picker_preview')}
onClick={(e) => {
e.stopPropagation()
setPreviewItem(it)
}}
>
<Eye className="h-4 w-4" />
</Button>
</div>
)
})
)}
</div>
</DialogContent>
</Dialog>
<Dialog open={previewItem !== null} onOpenChange={(o) => !o && setPreviewItem(null)}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle className="truncate pr-6">{previewItem?.file_name}</DialogTitle>
{previewItem && (previewItem.supplier_name || previewItem.amount != null) && (
<DialogDescription className="flex items-center gap-2 tabular-nums">
{previewItem.supplier_name && <span>{previewItem.supplier_name}</span>}
{previewItem.amount != null && (
<span>{formatCurrency(previewItem.amount, previewItem.currency ?? 'SEK')}</span>
)}
{previewItem.invoice_date && <span>{formatDate(previewItem.invoice_date)}</span>}
</DialogDescription>
)}
</DialogHeader>
{previewItem && previewSrc && (
<div className="py-1">
{isImageType(previewItem.mime_type) ? (
<img
src={previewSrc}
alt={previewItem.file_name}
className="max-h-[70vh] w-full rounded-lg border object-contain"
/>
) : isPdfType(previewItem.mime_type) ? (
// <object> + type="application/pdf" invokes Chrome's PDF plugin
// directly; <iframe> intermittently shows a blocked-content
// notice even with a permissive CSP. Mirrors JournalEntryAttachments.
<object
data={previewSrc}
type="application/pdf"
aria-label={previewItem.file_name}
className="w-full h-[70vh] rounded-lg border"
>
<a
href={previewSrc}
target="_blank"
rel="noopener noreferrer"
className="block px-4 py-2 text-sm text-muted-foreground underline"
>
{t('picker_preview_unavailable')}
</a>
</object>
) : (
<a
href={previewSrc}
target="_blank"
rel="noopener noreferrer"
className="block px-4 py-6 text-sm text-muted-foreground underline text-center"
>
{t('picker_preview_unavailable')}
</a>
)}
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setPreviewItem(null)}>
{t('picker_close')}
</Button>
<Button
onClick={() => {
if (previewItem) void handlePick(previewItem)
}}
disabled={!!linkingId}
>
{previewItem && linkingId === previewItem.document_id ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t('picker_attach')}
</>
) : (
t('picker_attach')
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}
@@ -24,9 +24,11 @@ import {
Loader2,
Lock,
AlertTriangle,
Inbox,
} from 'lucide-react'
import DocumentUploadZone from '@/components/bookkeeping/DocumentUploadZone'
import type { UploadedFile } from '@/components/bookkeeping/DocumentUploadZone'
import InboxDocumentPicker from '@/components/bookkeeping/InboxDocumentPicker'
interface DocumentRecord {
id: string
@@ -71,6 +73,7 @@ export default function JournalEntryAttachments({
const [loading, setLoading] = useState(true)
const [expandedDoc, setExpandedDoc] = useState<string | null>(null)
const [showUpload, setShowUpload] = useState(false)
const [showInboxPicker, setShowInboxPicker] = useState(false)
const [uploadFiles, setUploadFiles] = useState<UploadedFile[]>([])
// Docs listed here are filtered by journal_entry_id, so every row is bound
@@ -215,15 +218,26 @@ export default function JournalEntryAttachments({
<h4 className="text-sm font-medium">
{t('title')} {documents.length > 0 && `(${documents.length})`}
</h4>
<Button
variant="outline"
size="sm"
className="h-7 text-xs"
onClick={() => setShowUpload(!showUpload)}
>
<Plus className="h-3 w-3 mr-1" />
{t('add')}
</Button>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
className="h-7 text-xs"
onClick={() => setShowInboxPicker(true)}
>
<Inbox className="h-3 w-3 mr-1" />
{t('choose_from_inbox')}
</Button>
<Button
variant="outline"
size="sm"
className="h-7 text-xs"
onClick={() => setShowUpload(!showUpload)}
>
<Plus className="h-3 w-3 mr-1" />
{t('add')}
</Button>
</div>
</div>
{showUpload && (
@@ -402,6 +416,13 @@ export default function JournalEntryAttachments({
</DialogFooter>
</DialogContent>
</Dialog>
<InboxDocumentPicker
open={showInboxPicker}
onClose={() => setShowInboxPicker(false)}
journalEntryId={journalEntryId}
onLinked={fetchDocuments}
/>
</div>
)
}
+1 -5
View File
@@ -208,11 +208,7 @@ export default function WelcomeOnboarding({
let title = t('toast_error_title')
let description: string = result.error || t('toast_create_failed')
let backToStep2 = false
if (result.error === 'org_number_exists') {
title = t('toast_company_exists_title')
description = t('toast_company_exists_description', { appName: branding.appName.toLowerCase() })
backToStep2 = true
} else if (result.error === 'org_number_invalid') {
if (result.error === 'org_number_invalid') {
title = t('toast_org_invalid_title')
description = t('toast_org_invalid_description')
backToStep2 = true
@@ -42,7 +42,7 @@ type ArcimProvider = 'fortnox' | 'visma' | 'briox' | 'bokio' | 'bjornlunden'
const ARCIM_PROVIDERS: { id: ArcimProvider; name: string; authType: 'oauth' | 'token' }[] = [
{ id: 'fortnox', name: 'Fortnox', authType: 'oauth' },
{ id: 'visma', name: 'Visma eEkonomi', authType: 'oauth' },
{ id: 'visma', name: 'Visma', authType: 'oauth' },
{ id: 'bokio', name: 'Bokio', authType: 'token' },
{ id: 'bjornlunden', name: 'Björn Lundén', authType: 'token' },
{ id: 'briox', name: 'Briox', authType: 'token' },
@@ -349,16 +349,24 @@ function ProviderStep({
{ARCIM_PROVIDERS.map((provider) => {
const comingSoon = COMING_SOON_PROVIDERS.has(provider.id)
const alreadyConnected = activeConsents.some(c => c.provider === provider.id)
// Non-Fortnox providers only expose entity data (customers,
// suppliers, invoices) via API — the ledger must arrive via SIE
// first. Gate the connection entry until a completed SIE import
// exists so users don't authenticate into a flow that can't
// import anything yet. The /migrate route enforces this
// server-side regardless; this is just the matching UX.
const needsSieFirst = !hasSieImport && provider.id !== 'fortnox'
const isDisabled = comingSoon || alreadyConnected || needsSieFirst
return (
<button
key={provider.id}
disabled={comingSoon || alreadyConnected}
disabled={isDisabled}
className={`relative flex items-center gap-4 rounded-lg border p-4 text-left transition-all ${
comingSoon || alreadyConnected
isDisabled
? 'cursor-not-allowed border-border/50 opacity-60'
: 'border-border hover:border-primary/50 hover:bg-accent/50 active:scale-[0.98]'
}`}
onClick={() => !comingSoon && !alreadyConnected && onSelect(provider.id)}
onClick={() => !isDisabled && onSelect(provider.id)}
>
<img
src={PROVIDER_LOGOS[provider.id]}
@@ -378,9 +386,20 @@ function ProviderStep({
Ansluten
</span>
)}
{needsSieFirst && !comingSoon && !alreadyConnected && (
<span className="inline-flex items-center rounded-full bg-amber-500/10 px-2 py-0.5 text-[10px] font-medium text-amber-600 dark:text-amber-500">
SIE krävs först
</span>
)}
</div>
<p className="text-xs text-muted-foreground">
{alreadyConnected ? 'Använd "Synka igen" ovan' : provider.authType === 'oauth' ? 'Anslut via inloggning' : 'Anslut med API-nyckel'}
{alreadyConnected
? 'Använd "Synka igen" ovan'
: needsSieFirst
? 'Importera SIE-fil först'
: provider.authType === 'oauth'
? 'Anslut via inloggning'
: 'Anslut med API-nyckel'}
</p>
</div>
</button>
+1 -1
View File
@@ -301,7 +301,7 @@ export default function SIEUploadStep({ onFileSelect, isLoading, error, errorTyp
<p className="text-muted-foreground">Inställningar → Import/Export → Exportera SIE</p>
</div>
<div>
<p className="font-medium">Visma eEkonomi</p>
<p className="font-medium">Visma</p>
<p className="text-muted-foreground">Rapporter → Övrigt → Exportera till SIE</p>
</div>
<div>
+12 -22
View File
@@ -190,28 +190,16 @@ export default function BankIdCompanyPicker({
const entityLabel = humanTicEntityType(role.legalEntityType)
const mappable = mapEntityType(role.legalEntityType) !== null
if (status === 'exists') {
return (
<li key={cleaned}>
<div className="w-full rounded-lg border bg-muted/20 p-4 text-left opacity-70">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<p className="font-medium text-sm truncate">{role.legalName}</p>
<p className="text-xs text-muted-foreground mt-0.5 truncate">
{cleaned} · {entityLabel}
</p>
</div>
<span className="text-[10px] uppercase tracking-wide text-muted-foreground flex-shrink-0">
{t('already_in_app', { appName: branding.appName.toLowerCase() })}
</span>
</div>
<p className="text-xs text-muted-foreground/70 mt-2">
{t('ask_admin_invite')}
</p>
</div>
</li>
)
}
// Companies already in gnubok under another account are still
// offered for setup — org-number reuse is allowed (a director
// may keep a separate test copy). Keep a muted note so the
// "also already in {app}" context isn't lost.
const existsNote =
status === 'exists' ? (
<p className="text-xs text-muted-foreground/70 mt-2">
{t('already_in_app', { appName: branding.appName.toLowerCase() })}
</p>
) : null
if (!mappable) {
return (
@@ -236,6 +224,7 @@ export default function BankIdCompanyPicker({
{t('setup_manually')}
</span>
</div>
{existsNote}
</Link>
</li>
)
@@ -263,6 +252,7 @@ export default function BankIdCompanyPicker({
</div>
<ArrowRight className="h-4 w-4 text-muted-foreground flex-shrink-0" />
</div>
{existsNote}
</button>
</li>
)
+14 -18
View File
@@ -12,11 +12,8 @@ import { Label } from '@/components/ui/label'
import { Loader2, ArrowRight, ArrowLeft, CheckCircle2, AlertTriangle } from 'lucide-react'
import type { EntityType } from '@/types'
import type { CompanyLookupResult } from '@/lib/company-lookup/types'
import { getBranding } from '@/lib/branding/service'
import { normalizeOrgNumber } from '@/lib/company-lookup/normalize-org-number'
const branding = getBranding()
const schema = z.object({
company_name: z.string().min(1, 'Företagsnamn krävs'),
org_number: z.string()
@@ -82,7 +79,7 @@ export default function Step2CompanyDetails({
const [isLooking, setIsLooking] = useState(false)
const [lookupError, setLookupError] = useState<string | null>(null)
const [lookupDone, setLookupDone] = useState<CompanyLookupResult | null>(null)
const [orgNumberExists, setOrgNumberExists] = useState(false)
const [existingOwn, setExistingOwn] = useState<{ id: string; name: string } | null>(null)
const abortRef = useRef<AbortController | null>(null)
const dupAbortRef = useRef<AbortController | null>(null)
// Tracks an orgnr that's been pre-verified (BankID CompanyRoles match) so
@@ -96,13 +93,14 @@ export default function Step2CompanyDetails({
const orgNumber = watch('org_number')
// Debounced duplicate check against Accounted's own companies table. Runs in
// parallel with the TIC lookup — they don't conflict. On match, the submit
// button is disabled; the server action would also reject ('org_number_exists')
// but blocking client-side avoids a wasted roundtrip.
// Soft, account-scoped duplicate warning: if THIS user already has a
// (non-archived) company with the same org number, surface a non-blocking
// note. Org-number reuse is allowed (see lib/company/actions.ts), so this
// never disables submit. The endpoint is RLS-scoped to the caller's own
// companies, so it can't reveal or count another account's.
useEffect(() => {
if (!orgNumber || normalizeOrgNumber(orgNumber) === null) {
setOrgNumberExists(false)
setExistingOwn(null)
return
}
const timer = setTimeout(() => {
@@ -115,10 +113,10 @@ export default function Step2CompanyDetails({
.then(async (res) => {
if (controller.signal.aborted || !res.ok) return
const { data } = await res.json()
setOrgNumberExists(!!data?.exists)
setExistingOwn(data?.companies?.[0] ?? null)
})
.catch(() => {
// Network failure is non-fatal — the server action will re-check.
// Advisory only — never blocks creation.
})
}, 500)
return () => {
@@ -267,12 +265,10 @@ export default function Step2CompanyDetails({
{ticEnabled && lookupError && (
<p className="text-xs text-muted-foreground">{lookupError}</p>
)}
{orgNumberExists && (
<div className="flex items-start gap-2 text-sm text-destructive">
<AlertTriangle className="h-3.5 w-3.5 mt-0.5 flex-shrink-0" />
<span>
{t('step2_company_exists', { appName: branding.appName.toLowerCase() })}
</span>
{existingOwn && (
<div className="flex items-start gap-2 rounded-md bg-warning/15 px-3 py-2 text-sm text-warning-foreground">
<AlertTriangle className="h-4 w-4 mt-0.5 flex-shrink-0 text-warning" />
<span>{t('step2_company_exists_own', { name: existingOwn.name })}</span>
</div>
)}
</div>
@@ -338,7 +334,7 @@ export default function Step2CompanyDetails({
</Button>
<Button
type="submit"
disabled={isSaving || orgNumberExists}
disabled={isSaving}
className="w-full sm:w-auto"
>
{isSaving ? (
+2 -1
View File
@@ -13,6 +13,7 @@ import { Badge } from '@/components/ui/badge'
import { AlertCircle, ChevronDown, ChevronRight } from 'lucide-react'
import AgentSparkleButton from '@/components/agent/AgentSparkleButton'
import { formatDate } from '@/lib/utils'
import { roundOre } from '@/lib/money'
import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver'
import { AccountNumber } from '@/components/ui/account-number'
import { ReportExportMenu } from '@/components/reports/ReportExportMenu'
@@ -122,7 +123,7 @@ export function TrialBalanceView({ periodId, onNavigateToAccount }: { periodId:
// Credit-normal accounts (liabilities/equity class 2, revenue class 3): positive when credit > debit
// Debit-normal accounts (assets class 1, expenses class 4-9): positive when debit > credit
const creditNormal = row.account_class === 2 || row.account_class === 3
return Math.round((creditNormal ? credit - debit : debit - credit) * 100) / 100
return roundOre(creditNormal ? credit - debit : debit - credit)
}
function formatSigned(amount: number): string {
+13 -5
View File
@@ -637,18 +637,25 @@ export default function InvoiceMatchDialog({
<div className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground text-right">
{t('booking_credit')}
</div>
{/* Verifikat amounts are always denominated in SEK (the
bookkeeping home currency) — the preview route builds
every line via resolveSekAmount. Format them as SEK,
NOT transaction.currency, otherwise a foreign-currency
payment (e.g. 19 USD) shows the converted SEK figure
with the wrong symbol ("175,28 US$" instead of
"175,28 kr"). */}
{preview.lines.map((line, i) => (
<div key={i} className="contents">
<div className="font-medium">{line.account_number}</div>
<div className="text-muted-foreground truncate">{line.description}</div>
<div className="text-right">
{line.debit_amount > 0
? formatCurrency(line.debit_amount, transaction.currency)
? formatCurrency(line.debit_amount, 'SEK')
: ''}
</div>
<div className="text-right">
{line.credit_amount > 0
? formatCurrency(line.credit_amount, transaction.currency)
? formatCurrency(line.credit_amount, 'SEK')
: ''}
</div>
</div>
@@ -730,16 +737,17 @@ export default function InvoiceMatchDialog({
{t('booking_add_line')}
</Button>
<div className="text-xs tabular-nums text-muted-foreground">
{t('booking_debit')} {formatCurrency(editValidation.totalDebit, transaction.currency)}
{/* SEK: edited verifikat rows are home-currency, like the read-only preview above. */}
{t('booking_debit')} {formatCurrency(editValidation.totalDebit, 'SEK')}
{' / '}
{t('booking_credit')} {formatCurrency(editValidation.totalCredit, transaction.currency)}
{t('booking_credit')} {formatCurrency(editValidation.totalCredit, 'SEK')}
</div>
</div>
{!editValidation.isBalanced && (
<p className="text-xs text-destructive">
{t('booking_unbalanced', {
diff: formatCurrency(Math.abs(editValidation.diff), transaction.currency),
diff: formatCurrency(Math.abs(editValidation.diff), 'SEK'),
})}
</p>
)}