From f0bedc14afd47c666cd6a777b06f25bdc9b5c330 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Mon, 10 Aug 2026 20:07:04 +0200 Subject: [PATCH] feat(payments): betalfil UI (betalfil 3/3) (#1505) * feat(payments): betalfil UI (betalfil 3/3) Bulk-select + Skapa betalfil bulkbar on the supplier-invoices list, preview dialog with per-line editable amount/date and exclusion reasons, payment-files history page with re-download, cancel and a sequential bulk mark-paid (duplicate guard respected, never forced), I betalfil chip on rows in active batches, clearing/kontonummer fields on the supplier form, and the supplier_payment_files namespace in sv+en. Co-Authored-By: Claude Fable 5 * fix(sandbox): make the demo AP data betalfil-ready Demo supplier bankgiro numbers were not Luhn-valid, the unpaid demo invoice had remaining_amount 0 (no trigger derives it, so the list said 0 kr kvar att betala), and the company had no IBAN/BIC, all of which excluded the seeded data from the betalfil flow. Numbers swapped for Luhn-valid ones (991-2346 is Bankgirot's test number), a valid OCR added, and both bulk-insert rows set remaining_amount explicitly per the PostgREST normalization rule already documented inline. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 --- app/(dashboard)/supplier-invoices/page.tsx | 158 ++++++- .../supplier-invoices/payment-files/page.tsx | 440 ++++++++++++++++++ app/api/sandbox/seed/route.ts | 21 +- app/api/suppliers/[id]/route.ts | 2 + app/api/suppliers/route.ts | 2 + .../supplier-invoices/PaymentFileDialog.tsx | 360 ++++++++++++++ components/suppliers/SupplierForm.tsx | 14 + lib/api/schemas.ts | 2 + messages/en.json | 72 ++- messages/sv.json | 72 ++- 10 files changed, 1134 insertions(+), 9 deletions(-) create mode 100644 app/(dashboard)/supplier-invoices/payment-files/page.tsx create mode 100644 components/supplier-invoices/PaymentFileDialog.tsx diff --git a/app/(dashboard)/supplier-invoices/page.tsx b/app/(dashboard)/supplier-invoices/page.tsx index 417dd34c..0ccbd20b 100644 --- a/app/(dashboard)/supplier-invoices/page.tsx +++ b/app/(dashboard)/supplier-invoices/page.tsx @@ -7,6 +7,7 @@ import { useTranslations } from 'next-intl' import { Skeleton } from '@/components/ui/skeleton' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' +import { Checkbox } from '@/components/ui/checkbox' import { Input } from '@/components/ui/input' import { DataListEmpty } from '@/components/ui/data-list' import { TH_CLASS, TD_CLASS, QUIET_LINK_CLASS } from '@/components/ui/dry-table' @@ -29,6 +30,24 @@ const NewSupplierInvoiceDialog = dynamic( { loading: DialogLoadingSkeleton }, ) +const PaymentFileDialog = dynamic( + () => import('@/components/supplier-invoices/PaymentFileDialog'), + { loading: DialogLoadingSkeleton }, +) + +// Rough client-side gate for the payment-file bulk selection: the statuses +// mark-paid accepts, SEK only, something left to pay, not a credit note. The +// preview re-evaluates server-side (payee, OCR, active batches), so this only +// decides which rows get a checkbox. +function isBatchSelectable(inv: SupplierInvoice): boolean { + return ( + ['registered', 'approved', 'partially_paid', 'overdue'].includes(inv.status) && + !inv.is_credit_note && + inv.currency === 'SEK' && + inv.remaining_amount > 0.005 + ) +} + // One derivable chip per row (concept scene 21): Registrerad is the "waiting // for attest" state (outline), Godkänd the beige ready-to-pay state; paid is // the sage exception-free end state. @@ -79,6 +98,10 @@ export default function SupplierInvoicesPage() { const [fyPeriodId, setFyPeriodId] = useState(null) const [fyPeriod, setFyPeriod] = useState(null) const [approvingId, setApprovingId] = useState(null) + // Payment-file bulk selection + the "already in an active betalfil" chip map. + const [selectedIds, setSelectedIds] = useState>(new Set()) + const [activeBatchInvoiceIds, setActiveBatchInvoiceIds] = useState>(new Set()) + const [showPaymentDialog, setShowPaymentDialog] = useState(false) // The "Registrera leverantörsfaktura" modal is driven by the URL (?new=1, // optionally with inbox_item_id for the invoice-inbox conversion flow) so @@ -98,8 +121,26 @@ export default function SupplierInvoicesPage() { setIsLoading(false) } + // Which invoices already sit in an active (not cancelled) betalfil: feeds + // the "I betalfil" chip. Non-blocking; the list renders without it. + async function fetchActiveBatchMembership() { + try { + const res = await fetch('/api/supplier-invoices/payment-batches?status=created') + if (!res.ok) return + const { data } = await res.json() + const ids = new Set() + for (const batch of (data ?? []) as Array<{ supplier_invoice_ids?: string[] }>) { + for (const id of batch.supplier_invoice_ids ?? []) ids.add(id) + } + setActiveBatchInvoiceIds(ids) + } catch { + // Chip data only; the list stays functional without it. + } + } + useEffect(() => { fetchInvoices() + fetchActiveBatchMembership() }, []) // Mirrors the old standalone page's post-create navigation: inbox @@ -149,6 +190,34 @@ export default function SupplierInvoicesPage() { (inv) => inv.status === 'registered' || inv.status === 'approved' || inv.status === 'overdue', ).length + const selectableInvoices = filteredInvoices.filter(isBatchSelectable) + const allSelectableSelected = + selectableInvoices.length > 0 && selectableInvoices.every((inv) => selectedIds.has(inv.id)) + + function toggleSelect(id: string) { + setSelectedIds((prev) => { + const next = new Set(prev) + if (next.has(id)) next.delete(id) + else next.add(id) + return next + }) + } + + // Labels the excluded rows in the payment dialog ("Derome CD3014794407"), + // so a server-side exclusion never reads as a bare UUID. + const invoiceLabelById = new Map( + invoices.map((inv) => [ + inv.id, + `${inv.supplier?.name ?? ''} ${inv.supplier_invoice_number}`.trim(), + ]), + ) + + const handleBatchCreated = () => { + setSelectedIds(new Set()) + fetchInvoices() + fetchActiveBatchMembership() + } + async function handleApprove(id: string) { setApprovingId(id) try { @@ -245,7 +314,10 @@ export default function SupplierInvoicesPage() { className="h-9 pl-10" /> -
+
+ + {t('payment_files_link')} + { @@ -257,6 +329,37 @@ export default function SupplierInvoicesPage() {
+ {/* Bulkbar: appears once anything is selected (transactions-page shape). */} + {selectedIds.size > 0 && ( +
+ + {selectedIds.size}{' '} + {t('bulkbar_selected', { count: selectedIds.size })} + + + {!allSelectableSelected && ( + + )} + +
+ )} + {isLoading ? (
{[1, 2, 3, 4].map((i) => ( @@ -288,6 +391,7 @@ export default function SupplierInvoicesPage() { + {canWrite && } @@ -311,12 +415,37 @@ export default function SupplierInvoicesPage() { // them there), so attest keys off approved_at, not the status. const canApprove = canApproveSupplierInvoice(inv) && !inv.is_credit_note && canWrite + const selectable = canWrite && isBatchSelectable(inv) return ( router.push(`/supplier-invoices/${inv.id}`)} > + {/* Hover-revealed selection checkbox (JournalEntryList shape). */} + {canWrite && ( + + )} @@ -348,9 +477,16 @@ export default function SupplierInvoicesPage() { {formatCurrency(inv.remaining_amount, inv.currency)} {/* Attest as a hover action on registered rows (concept): approval gates payment, so it lives right on the row. */} @@ -390,6 +526,18 @@ export default function SupplierInvoicesPage() { onCreated={handleCreated} /> )} + + {showPaymentDialog && ( + { + if (!open) setShowPaymentDialog(false) + }} + invoiceIds={Array.from(selectedIds)} + invoiceLabelById={invoiceLabelById} + onCreated={handleBatchCreated} + /> + )} ) } diff --git a/app/(dashboard)/supplier-invoices/payment-files/page.tsx b/app/(dashboard)/supplier-invoices/payment-files/page.tsx new file mode 100644 index 00000000..a9896baa --- /dev/null +++ b/app/(dashboard)/supplier-invoices/payment-files/page.tsx @@ -0,0 +1,440 @@ +'use client' + +import { useCallback, useEffect, useState } from 'react' +import Link from 'next/link' +import { useLocale, useTranslations } from 'next-intl' +import { PageHeader } from '@/components/ui/page-header' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Skeleton } from '@/components/ui/skeleton' +import { EmptyState } from '@/components/ui/empty-state' +import { TH_CLASS, TD_CLASS, QUIET_LINK_CLASS } from '@/components/ui/dry-table' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { + SlideOver, + SlideOverBody, + SlideOverContent, + SlideOverFooter, + SlideOverHeader, +} from '@/components/ui/slide-over' +import { FileText, Loader2 } from 'lucide-react' +import { useToast } from '@/components/ui/use-toast' +import { useCanWrite } from '@/lib/hooks/use-can-write' +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 { cn, formatCurrency, formatDate } from '@/lib/utils' +import type { SupplierPaymentBatch, SupplierPaymentBatchItem } from '@/types' + +type BatchListRow = SupplierPaymentBatch & { settled_count: number } + +type BatchItemWithInvoice = SupplierPaymentBatchItem & { + invoice: { + id: string + status: string + remaining_amount: number + supplier_invoice_number: string + arrival_number: number + } | null +} + +type BatchDetail = SupplierPaymentBatch & { items: BatchItemWithInvoice[] } + +/** Mirrors the öre epsilon the server derives settled_count with. */ +const SETTLED_EPSILON = 0.005 + +function batchFilename(batch: Pick): string { + const datePart = batch.created_at.slice(0, 10).replace(/-/g, '') + return `betalfil_${datePart}_${batch.id.replace(/-/g, '').slice(0, 8)}.xml` +} + +export default function PaymentFilesPage() { + const t = useTranslations('supplier_payment_files') + const locale = useLocale() as ErrorLocale + const { toast } = useToast() + const { canWrite } = useCanWrite() + + const [batches, setBatches] = useState([]) + const [isLoading, setIsLoading] = useState(true) + const [detail, setDetail] = useState(null) + const [detailId, setDetailId] = useState(null) + const [downloadingId, setDownloadingId] = useState(null) + const [confirmCancelId, setConfirmCancelId] = useState(null) + const [cancelling, setCancelling] = useState(false) + const [markingAll, setMarkingAll] = useState(false) + + const fetchBatches = useCallback(async () => { + setIsLoading(true) + try { + const res = await fetch('/api/supplier-invoices/payment-batches?status=all') + const body = await res.json() + setBatches((body.data as BatchListRow[]) ?? []) + } finally { + setIsLoading(false) + } + }, []) + + useEffect(() => { + fetchBatches() + }, [fetchBatches]) + + const openDetail = useCallback(async (id: string) => { + setDetailId(id) + setDetail(null) + const res = await fetch(`/api/supplier-invoices/payment-batches/${id}`) + if (!res.ok) { + setDetailId(null) + return + } + const body = await res.json() + setDetail(body.data as BatchDetail) + }, []) + + async function handleDownload(batch: Pick) { + if (downloadingId) return + setDownloadingId(batch.id) + try { + const result = await downloadFile({ + url: `/api/supplier-invoices/payment-batches/${batch.id}/file`, + filename: batchFilename(batch), + locale, + }) + if (!result.ok) { + toast({ + title: t('download_failed_title'), + description: failureDescription(result, { + timeout: t('download_timeout'), + network: t('download_network'), + }), + variant: 'destructive', + }) + } + } finally { + setDownloadingId(null) + } + } + + async function handleCancel() { + if (!confirmCancelId || cancelling) return + setCancelling(true) + try { + const res = await fetch( + `/api/supplier-invoices/payment-batches/${confirmCancelId}/cancel`, + { method: 'POST' }, + ) + const body = await res.json() + if (!res.ok) { + toast({ + title: t('cancel_failed_title'), + description: getErrorMessage(body, { locale }), + variant: 'destructive', + }) + } else { + toast({ title: t('cancelled_toast') }) + } + setConfirmCancelId(null) + setDetailId(null) + fetchBatches() + } finally { + setCancelling(false) + } + } + + // Sequential mark-paid per item, reusing the existing per-invoice route with + // its duplicate-payment guard intact. Never force: a 409 duplicate means a + // matching bank transaction is already in the feed and bank matching is the + // right way to settle that invoice. + async function handleMarkAllPaid() { + if (!detail || markingAll) return + setMarkingAll(true) + let booked = 0 + let skippedSettled = 0 + let skippedDuplicate = 0 + let failed = 0 + try { + for (const item of detail.items) { + const invoice = item.invoice + if (!invoice || invoice.remaining_amount <= SETTLED_EPSILON) { + skippedSettled += 1 + continue + } + try { + const res = await fetch(`/api/supplier-invoices/${item.supplier_invoice_id}/mark-paid`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + amount: Math.min(item.amount, invoice.remaining_amount), + payment_date: item.payment_date, + }), + }) + if (res.ok) { + booked += 1 + continue + } + const body = await res.json() + if (body?.error?.code === 'SI_PAID_LIKELY_DUPLICATE') skippedDuplicate += 1 + else failed += 1 + } catch { + failed += 1 + } + } + + toast({ + title: t('mark_all_result_title', { booked }), + description: + skippedDuplicate > 0 + ? t('mark_all_result_duplicates', { count: skippedDuplicate }) + : failed > 0 + ? t('mark_all_result_failed', { count: failed }) + : skippedSettled > 0 + ? t('mark_all_result_settled', { count: skippedSettled }) + : undefined, + variant: failed > 0 ? 'destructive' : undefined, + }) + await fetchBatches() + if (detailId) await openDetail(detailId) + } finally { + setMarkingAll(false) + } + } + + const detailUnsettled = + detail?.items.filter( + (item) => item.invoice && item.invoice.remaining_amount > SETTLED_EPSILON, + ).length ?? 0 + + return ( +
+ + + {isLoading ? ( +
+ {[1, 2, 3].map((i) => ( +
+ + + +
+ ))} +
+ ) : batches.length === 0 ? ( + + ) : ( +
+
{t('th_supplier')} {t('th_invoice_number')} {t('th_invoice_date')}
e.stopPropagation()} + > + {selectable && ( + toggleSelect(inv.id)} + aria-label={t('bulk_select_row')} + className={cn( + 'transition-opacity duration-150', + selectedIds.has(inv.id) || selectedIds.size > 0 + ? 'opacity-100' + : 'opacity-0 group-hover:opacity-100 focus-visible:opacity-100 pointer-coarse:opacity-100', + )} + /> + )} + {inv.supplier?.name || '-'} - - {chipLabel} - + + + {chipLabel} + + {activeBatchInvoiceIds.has(inv.id) && inv.status !== 'paid' && ( + + {t('in_batch_chip')} + + )} +
+ + + + + + + + + + + {batches.map((batch) => ( + openDetail(batch.id)} + > + + + + + + + ))} + +
{t('th_created')}{t('th_count')}{t('th_total')}{t('th_status')}
+ {formatDate(batch.created_at)} + + {batch.item_count} + + {formatCurrency(batch.total_amount)} + + {batch.status === 'cancelled' ? ( + + {t('status_cancelled')} + + ) : ( + + {t('settled_count', { + settled: batch.settled_count, + total: batch.item_count, + })} + + )} + e.stopPropagation()} + > + + {batch.status === 'created' && ( + + )} + {batch.status === 'created' && canWrite && ( + + )} + +
+
+ )} + + {/* Batch detail: right slide-over (convention 13). */} + { + if (!open) { + setDetailId(null) + setDetail(null) + } + }} + > + + {detail ? ( + <> + + + {detail.status === 'cancelled' && ( + + {t('status_cancelled')} + + )} +
+ {detail.items.map((item) => { + const settled = + !item.invoice || item.invoice.remaining_amount <= SETTLED_EPSILON + return ( +
+
+ + {item.payee_name} + + + {item.invoice?.supplier_invoice_number ?? item.reference} + {' · '} + {formatDate(item.payment_date)} + +
+ {settled ? ( + + {t('item_settled')} + + ) : ( + + {t('item_unsettled')} + + )} + + {formatCurrency(item.amount)} + +
+ ) + })} +
+
+ {t('total_label')} + + {formatCurrency(detail.total_amount)} + +
+ {detail.status === 'created' && detailUnsettled > 0 && ( +

{t('mark_all_hint')}

+ )} +
+ +
+ {detail.status === 'created' && ( + + )} + {detail.status === 'created' && canWrite && detailUnsettled > 0 && ( + + )} +
+
+ + ) : ( + + {[1, 2, 3].map((i) => ( + + ))} + + )} +
+
+ + {/* Cancel confirm (convention 10): describe the outcome up front. */} + { + if (!open && !cancelling) setConfirmCancelId(null) + }} + > + + + {t('cancel_confirm_title')} + {t('cancel_confirm_body')} + + + + + + + + + ) +} diff --git a/app/api/sandbox/seed/route.ts b/app/api/sandbox/seed/route.ts index c5c8d7c3..038169f4 100644 --- a/app/api/sandbox/seed/route.ts +++ b/app/api/sandbox/seed/route.ts @@ -167,6 +167,12 @@ export async function POST(request: Request) { next_invoice_number: 5, next_delivery_note_number: 1, invoice_default_days: 30, + // Sender bank details: the pain.001 debtor for the betalfil demo. + // Example IBAN from the Swedish IBAN documentation range; BIC derives + // from it being an SEB-style example. Demo-only values. + iban: 'SE3550000000054910000003', + bic: 'ESSESESS', + bankgiro: '991-2346', onboarding_step: 6, onboarding_complete: true, initial_setup_path: 'fresh', @@ -867,7 +873,9 @@ export async function POST(request: Request) { org_number: '5559000001', vat_number: 'SE555900000101', email: 'demo+telekom@example.com', - bankgiro: '5559-0001', + // Luhn-valid (Bankgirot check digit): the betalfil flow validates + // payee numbers, so demo suppliers must carry numbers that pass. + bankgiro: '5559-0004', address_line1: 'Demovägen 10', postal_code: '111 22', city: 'Stockholm', @@ -881,7 +889,7 @@ export async function POST(request: Request) { supplier_type: 'swedish_business', org_number: '5559000002', vat_number: 'SE555900000201', - bankgiro: '5559-0002', + bankgiro: '5559-0012', address_line1: 'Demovägen 11', postal_code: '111 22', city: 'Stockholm', @@ -923,6 +931,9 @@ export async function POST(request: Request) { payment_reference: '47112026031', paid_at: toDateStr(fifteenDaysAgo), paid_amount: 600, + // Same normalization rule as paid_amount below: the other row in + // this bulk insert sets remaining_amount, so this one must too. + remaining_amount: 0, }, { user_id: userId, @@ -938,11 +949,17 @@ export async function POST(request: Request) { subtotal: 240, vat_amount: 28.80, total: 268.80, + // Luhn-valid OCR so the betalfil preview demos the structured + // reference path instead of the invoice-number fallback. + payment_reference: '882456', // Must be set explicitly: PostgREST normalizes columns across // rows in a bulk insert, so omitting paid_amount here while the // first row sets it sends null instead of falling through to the // schema default (0), violating the NOT NULL constraint. paid_amount: 0, + // No trigger derives this; without it the unpaid demo invoice + // shows "0 kr kvar att betala" and cannot join a betalfil. + remaining_amount: 268.80, }, ]) .select('id, supplier_invoice_number') diff --git a/app/api/suppliers/[id]/route.ts b/app/api/suppliers/[id]/route.ts index ec08377c..df764a05 100644 --- a/app/api/suppliers/[id]/route.ts +++ b/app/api/suppliers/[id]/route.ts @@ -94,6 +94,8 @@ export const PUT = withRouteContext( bank_account: body.bank_account, iban: body.iban, bic: body.bic, + clearing_number: body.clearing_number, + account_number: body.account_number, default_expense_account: body.default_expense_account, default_payment_terms: body.default_payment_terms, default_currency: body.default_currency, diff --git a/app/api/suppliers/route.ts b/app/api/suppliers/route.ts index f8f0bd1b..ca631734 100644 --- a/app/api/suppliers/route.ts +++ b/app/api/suppliers/route.ts @@ -63,6 +63,8 @@ export const POST = withRouteContext( bank_account: body.bank_account, iban: body.iban, bic: body.bic, + clearing_number: body.clearing_number, + account_number: body.account_number, default_expense_account: body.default_expense_account, default_payment_terms: body.default_payment_terms || 30, default_currency: body.default_currency || 'SEK', diff --git a/components/supplier-invoices/PaymentFileDialog.tsx b/components/supplier-invoices/PaymentFileDialog.tsx new file mode 100644 index 00000000..0a6efd87 --- /dev/null +++ b/components/supplier-invoices/PaymentFileDialog.tsx @@ -0,0 +1,360 @@ +'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' +} + +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 +} + +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(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>({}) + const [dates, setDates] = useState>({}) + + 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 ( + !creating && onOpenChange(next)}> + + + {t('dialog_title')} + + + {loading || !preview ? ( +
+ {[1, 2, 3].map((i) => ( +
+ + + +
+ ))} +
+ ) : ( +
+ {!preview.debtor_ok && ( + + {preview.debtor_missing === 'bic' + ? t('debtor_missing_bic') + : t('debtor_missing_iban')} + + )} + + {preview.eligible.length > 0 && ( +
+ + + + + + + + + + + + {preview.eligible.map((line) => ( + + + + + + + + ))} + + + + + + + +
{t('th_supplier')}{t('th_payee')}{t('th_reference')}{t('th_payment_date')}{t('th_amount')}
+ {line.supplier_name} + + {line.invoice_number} + {line.warnings.map((warning) => ( + + {t(`warning_${warning}`)} + + ))} + + + {line.payee.label} + + {line.reference.value} + + + setDates((prev) => ({ ...prev, [line.id]: e.target.value })) + } + className="h-8 w-[140px] text-xs tabular-nums" + /> + + + 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" + /> +
+ {t('total_label')} + + {formatCurrency(total)} +
+
+ )} + + {preview.eligible.length === 0 && ( +

{t('none_eligible')}

+ )} + + {preview.excluded.length > 0 && ( +
+

{t('excluded_title')}

+ {preview.excluded.map((row) => ( +

+ {invoiceLabelById?.get(row.id) ?? row.id}:{' '} + {t(`excluded_reason_${row.reason}`)} +

+ ))} +
+ )} + + {/* The file downloads fine and only fails at the bank if the + upload agreement is missing: say the precondition up front. */} +
+ + {t('pain001_agreement_warning')} +
+ + {hasAlreadyBatched && ( + + )} + +
+ + +
+
+ )} +
+
+ ) +} diff --git a/components/suppliers/SupplierForm.tsx b/components/suppliers/SupplierForm.tsx index 819d2810..a18b71a5 100644 --- a/components/suppliers/SupplierForm.tsx +++ b/components/suppliers/SupplierForm.tsx @@ -44,6 +44,8 @@ export default function SupplierForm({ plusgiro: z.string().optional(), iban: z.string().optional(), bic: z.string().optional(), + clearing_number: z.string().optional(), + account_number: z.string().optional(), default_expense_account: z.string().optional(), default_payment_terms: z.number().min(1).optional(), default_currency: z.string().optional(), @@ -74,6 +76,8 @@ export default function SupplierForm({ plusgiro: initialData?.plusgiro || '', iban: initialData?.iban || '', bic: initialData?.bic || '', + clearing_number: initialData?.clearing_number || '', + account_number: initialData?.account_number || '', default_expense_account: initialData?.default_expense_account || '', default_payment_terms: initialData?.default_payment_terms || 30, default_currency: initialData?.default_currency || 'SEK', @@ -211,6 +215,16 @@ export default function SupplierForm({ +
+
+ + +
+
+ + +
+
diff --git a/lib/api/schemas.ts b/lib/api/schemas.ts index c7bec459..c3249585 100644 --- a/lib/api/schemas.ts +++ b/lib/api/schemas.ts @@ -941,6 +941,8 @@ export const CreateSupplierSchema = z.object({ bank_account: z.string().optional(), iban: z.string().optional(), bic: z.string().optional(), + clearing_number: z.string().optional(), + account_number: z.string().optional(), default_expense_account: accountNumber.optional(), default_payment_terms: z.number().int().positive().optional(), default_currency: CurrencySchema.nullable().optional(), diff --git a/messages/en.json b/messages/en.json index 11d44f2c..7ccfec96 100644 --- a/messages/en.json +++ b/messages/en.json @@ -869,7 +869,75 @@ "status_picker_aria": "Filter by status", "search_placeholder": "Search supplier invoices …", "status_paid_date": "Paid {date}", - "help_body": "Approval attests the invoice for payment. Payments are reconciled automatically when they are matched against the bank, so there is no \"mark as paid\" button here." + "help_body": "Approval attests the invoice for payment. Select invoices with the checkbox and create a payment file to upload in your internet bank. Payments are reconciled automatically when they are matched against the bank.", + "bulkbar_selected": "{count, plural, one {invoice selected} other {invoices selected}}", + "bulk_create_file": "Create payment file", + "bulk_select_all": "Select all payable ({count})", + "bulk_clear": "Clear", + "bulk_select_row": "Select invoice for payment file", + "in_batch_chip": "In payment file", + "payment_files_link": "Payment files" + }, + "supplier_payment_files": { + "dialog_title": "Create payment file", + "th_supplier": "Supplier", + "th_payee": "Payee", + "th_reference": "Reference", + "th_payment_date": "Payment date", + "th_amount": "Amount", + "total_label": "Total", + "warning_unattested": "Not approved", + "warning_already_batched": "In payment file", + "warning_ocr_invalid": "Invalid OCR", + "confirm_already_batched": "One or more invoices are already part of an active payment file. I understand that a new file creates another payment for them.", + "excluded_title": "Not included in the payment file", + "excluded_reason_not_payable": "cannot be paid in its current status", + "excluded_reason_nothing_remaining": "nothing left to pay", + "excluded_reason_credit_note": "credit notes are not supported in payment files yet", + "excluded_reason_foreign_currency": "only SEK invoices can be included", + "excluded_reason_payee_missing": "the supplier has no bankgiro, plusgiro or bank account", + "excluded_reason_payee_invalid": "the supplier's payment details look invalid", + "excluded_reason_not_found": "the invoice was not found", + "none_eligible": "None of the selected invoices can be included in a payment file.", + "cancel": "Cancel", + "create_and_download": "Create and download", + "created_toast": "Payment file downloaded", + "create_failed_title": "Could not create the payment file", + "preview_failed_title": "Could not prepare the payment file", + "download_failed_title": "Download failed", + "download_timeout": "The server did not respond in time. The file is saved under Payment files and can be downloaded again from there.", + "download_network": "No contact with the server. The file is saved under Payment files and can be downloaded again from there.", + "pain001_agreement_warning": "The file is in ISO 20022 format (pain.001) and is uploaded in your internet bank. Some banks require a file communication agreement; verify that your bank accepts the file well before the payment date.", + "debtor_missing_iban": "The company IBAN is missing and is needed as the sender account in the payment file.", + "debtor_missing_bic": "The company bank's BIC is missing and could not be derived.", + "debtor_missing_link": "Open Settings → Invoicing", + "history_title": "Payment files", + "th_created": "Created", + "th_count": "Count", + "th_total": "Total", + "th_status": "Status", + "status_cancelled": "Cancelled", + "settled_count": "{settled} of {total} paid", + "download_again": "Download again", + "cancel_batch": "Cancel file", + "cancel_confirm_title": "Cancel the payment file?", + "cancel_confirm_body": "The payment file cannot be downloaded again after cancellation. A file already uploaded to the bank is not recalled by this; cancel the order in your internet bank if needed.", + "cancel_confirm_abort": "Keep it", + "cancel_confirm_action": "Cancel file", + "cancel_failed_title": "Could not cancel the payment file", + "cancelled_toast": "Payment file cancelled", + "empty_title": "No payment files", + "empty_description": "Select invoices on the supplier invoices page and create a payment file to upload in your internet bank.", + "empty_action": "Go to supplier invoices", + "detail_title": "Payment file · {count} payments", + "item_settled": "Paid", + "item_unsettled": "Not reconciled", + "mark_all_paid": "Mark {count} as paid", + "mark_all_hint": "Once the bank has executed the payments they are reconciled automatically by bank matching. Without a bank connection you can book them all as paid here instead.", + "mark_all_result_title": "{booked} {booked, plural, one {payment booked} other {payments booked}}", + "mark_all_result_duplicates": "{count} skipped: a matching bank transaction exists. Book them via bank matching instead.", + "mark_all_result_failed": "{count} could not be booked. Open the invoices and try again.", + "mark_all_result_settled": "{count} were already paid." }, "purchase_orders": { "title": "Purchase orders", @@ -1320,6 +1388,8 @@ "iban_placeholder": "SE45 5000 0000 0583 9825 7466", "swift_label": "SWIFT/BIC", "bank_account_label": "Bank account", + "clearing_label": "Clearing number", + "account_number_label": "Account number", "default_payment_terms_label": "Payment terms (days)", "default_account_label": "Default account", "default_account_placeholder": "e.g. 5410", diff --git a/messages/sv.json b/messages/sv.json index 00a3f808..2f763602 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -869,7 +869,75 @@ "status_picker_aria": "Filtrera på status", "search_placeholder": "Sök leverantörsfaktura …", "status_paid_date": "Betald {date}", - "help_body": "Godkännandet attesterar fakturan för betalning. Betalningar prickas av automatiskt när de matchas mot banken, så det finns ingen \"markera som betald\"-knapp här." + "help_body": "Godkännandet attesterar fakturan för betalning. Markera fakturor med kryssrutan och skapa en betalfil som laddas upp i internetbanken. Betalningar prickas av automatiskt när de matchas mot banken.", + "bulkbar_selected": "{count, plural, one {faktura vald} other {fakturor valda}}", + "bulk_create_file": "Skapa betalfil", + "bulk_select_all": "Välj alla betalbara ({count})", + "bulk_clear": "Rensa", + "bulk_select_row": "Välj faktura för betalfil", + "in_batch_chip": "I betalfil", + "payment_files_link": "Betalfiler" + }, + "supplier_payment_files": { + "dialog_title": "Skapa betalfil", + "th_supplier": "Leverantör", + "th_payee": "Mottagare", + "th_reference": "Referens", + "th_payment_date": "Betaldatum", + "th_amount": "Belopp", + "total_label": "Summa", + "warning_unattested": "Ej attesterad", + "warning_already_batched": "I betalfil", + "warning_ocr_invalid": "Ogiltigt OCR-nr", + "confirm_already_batched": "En eller flera fakturor ingår redan i en aktiv betalfil. Jag förstår att en ny fil skapar ytterligare en betalning för dem.", + "excluded_title": "Ingår inte i betalfilen", + "excluded_reason_not_payable": "kan inte betalas i nuvarande status", + "excluded_reason_nothing_remaining": "inget kvar att betala", + "excluded_reason_credit_note": "kreditfakturor stöds inte i betalfil ännu", + "excluded_reason_foreign_currency": "endast fakturor i SEK kan ingå", + "excluded_reason_payee_missing": "leverantören saknar bankgiro, plusgiro eller bankkonto", + "excluded_reason_payee_invalid": "leverantörens betalningsuppgifter ser felaktiga ut", + "excluded_reason_not_found": "fakturan hittades inte", + "none_eligible": "Ingen av de valda fakturorna kan ingå i en betalfil.", + "cancel": "Avbryt", + "create_and_download": "Skapa och ladda ner", + "created_toast": "Betalfil nedladdad", + "create_failed_title": "Kunde inte skapa betalfilen", + "preview_failed_title": "Kunde inte förbereda betalfilen", + "download_failed_title": "Nedladdningen misslyckades", + "download_timeout": "Servern svarade inte i tid. Filen finns sparad under Betalfiler och kan laddas ner igen därifrån.", + "download_network": "Ingen kontakt med servern. Filen finns sparad under Betalfiler och kan laddas ner igen därifrån.", + "pain001_agreement_warning": "Filen är i ISO 20022-format (pain.001) och laddas upp i internetbanken. Vissa banker kräver filkommunikationsavtal; kontrollera att din bank tar emot filen i god tid före betaldagen.", + "debtor_missing_iban": "Företagets IBAN saknas och behövs som avsändarkonto i betalfilen.", + "debtor_missing_bic": "Företagsbankens BIC saknas och kunde inte härledas.", + "debtor_missing_link": "Öppna Inställningar → Fakturering", + "history_title": "Betalfiler", + "th_created": "Skapad", + "th_count": "Antal", + "th_total": "Summa", + "th_status": "Status", + "status_cancelled": "Makulerad", + "settled_count": "{settled} av {total} betalda", + "download_again": "Ladda ner igen", + "cancel_batch": "Makulera", + "cancel_confirm_title": "Makulera betalfilen?", + "cancel_confirm_body": "Betalfilen kan inte laddas ner igen efter makulering. En fil som redan laddats upp till banken återkallas inte av detta; makulera i så fall uppdraget i internetbanken.", + "cancel_confirm_abort": "Avbryt", + "cancel_confirm_action": "Makulera", + "cancel_failed_title": "Kunde inte makulera betalfilen", + "cancelled_toast": "Betalfilen makulerad", + "empty_title": "Inga betalfiler", + "empty_description": "Markera fakturor på leverantörsfakturasidan och skapa en betalfil som laddas upp i internetbanken.", + "empty_action": "Till leverantörsfakturor", + "detail_title": "Betalfil · {count} betalningar", + "item_settled": "Betald", + "item_unsettled": "Ej avprickad", + "mark_all_paid": "Markera {count} som betalda", + "mark_all_hint": "När banken har utfört betalningarna prickas de av automatiskt vid bankmatchning. Utan bankkoppling kan du i stället bokföra alla som betalda här.", + "mark_all_result_title": "{booked} {booked, plural, one {betalning bokförd} other {betalningar bokförda}}", + "mark_all_result_duplicates": "{count} hoppades över: det finns en matchande banktransaktion. Bokför dem via bankmatchningen i stället.", + "mark_all_result_failed": "{count} kunde inte bokföras. Öppna fakturorna och försök igen.", + "mark_all_result_settled": "{count} var redan betalda." }, "purchase_orders": { "title": "Inköpsorder", @@ -1320,6 +1388,8 @@ "iban_placeholder": "SE45 5000 0000 0583 9825 7466", "swift_label": "SWIFT/BIC", "bank_account_label": "Bankkonto", + "clearing_label": "Clearingnummer", + "account_number_label": "Kontonummer", "default_payment_terms_label": "Betalningsvillkor (dagar)", "default_account_label": "Standardkonto", "default_account_placeholder": "T.ex. 5410",