* 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>
99 lines
3.5 KiB
TypeScript
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`
|
|
}
|