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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-08-10 20:07:04 +02:00
committed by GitHub
co-authored by Claude Fable 5 Jakob Wennberg
parent 576a34750a
commit f0bedc14af
10 changed files with 1134 additions and 9 deletions
+153 -5
View File
@@ -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<string | null>(null)
const [fyPeriod, setFyPeriod] = useState<FiscalPeriod | null>(null)
const [approvingId, setApprovingId] = useState<string | null>(null)
// Payment-file bulk selection + the "already in an active betalfil" chip map.
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [activeBatchInvoiceIds, setActiveBatchInvoiceIds] = useState<Set<string>>(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<string>()
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"
/>
</div>
<div className="ml-auto">
<div className="ml-auto flex items-center gap-4">
<Link href="/supplier-invoices/payment-files" className={QUIET_LINK_CLASS}>
{t('payment_files_link')}
</Link>
<FyPicker
value={fyPeriodId}
onChange={(periodId, period) => {
@@ -257,6 +329,37 @@ export default function SupplierInvoicesPage() {
</div>
</div>
{/* Bulkbar: appears once anything is selected (transactions-page shape). */}
{selectedIds.size > 0 && (
<div className="flex flex-wrap items-center gap-x-5 gap-y-2 border-b border-border px-1 py-2.5 text-[12.5px] animate-fade-in">
<span className="whitespace-nowrap">
<strong className="font-semibold tabular-nums">{selectedIds.size}</strong>{' '}
{t('bulkbar_selected', { count: selectedIds.size })}
</span>
<Button size="sm" onClick={() => setShowPaymentDialog(true)}>
{t('bulk_create_file')}
</Button>
{!allSelectableSelected && (
<button
type="button"
className={QUIET_LINK_CLASS}
onClick={() =>
setSelectedIds(new Set(selectableInvoices.map((inv) => inv.id)))
}
>
{t('bulk_select_all', { count: selectableInvoices.length })}
</button>
)}
<button
type="button"
className={QUIET_LINK_CLASS}
onClick={() => setSelectedIds(new Set())}
>
{t('bulk_clear')}
</button>
</div>
)}
{isLoading ? (
<div className="space-y-3">
{[1, 2, 3, 4].map((i) => (
@@ -288,6 +391,7 @@ export default function SupplierInvoicesPage() {
<table className="w-full border-collapse text-[13px]">
<thead>
<tr>
{canWrite && <th className={cn(TH_CLASS, 'w-[26px] !pl-1')} aria-hidden="true"></th>}
<th className={cn(TH_CLASS, 'w-full')}>{t('th_supplier')}</th>
<th className={TH_CLASS}>{t('th_invoice_number')}</th>
<th className={cn(TH_CLASS, 'hidden text-right md:table-cell')}>{t('th_invoice_date')}</th>
@@ -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 (
<tr
key={inv.id}
className="group cursor-pointer transition-colors duration-150 hover:bg-secondary/35"
className={cn(
'group cursor-pointer transition-colors duration-150 hover:bg-secondary/35',
selectedIds.has(inv.id) && 'bg-secondary/40',
)}
onClick={() => router.push(`/supplier-invoices/${inv.id}`)}
>
{/* Hover-revealed selection checkbox (JournalEntryList shape). */}
{canWrite && (
<td
className={cn(TD_CLASS, 'w-[26px] !pl-1 py-[9px]')}
onClick={(e) => e.stopPropagation()}
>
{selectable && (
<Checkbox
checked={selectedIds.has(inv.id)}
onCheckedChange={() => 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',
)}
/>
)}
</td>
)}
<td className={cn(TD_CLASS, 'max-w-0 w-full')}>
<span className="block truncate">{inv.supplier?.name || '-'}</span>
</td>
@@ -348,9 +477,16 @@ export default function SupplierInvoicesPage() {
{formatCurrency(inv.remaining_amount, inv.currency)}
</td>
<td className={cn(TD_CLASS, 'whitespace-nowrap')}>
<Badge variant={chipVariant} className="font-normal">
{chipLabel}
</Badge>
<span className="inline-flex items-center gap-1">
<Badge variant={chipVariant} className="font-normal">
{chipLabel}
</Badge>
{activeBatchInvoiceIds.has(inv.id) && inv.status !== 'paid' && (
<Badge variant="outline" className="font-normal">
{t('in_batch_chip')}
</Badge>
)}
</span>
</td>
{/* 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 && (
<PaymentFileDialog
open
onOpenChange={(open) => {
if (!open) setShowPaymentDialog(false)
}}
invoiceIds={Array.from(selectedIds)}
invoiceLabelById={invoiceLabelById}
onCreated={handleBatchCreated}
/>
)}
</div>
)
}
@@ -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<SupplierPaymentBatch, 'id' | 'created_at'>): 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<BatchListRow[]>([])
const [isLoading, setIsLoading] = useState(true)
const [detail, setDetail] = useState<BatchDetail | null>(null)
const [detailId, setDetailId] = useState<string | null>(null)
const [downloadingId, setDownloadingId] = useState<string | null>(null)
const [confirmCancelId, setConfirmCancelId] = useState<string | null>(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<SupplierPaymentBatch, 'id' | 'created_at'>) {
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 (
<div className="space-y-8">
<PageHeader title={t('history_title')} />
{isLoading ? (
<div className="space-y-3">
{[1, 2, 3].map((i) => (
<div key={i} className="flex items-center gap-4 px-4 py-3">
<Skeleton className="h-4 w-28" />
<Skeleton className="h-4 w-20 flex-1" />
<Skeleton className="h-4 w-24" />
</div>
))}
</div>
) : batches.length === 0 ? (
<EmptyState
icon={FileText}
title={t('empty_title')}
description={t('empty_description')}
actionLabel={t('empty_action')}
actionHref="/supplier-invoices"
/>
) : (
<div className="overflow-x-auto">
<table className="w-full border-collapse text-[13px]">
<thead>
<tr>
<th className={TH_CLASS}>{t('th_created')}</th>
<th className={cn(TH_CLASS, 'text-right')}>{t('th_count')}</th>
<th className={cn(TH_CLASS, 'text-right')}>{t('th_total')}</th>
<th className={cn(TH_CLASS, 'w-full')}>{t('th_status')}</th>
<th className={cn(TH_CLASS, 'w-[180px]')} aria-hidden="true"></th>
</tr>
</thead>
<tbody className="stagger-enter">
{batches.map((batch) => (
<tr
key={batch.id}
className="group cursor-pointer transition-colors duration-150 hover:bg-secondary/35"
onClick={() => openDetail(batch.id)}
>
<td className={cn(TD_CLASS, 'whitespace-nowrap tabular-nums')}>
{formatDate(batch.created_at)}
</td>
<td className={cn(TD_CLASS, 'whitespace-nowrap text-right tabular-nums')}>
{batch.item_count}
</td>
<td className={cn(TD_CLASS, 'whitespace-nowrap text-right tabular-nums rr-mask')}>
{formatCurrency(batch.total_amount)}
</td>
<td className={cn(TD_CLASS, 'whitespace-nowrap')}>
{batch.status === 'cancelled' ? (
<Badge variant="outline" className="font-normal">
{t('status_cancelled')}
</Badge>
) : (
<span className="text-muted-foreground">
{t('settled_count', {
settled: batch.settled_count,
total: batch.item_count,
})}
</span>
)}
</td>
<td
className={cn(TD_CLASS, 'whitespace-nowrap text-right')}
onClick={(e) => e.stopPropagation()}
>
<span className="flex items-center justify-end gap-4 opacity-0 transition-opacity duration-150 focus-within:opacity-100 group-hover:opacity-100 pointer-coarse:opacity-100">
{batch.status === 'created' && (
<button
type="button"
className={QUIET_LINK_CLASS}
onClick={() => handleDownload(batch)}
disabled={downloadingId !== null}
>
{t('download_again')}
</button>
)}
{batch.status === 'created' && canWrite && (
<button
type="button"
className={QUIET_LINK_CLASS}
onClick={() => setConfirmCancelId(batch.id)}
>
{t('cancel_batch')}
</button>
)}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Batch detail: right slide-over (convention 13). */}
<SlideOver
open={detailId != null}
onOpenChange={(open) => {
if (!open) {
setDetailId(null)
setDetail(null)
}
}}
>
<SlideOverContent aria-describedby={undefined}>
{detail ? (
<>
<SlideOverHeader
kicker={formatDate(detail.created_at)}
title={t('detail_title', { count: detail.item_count })}
/>
<SlideOverBody className="space-y-4">
{detail.status === 'cancelled' && (
<Badge variant="outline" className="font-normal">
{t('status_cancelled')}
</Badge>
)}
<div className="space-y-0">
{detail.items.map((item) => {
const settled =
!item.invoice || item.invoice.remaining_amount <= SETTLED_EPSILON
return (
<div
key={item.id}
className="flex items-center gap-3 border-b border-border/60 py-2.5 text-[13px]"
>
<div className="min-w-0 flex-1">
<Link
href={`/supplier-invoices/${item.supplier_invoice_id}`}
className="block truncate hover:underline"
>
{item.payee_name}
</Link>
<span className="block text-[11px] text-muted-foreground tabular-nums">
{item.invoice?.supplier_invoice_number ?? item.reference}
{' · '}
{formatDate(item.payment_date)}
</span>
</div>
{settled ? (
<span className="whitespace-nowrap text-[11px] text-muted-foreground">
{t('item_settled')}
</span>
) : (
<Badge variant="outline" className="font-normal">
{t('item_unsettled')}
</Badge>
)}
<span className="whitespace-nowrap text-right tabular-nums rr-mask">
{formatCurrency(item.amount)}
</span>
</div>
)
})}
</div>
<div className="flex items-center justify-between text-[13px]">
<span className="text-muted-foreground">{t('total_label')}</span>
<span className="font-medium tabular-nums rr-mask">
{formatCurrency(detail.total_amount)}
</span>
</div>
{detail.status === 'created' && detailUnsettled > 0 && (
<p className="text-xs text-muted-foreground">{t('mark_all_hint')}</p>
)}
</SlideOverBody>
<SlideOverFooter>
<div className="flex w-full flex-wrap items-center justify-end gap-3">
{detail.status === 'created' && (
<Button
variant="outline"
onClick={() => handleDownload(detail)}
disabled={downloadingId !== null}
>
{t('download_again')}
</Button>
)}
{detail.status === 'created' && canWrite && detailUnsettled > 0 && (
<Button onClick={handleMarkAllPaid} disabled={markingAll}>
{markingAll && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t('mark_all_paid', { count: detailUnsettled })}
</Button>
)}
</div>
</SlideOverFooter>
</>
) : (
<SlideOverBody className="space-y-3 pt-6">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-5 w-full" />
))}
</SlideOverBody>
)}
</SlideOverContent>
</SlideOver>
{/* Cancel confirm (convention 10): describe the outcome up front. */}
<Dialog
open={confirmCancelId != null}
onOpenChange={(open) => {
if (!open && !cancelling) setConfirmCancelId(null)
}}
>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{t('cancel_confirm_title')}</DialogTitle>
<DialogDescription>{t('cancel_confirm_body')}</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="ghost"
onClick={() => setConfirmCancelId(null)}
disabled={cancelling}
>
{t('cancel_confirm_abort')}
</Button>
<Button variant="destructive" onClick={handleCancel} disabled={cancelling}>
{cancelling && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t('cancel_confirm_action')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
+19 -2
View File
@@ -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')
+2
View File
@@ -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,
+2
View File
@@ -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',
@@ -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<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={{ label: t('debtor_missing_link'), href: '/settings/invoicing' }}>
{preview.debtor_missing === 'bic'
? t('debtor_missing_bic')
: 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>
)
}
+14
View File
@@ -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({
<Input id="plusgiro" placeholder="XXXXXXX-X" {...register('plusgiro')} />
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="clearing_number">{t('clearing_label')}</Label>
<Input id="clearing_number" placeholder="XXXX" {...register('clearing_number')} />
</div>
<div className="space-y-2">
<Label htmlFor="account_number">{t('account_number_label')}</Label>
<Input id="account_number" placeholder="XXXXXXXXX" {...register('account_number')} />
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="iban">{t('iban_label')}</Label>
+2
View File
@@ -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(),
+71 -1
View File
@@ -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",
+71 -1
View File
@@ -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",