Files
accounted/components/supplier-invoices/PaymentFileDialog.tsx
T
fea5dfd1f9 fix(payments): correct pain.001 dialect per Swedbank Validex run (#1507)
* fix(payments): correct pain.001 dialect per Swedbank Validex run

Real MIG validation (eken.validex.net) rejected the first generated file
on four rules: character set (e-acute in names), missing InitgPty OrgId,
BGNR creditors demanding a BGNR debtor, and Strd lacking RfrdDocAmt.
Names and messages now transliterate to the MIG set, the org number is
required at batch creation (settings first, companies fallback), bankgiro
payees debit the company bankgiro in their own PmtInf group when one
exists (IBAN otherwise, with Cdtr PstlAdr/Ctry SE always present), and
structured OCR remittance repeats the amount as RfrdDocAmt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(payments): review quick wins on the MIG pass

NFC-normalize before transliteration (decomposed marks from PDF-pasted
names fold to the precomposed forms the map knows), a dedicated settings
link label for the missing-org state, and coverage for an invalid
company bankgiro being dropped from the debtor snapshot.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 20:53:43 +02:00

369 lines
14 KiB
TypeScript

'use client'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useLocale, useTranslations } from 'next-intl'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Checkbox } from '@/components/ui/checkbox'
import { Skeleton } from '@/components/ui/skeleton'
import { AttnLine } from '@/components/ui/attn-line'
import { AlertTriangle, Download, Loader2 } from 'lucide-react'
import { useToast } from '@/components/ui/use-toast'
import { downloadFile } from '@/lib/browser/download-file'
import { failureDescription } from '@/lib/browser/action-failure'
import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message'
import { formatCurrency } from '@/lib/utils'
interface PreviewLine {
id: string
supplier_name: string
invoice_number: string
amount: number
payment_date: string
payee: { type: string; label: string }
reference: { type: 'ocr' | 'invoice_number'; value: string }
warnings: Array<'unattested' | 'already_batched' | 'ocr_invalid'>
active_batch_id: string | null
}
interface Preview {
eligible: PreviewLine[]
excluded: Array<{ id: string; reason: string }>
total: number
debtor_ok: boolean
debtor_missing?: 'iban' | 'bic' | 'org_number'
}
interface PaymentFileDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
/** Selected supplier-invoice ids from the list page. */
invoiceIds: string[]
/** Called after a batch was created and its file downloaded. */
onCreated: () => void
/** Lets the dialog name the excluded invoices, not just their ids. */
invoiceLabelById?: ReadonlyMap<string, string>
}
export default function PaymentFileDialog({
open,
onOpenChange,
invoiceIds,
onCreated,
invoiceLabelById,
}: PaymentFileDialogProps) {
const t = useTranslations('supplier_payment_files')
const locale = useLocale() as ErrorLocale
const { toast } = useToast()
const [preview, setPreview] = useState<Preview | null>(null)
const [loading, setLoading] = useState(false)
const [creating, setCreating] = useState(false)
const [confirmAlreadyBatched, setConfirmAlreadyBatched] = useState(false)
// Per-line editable overrides, keyed by invoice id. Values stay as input
// strings so partially-typed numbers do not fight the user.
const [amounts, setAmounts] = useState<Record<string, string>>({})
const [dates, setDates] = useState<Record<string, string>>({})
const loadPreview = useCallback(async () => {
setLoading(true)
setPreview(null)
setAmounts({})
setDates({})
setConfirmAlreadyBatched(false)
try {
const res = await fetch('/api/supplier-invoices/payment-batches/preview', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ format: 'pain001', ids: invoiceIds }),
})
const body = await res.json()
if (!res.ok) {
toast({
title: t('preview_failed_title'),
description: getErrorMessage(body, { locale }),
variant: 'destructive',
})
onOpenChange(false)
return
}
setPreview(body.data as Preview)
} catch {
toast({
title: t('preview_failed_title'),
description: getErrorMessage(null, { locale }),
variant: 'destructive',
})
onOpenChange(false)
} finally {
setLoading(false)
}
}, [invoiceIds, locale, onOpenChange, t, toast])
useEffect(() => {
if (open) loadPreview()
}, [open, loadPreview])
const lineAmount = useCallback(
(line: PreviewLine): number => {
const raw = amounts[line.id]
if (raw === undefined) return line.amount
const parsed = Number.parseFloat(raw.replace(',', '.'))
return Number.isFinite(parsed) ? parsed : 0
},
[amounts],
)
const total = useMemo(
() => (preview ? preview.eligible.reduce((sum, line) => sum + lineAmount(line), 0) : 0),
[preview, lineAmount],
)
const hasAlreadyBatched =
preview?.eligible.some((line) => line.warnings.includes('already_batched')) ?? false
const hasInvalidAmount =
preview?.eligible.some((line) => lineAmount(line) <= 0 || lineAmount(line) > line.amount) ??
false
const canCreate =
!!preview &&
preview.eligible.length > 0 &&
preview.debtor_ok &&
!hasInvalidAmount &&
(!hasAlreadyBatched || confirmAlreadyBatched) &&
!creating
async function handleCreate() {
if (!preview || creating) return
setCreating(true)
try {
const res = await fetch('/api/supplier-invoices/payment-batches', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
format: 'pain001',
items: preview.eligible.map((line) => ({
supplier_invoice_id: line.id,
amount: lineAmount(line),
payment_date: dates[line.id] ?? line.payment_date,
})),
...(confirmAlreadyBatched ? { confirm_already_batched: true } : {}),
}),
})
const body = await res.json()
if (!res.ok) {
toast({
title: t('create_failed_title'),
description: getErrorMessage(body, { locale }),
variant: 'destructive',
})
// The server re-evaluated and something changed since the preview:
// reload so the dialog shows the state the refusal was based on.
loadPreview()
return
}
const batch = body.data as { id: string; created_at: string }
const datePart = batch.created_at.slice(0, 10).replace(/-/g, '')
const shortId = batch.id.replace(/-/g, '').slice(0, 8)
const result = await downloadFile({
url: `/api/supplier-invoices/payment-batches/${batch.id}/file`,
filename: `betalfil_${datePart}_${shortId}.xml`,
locale,
})
if (!result.ok) {
// The batch exists even though the download failed; the history page
// can re-serve the identical file, so point there instead of implying
// the whole operation failed.
toast({
title: t('download_failed_title'),
description: failureDescription(result, {
timeout: t('download_timeout'),
network: t('download_network'),
}),
variant: 'destructive',
})
} else {
toast({ title: t('created_toast') })
}
onCreated()
onOpenChange(false)
} finally {
setCreating(false)
}
}
return (
<Dialog open={open} onOpenChange={(next) => !creating && onOpenChange(next)}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>{t('dialog_title')}</DialogTitle>
</DialogHeader>
{loading || !preview ? (
<div className="space-y-3 py-2">
{[1, 2, 3].map((i) => (
<div key={i} className="flex items-center gap-4">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-4 w-24 flex-1" />
<Skeleton className="h-4 w-20" />
</div>
))}
</div>
) : (
<div className="space-y-4">
{!preview.debtor_ok && (
<AttnLine
action={
preview.debtor_missing === 'org_number'
? { label: t('debtor_missing_org_link'), href: '/settings/company' }
: { label: t('debtor_missing_link'), href: '/settings/invoicing' }
}
>
{preview.debtor_missing === 'bic'
? t('debtor_missing_bic')
: preview.debtor_missing === 'org_number'
? t('debtor_missing_org')
: t('debtor_missing_iban')}
</AttnLine>
)}
{preview.eligible.length > 0 && (
<div className="overflow-x-auto">
<table className="w-full border-collapse text-[13px]">
<thead>
<tr className="border-b border-border text-left text-[11px] uppercase tracking-wider text-muted-foreground">
<th className="py-2 pr-3 font-medium">{t('th_supplier')}</th>
<th className="py-2 pr-3 font-medium">{t('th_payee')}</th>
<th className="py-2 pr-3 font-medium">{t('th_reference')}</th>
<th className="py-2 pr-3 font-medium">{t('th_payment_date')}</th>
<th className="py-2 text-right font-medium">{t('th_amount')}</th>
</tr>
</thead>
<tbody>
{preview.eligible.map((line) => (
<tr key={line.id} className="border-b border-border/60 align-middle">
<td className="max-w-0 w-1/3 py-2 pr-3">
<span className="block truncate">{line.supplier_name}</span>
<span className="flex flex-wrap items-center gap-1 text-[11px] text-muted-foreground">
<span className="tabular-nums">{line.invoice_number}</span>
{line.warnings.map((warning) => (
<Badge
key={warning}
variant={warning === 'already_batched' ? 'warning' : 'outline'}
className="px-1.5 py-0 text-[10px] font-normal"
>
{t(`warning_${warning}`)}
</Badge>
))}
</span>
</td>
<td className="whitespace-nowrap py-2 pr-3 tabular-nums">
{line.payee.label}
</td>
<td className="max-w-[120px] truncate whitespace-nowrap py-2 pr-3 tabular-nums text-muted-foreground">
{line.reference.value}
</td>
<td className="whitespace-nowrap py-2 pr-3">
<Input
type="date"
value={dates[line.id] ?? line.payment_date}
onChange={(e) =>
setDates((prev) => ({ ...prev, [line.id]: e.target.value }))
}
className="h-8 w-[140px] text-xs tabular-nums"
/>
</td>
<td className="whitespace-nowrap py-2 text-right">
<Input
inputMode="decimal"
value={amounts[line.id] ?? String(line.amount)}
onChange={(e) =>
setAmounts((prev) => ({ ...prev, [line.id]: e.target.value }))
}
aria-invalid={lineAmount(line) <= 0 || lineAmount(line) > line.amount}
className="h-8 w-[110px] text-right text-xs tabular-nums"
/>
</td>
</tr>
))}
</tbody>
<tfoot>
<tr>
<td colSpan={4} className="py-2 pr-3 text-right text-muted-foreground">
{t('total_label')}
</td>
<td className="whitespace-nowrap py-2 text-right font-medium tabular-nums">
{formatCurrency(total)}
</td>
</tr>
</tfoot>
</table>
</div>
)}
{preview.eligible.length === 0 && (
<p className="py-2 text-sm text-muted-foreground">{t('none_eligible')}</p>
)}
{preview.excluded.length > 0 && (
<div className="space-y-1 text-xs text-muted-foreground">
<p className="font-medium text-foreground">{t('excluded_title')}</p>
{preview.excluded.map((row) => (
<p key={row.id}>
{invoiceLabelById?.get(row.id) ?? row.id}:{' '}
{t(`excluded_reason_${row.reason}`)}
</p>
))}
</div>
)}
{/* The file downloads fine and only fails at the bank if the
upload agreement is missing: say the precondition up front. */}
<div className="flex items-start gap-2 rounded-md border border-border p-3 text-xs">
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span className="text-muted-foreground">{t('pain001_agreement_warning')}</span>
</div>
{hasAlreadyBatched && (
<label className="flex items-start gap-2 text-xs">
<Checkbox
checked={confirmAlreadyBatched}
onCheckedChange={(checked) => setConfirmAlreadyBatched(checked === true)}
className="mt-0.5"
/>
<span className="text-muted-foreground">{t('confirm_already_batched')}</span>
</label>
)}
<div className="flex items-center justify-end gap-3">
<Button
variant="ghost"
onClick={() => onOpenChange(false)}
disabled={creating}
>
{t('cancel')}
</Button>
<Button onClick={handleCreate} disabled={!canCreate}>
{creating ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Download className="mr-2 h-4 w-4" />
)}
{t('create_and_download')}
</Button>
</div>
</div>
)}
</DialogContent>
</Dialog>
)
}