Files
accounted/lib/invoices/pdf-filename.ts
T
6b9338f17e feat(invoices): Betald-stämpel i faktura-PDF + betalningsbekräftelse till kund (#1700)
* feat(invoices): Betald-stämpel i faktura-PDF + betalningsbekräftelse till kund

Closes #1693. A paid faktura re-renders with a BETALD banner (paid date
and amount) and "Betalt: X" followed by "Att betala: 0"; partially_paid
gets the Betalt / Att betala (remaining) rows without a banner. Credit
notes and proformas are unchanged. Labels in sv and en.

The paid copy is its own document, a betalningsbekräftelse, never the
archived original: GET /api/invoices/[id]/pdf?variant=paid refuses
anything but status paid (409 INVOICE_PAYMENT_CONFIRMATION_NOT_PAID),
names the file Betalningsbekraftelse-<nr>.pdf and never reads or
replaces the delivery archive. invoice-pdf-source gains the
'payment_confirmation' re-render reason so the UI caveats it like any
re-render. POST /api/invoices/[id]/send-payment-confirmation emails the
paid PDF with a dedicated subject/body through the existing email
service and recipient routing, without touching status, sent_at,
journal entries or invoice_deliveries (no kind column there; logged via
the route logger instead).

Detail page: the two actions sit inside the Betald card (download paid
copy, send confirmation with an up-front confirm dialog), not in the
header row. No migrations.

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

* fix(invoices): one-line hint for the betalningsbekräftelse actions

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-19 20:17:18 +02:00

99 lines
3.5 KiB
TypeScript

import type { InvoiceDocumentType } from '@/types'
const MAX_NAME_PART_LENGTH = 60
const MAX_NUMBER_PART_LENGTH = 40
const MAX_FILENAME_BYTES = 255
interface InvoicePdfFilenameInput {
companyName?: string | null
customerName?: string | null
invoiceNumber?: string | null
invoiceId?: string | null
invoiceDate?: string | null
documentType?: InvoiceDocumentType | null
isCreditNote?: boolean
}
function safeFilenamePart(value: string | null | undefined, fallback: string, maxLength: number): string {
const normalized = (value ?? '')
.toWellFormed()
.normalize('NFC')
.replace(/[\u0000-\u001f\u007f<>:"/\\|?*]+/g, ' ')
.replace(/\s+/g, ' ')
.replace(/[ .]+$/g, '')
.trim()
if (!normalized) return fallback
return Array.from(normalized).slice(0, maxLength).join('').replace(/[ .]+$/g, '') || fallback
}
function documentLabel(documentType: InvoiceDocumentType, isCreditNote: boolean): string {
if (isCreditNote) return 'Kreditfaktura'
if (documentType === 'proforma') return 'Proformafaktura'
if (documentType === 'delivery_note') return 'Följesedel'
return 'Faktura'
}
function utf8ByteLength(value: string): number {
return new TextEncoder().encode(value).length
}
function fitFilename(companyName: string, customerName: string, suffix: string): string {
const company = Array.from(companyName)
const customer = Array.from(customerName)
const build = () => `${company.join('')} x ${customer.join('')} ${suffix}`
while (utf8ByteLength(build()) > MAX_FILENAME_BYTES && (company.length > 1 || customer.length > 1)) {
if (utf8ByteLength(company.join('')) >= utf8ByteLength(customer.join('')) && company.length > 1) {
company.pop()
} else if (customer.length > 1) {
customer.pop()
} else {
company.pop()
}
}
return build()
}
/**
* Build a descriptive, cross-platform-safe PDF filename for an invoice document.
*
* Example: `Oppy x Kund AB Faktura nr 2621 20260721.pdf`.
*/
export function invoicePdfFilename({
companyName,
customerName,
invoiceNumber,
invoiceId,
invoiceDate,
documentType = 'invoice',
isCreditNote = false,
}: InvoicePdfFilenameInput): string {
const company = safeFilenamePart(companyName, 'Företag', MAX_NAME_PART_LENGTH)
const customer = safeFilenamePart(customerName, 'Kund', MAX_NAME_PART_LENGTH)
const label = documentLabel(documentType ?? 'invoice', isCreditNote)
// The cross-platform filename is descriptive only. The invoice body retains
// the authoritative number and credit-note reference, including separators.
const number = invoiceNumber
? `nr ${safeFilenamePart(invoiceNumber, 'okänd', MAX_NUMBER_PART_LENGTH)}`
: `utkast-${safeFilenamePart(invoiceId?.slice(0, 8), 'utan-nummer', MAX_NUMBER_PART_LENGTH)}`
const compactDate = (invoiceDate ?? '').replace(/[^0-9]/g, '').slice(0, 8)
const suffix = [label, number, compactDate].filter(Boolean).join(' ') + '.pdf'
return fitFilename(company, customer, suffix)
}
/**
* Filename for the betalningsbekräftelse (#1693): the paid re-render of a
* settled faktura. Deliberately not the invoice filename shape above, so the
* file can never be mistaken for the invoice that was sent. ASCII on purpose
* (no ä): the name travels as an email attachment to arbitrary mail clients.
*
* Example: `Betalningsbekraftelse-2621.pdf`.
*/
export function paymentConfirmationPdfFilename(invoiceNumber: string | null | undefined): string {
const number = safeFilenamePart(invoiceNumber, 'okand', MAX_NUMBER_PART_LENGTH).replace(/\s+/g, '-')
return `Betalningsbekraftelse-${number}.pdf`
}