diff --git a/apps/mana/apps/web/package.json b/apps/mana/apps/web/package.json index 79b118b3a..a8fd81faa 100644 --- a/apps/mana/apps/web/package.json +++ b/apps/mana/apps/web/package.json @@ -89,6 +89,7 @@ "svelte-dnd-action": "^0.9.68", "svelte-i18n": "^4.0.0", "svelte-sonner": "^1.0.5", + "swissqrbill": "^4.3.0", "zod": "^3.25.76" }, "type": "module" diff --git a/apps/mana/apps/web/src/lib/modules/invoices/index.ts b/apps/mana/apps/web/src/lib/modules/invoices/index.ts index 5c10ae3b5..a8c7b80b1 100644 --- a/apps/mana/apps/web/src/lib/modules/invoices/index.ts +++ b/apps/mana/apps/web/src/lib/modules/invoices/index.ts @@ -34,6 +34,9 @@ export { export { computeLineTotal, computeInvoiceTotals, EMPTY_TOTALS } from './totals'; +export { renderInvoicePdf, renderInvoicePdfBlob, qrBillStatus } from './pdf/renderer'; +export { generateSCORReference, QRBillError } from './pdf/qr-bill'; + export { invoicesStore } from './stores/invoices.svelte'; export { invoiceSettingsStore, ensureSettings } from './stores/settings.svelte'; diff --git a/apps/mana/apps/web/src/lib/modules/invoices/pdf/qr-bill.ts b/apps/mana/apps/web/src/lib/modules/invoices/pdf/qr-bill.ts new file mode 100644 index 000000000..a0eb42d1a --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/invoices/pdf/qr-bill.ts @@ -0,0 +1,295 @@ +/** + * Swiss QR-Bill integration. + * + * ## Architecture + * + * `swissqrbill/pdf` targets PDFKit, not pdf-lib. So we use the `svg` + * export, rasterise the SVG to PNG in the browser, and embed that PNG + * at the bottom of the last page of the pdf-lib invoice. + * + * invoice + settings + * │ + * ▼ + * buildQRBillData() ← validation: CHF/EUR + valid IBAN + parseable address + * │ + * ▼ + * new SwissQRBill(data).toString() ← SVG markup + * │ + * ▼ + * rasteriseSvgToPng() ← canvas, 300 DPI target + * │ + * ▼ + * doc.embedPng(bytes) → page.drawImage(at y=0, full width, 105mm tall) + * + * Browser-only — canvas + Image are not available in SSR. The renderer + * never runs during prerender (DetailView is client-only for PDF), so + * this is safe. + * + * ## Address parsing + * + * The QR-Bill spec (v2022) requires structured addresses: street, city, + * zip, country. Our sender/client snapshots hold free-text multi-line + * addresses. `parseAddress()` is a heuristic for Swiss/DE addresses: + * + * "Bahnhofstrasse 1\n8000 Zürich" + * ↓ + * { street: "Bahnhofstrasse 1", zip: "8000", city: "Zürich" } + * + * If parsing fails (missing zip+city line), we throw `QRBillError` and + * the renderer skips the Zahlteil with a visible warning to the user. + */ + +import type { PDFDocument } from 'pdf-lib'; +import { SwissQRBill } from 'swissqrbill/svg'; +import { isIBANValid, calculateSCORReferenceChecksum } from 'swissqrbill/utils'; +import type { Data } from 'swissqrbill/types'; +import type { Invoice, InvoiceSettings } from '../types'; +import { CURRENCIES } from '../constants'; +import { A4, mm } from './templates/default'; + +export class QRBillError extends Error { + constructor( + message: string, + public readonly reason: + | 'invalid-currency' + | 'missing-iban' + | 'invalid-iban' + | 'unparseable-sender-address' + | 'unparseable-client-address' + | 'missing-amount' + ) { + super(message); + this.name = 'QRBillError'; + } +} + +// ─── SCOR reference ────────────────────────────────────── + +/** + * Generate an ISO 11649 Creditor Reference (SCOR) for the invoice. Uses + * the invoice number as payload so the reference is stable across re- + * renders. Format: `RF{check}{payload}`. + * + * invoice.number "2026-0042" → payload "20260042" → RF{check}20260042 + * + * Non-alphanumerics are stripped (the spec allows only [0-9A-Z] in the + * payload). The payload is truncated to 21 chars (SCOR max). + */ +export function generateSCORReference(invoiceNumber: string): string { + const payload = invoiceNumber + .replace(/[^0-9A-Za-z]/g, '') + .toUpperCase() + .slice(0, 21); + if (!payload) { + // Degenerate input (e.g. all dashes) — fall back to a literal. + return `RF${calculateSCORReferenceChecksum('INVOICE')}INVOICE`; + } + const checksum = calculateSCORReferenceChecksum(payload); + return `RF${checksum}${payload}`; +} + +// ─── Address parsing ───────────────────────────────────── + +interface StructuredAddress { + street: string; + zip: string; + city: string; + country: string; +} + +/** + * Parse a free-text multi-line address into structured fields. + * Expects two non-empty lines: + * Line 1: "Street + number" + * Line 2: " " where zip is 4-5 digits (CH=4, DE=5, AT=4) + * Returns null if the format isn't recognised. + */ +function parseAddress(text: string | undefined, defaultCountry = 'CH'): StructuredAddress | null { + if (!text) return null; + const lines = text + .split('\n') + .map((l) => l.trim()) + .filter(Boolean); + if (lines.length < 2) return null; + const last = lines[lines.length - 1]; + const match = last.match(/^(\d{4,5})\s+(.+)$/); + if (!match) return null; + const [, zip, city] = match; + const street = lines.slice(0, -1).join(', '); + if (!street) return null; + return { street, zip, city, country: defaultCountry }; +} + +// ─── QR-Bill data assembly ──────────────────────────────── + +/** + * Build the data object for swissqrbill, or throw QRBillError with the + * specific reason. The caller catches and surfaces a warning in the UI. + */ +export function buildQRBillData(invoice: Invoice, settings: InvoiceSettings): Data { + if (invoice.currency !== 'CHF' && invoice.currency !== 'EUR') { + throw new QRBillError( + `QR-Rechnung unterstützt nur CHF und EUR (nicht ${invoice.currency}).`, + 'invalid-currency' + ); + } + const iban = (settings.senderIban ?? '').replace(/\s+/g, ''); + if (!iban) { + throw new QRBillError( + 'IBAN fehlt in den Rechnungs-Einstellungen. Bitte ergänzen.', + 'missing-iban' + ); + } + if (!isIBANValid(iban)) { + throw new QRBillError('Die hinterlegte IBAN ist ungültig.', 'invalid-iban'); + } + + const creditorAddr = parseAddress(settings.senderAddress); + if (!creditorAddr) { + throw new QRBillError( + 'Absender-Adresse konnte nicht geparst werden (erwartet: Strasse + Nummer, dann "PLZ Ort").', + 'unparseable-sender-address' + ); + } + + const amount = invoice.totals.gross / CURRENCIES[invoice.currency].minorUnit; + if (!Number.isFinite(amount) || amount <= 0) { + throw new QRBillError('Rechnungsbetrag muss grösser als 0 sein.', 'missing-amount'); + } + + const debtorAddr = parseAddress(invoice.clientSnapshot.address); + // Debtor is optional per spec; if the client address doesn't parse, we + // still emit the QR-Bill without a debtor (user can fill it in by hand). + + // Prefer the reference persisted on the invoice (set at create time) so + // the reference is stable even if invoice.number is later edited. Fall + // back to a fresh derivation for pre-M5 invoices that have null. + const reference = invoice.referenceNumber ?? generateSCORReference(invoice.number); + + const data: Data = { + currency: invoice.currency as 'CHF' | 'EUR', + amount, + reference, + message: invoice.subject ?? undefined, + creditor: { + account: iban, + name: settings.senderName, + address: creditorAddr.street, + zip: creditorAddr.zip, + city: creditorAddr.city, + country: creditorAddr.country, + }, + }; + + if (debtorAddr && invoice.clientSnapshot.name) { + data.debtor = { + name: invoice.clientSnapshot.name, + address: debtorAddr.street, + zip: debtorAddr.zip, + city: debtorAddr.city, + country: debtorAddr.country, + }; + } + + return data; +} + +// ─── SVG → PNG rasterisation ───────────────────────────── + +/** + * Rasterise the QR-Bill SVG to a PNG byte array via the browser's native + * Image + Canvas path. Target ~300 DPI across 210×105mm so the QR code + * stays crisp when scaled back down to the embedded size. + * + * QR-Bill modules need to stay at least 1mm per module — at 300 DPI + * that's ~12 px per module, safely above any scanner threshold. + */ +async function rasteriseSvgToPng(svg: string): Promise { + // Target canvas pixel size at 300 DPI (25.4mm per inch): + // 210mm × (300/25.4) ≈ 2480 px + // 105mm × (300/25.4) ≈ 1240 px + const widthPx = 2480; + const heightPx = 1240; + + const blob = new Blob([svg], { type: 'image/svg+xml;charset=utf-8' }); + const url = URL.createObjectURL(blob); + try { + const img = await loadImage(url); + const canvas = document.createElement('canvas'); + canvas.width = widthPx; + canvas.height = heightPx; + const ctx = canvas.getContext('2d'); + if (!ctx) throw new Error('Canvas 2D-Kontext nicht verfügbar'); + // White background so the QR has the required contrast (spec requires + // the Zahlteil to be printed on white). + ctx.fillStyle = '#ffffff'; + ctx.fillRect(0, 0, widthPx, heightPx); + ctx.drawImage(img, 0, 0, widthPx, heightPx); + + const pngBlob: Blob = await new Promise((resolve, reject) => { + canvas.toBlob( + (b) => (b ? resolve(b) : reject(new Error('toBlob() lieferte null'))), + 'image/png' + ); + }); + const arrayBuf = await pngBlob.arrayBuffer(); + return new Uint8Array(arrayBuf); + } finally { + URL.revokeObjectURL(url); + } +} + +function loadImage(src: string): Promise { + return new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => resolve(img); + img.onerror = () => reject(new Error('SVG konnte nicht in Bild geladen werden')); + img.src = src; + }); +} + +// ─── Public API ────────────────────────────────────────── + +/** + * Render the QR-Bill for `invoice + settings` into a PNG + metadata. Does + * not touch pdf-lib — callers embed the PNG where they need it. Throws + * `QRBillError` if the invoice isn't eligible. + */ +export async function renderQRBillPng( + invoice: Invoice, + settings: InvoiceSettings +): Promise<{ bytes: Uint8Array; widthPt: number; heightPt: number }> { + const data = buildQRBillData(invoice, settings); + const svg = new SwissQRBill(data).toString(); + const bytes = await rasteriseSvgToPng(svg); + return { + bytes, + widthPt: A4.width, // 210mm + heightPt: mm(105), // official QR-Bill height + }; +} + +/** + * Attach the QR-Bill to the given pdf-lib document — embeds the PNG at + * the bottom of the LAST page, spanning the full page width × 105mm. + * + * Assumes the renderer reserved those bottom 105mm (BODY_MIN_Y guard in + * templates/default.ts). If the invoice content overflowed and opened a + * continuation page, that last page becomes the QR-Bill carrier. + */ +export async function attachQRBillToPdf( + doc: PDFDocument, + invoice: Invoice, + settings: InvoiceSettings +): Promise { + const { bytes, widthPt, heightPt } = await renderQRBillPng(invoice, settings); + const png = await doc.embedPng(bytes); + const pages = doc.getPages(); + const lastPage = pages[pages.length - 1]; + lastPage.drawImage(png, { + x: 0, + y: 0, + width: widthPt, + height: heightPt, + }); +} diff --git a/apps/mana/apps/web/src/lib/modules/invoices/pdf/renderer.ts b/apps/mana/apps/web/src/lib/modules/invoices/pdf/renderer.ts index b3e1d30a1..6d17769bc 100644 --- a/apps/mana/apps/web/src/lib/modules/invoices/pdf/renderer.ts +++ b/apps/mana/apps/web/src/lib/modules/invoices/pdf/renderer.ts @@ -46,6 +46,7 @@ import { SPACE, LINE_HEIGHT, } from './templates/default'; +import { attachQRBillToPdf, buildQRBillData, QRBillError } from './qr-bill'; // ─── Small geometry helpers ──────────────────────────────── @@ -480,13 +481,25 @@ function renderFooter(ctx: RenderContext, settings: InvoiceSettings): void { // ─── Public API ─────────────────────────────────────────── +export interface RenderOptions { + /** + * Attach the Swiss QR-Bill at the bottom of the last page when the + * invoice is eligible (CHF/EUR + valid IBAN + parseable addresses). + * Defaults to true. Set to false for draft previews where the user + * hasn't filled settings yet and you want a fast, non-rasterising + * render path. + */ + includeQRBill?: boolean; +} + /** * Render an invoice to PDF bytes. Call-site is responsible for wrapping the * output into a Blob, iframe URL, or File attachment. */ export async function renderInvoicePdf( invoice: Invoice, - settings: InvoiceSettings + settings: InvoiceSettings, + opts?: RenderOptions ): Promise { const doc = await PDFDocument.create(); doc.setTitle(`Rechnung ${invoice.number}`); @@ -531,6 +544,24 @@ export async function renderInvoicePdf( const firstCtx: RenderContext = { ...ctx, page: firstPage }; renderFooter(firstCtx, settings); + // Swiss QR-Bill overlay, bottom 105mm of the last page. Only attached + // if the invoice is eligible (CHF/EUR + valid IBAN + parseable + // addresses). Non-eligible invoices just get the plain PDF; the UI + // surfaces a warning separately via qrBillStatus(). + if (opts?.includeQRBill !== false) { + try { + await attachQRBillToPdf(doc, invoice, settings); + } catch (err) { + if (err instanceof QRBillError) { + // Silently omit — the DetailView calls qrBillStatus() to show the + // user why the Zahlteil is missing. We don't want PDF generation + // to fail just because the IBAN isn't set yet. + } else { + throw err; + } + } + } + return await doc.save(); } @@ -540,10 +571,34 @@ export async function renderInvoicePdf( */ export async function renderInvoicePdfBlob( invoice: Invoice, - settings: InvoiceSettings + settings: InvoiceSettings, + opts?: RenderOptions ): Promise { - const bytes = await renderInvoicePdf(invoice, settings); + const bytes = await renderInvoicePdf(invoice, settings, opts); // `.slice(0)` copies into a fresh ArrayBuffer-backed Uint8Array so the // Blob constructor types match (SharedArrayBuffer is not a BlobPart). return new Blob([bytes.slice(0) as BlobPart], { type: 'application/pdf' }); } + +// ─── QR-Bill status for UI ─────────────────────────────── + +/** + * Returns `{ ok: true }` if the invoice is QR-Bill-eligible, or + * `{ ok: false, message, reason }` describing what's missing so the UI + * can show a targeted hint. Does NOT render — pure validation, cheap to + * call on every form change. + */ +export function qrBillStatus( + invoice: Invoice, + settings: InvoiceSettings +): { ok: true } | { ok: false; message: string; reason: QRBillError['reason'] } { + try { + buildQRBillData(invoice, settings); + return { ok: true }; + } catch (err) { + if (err instanceof QRBillError) { + return { ok: false, message: err.message, reason: err.reason }; + } + throw err; + } +} diff --git a/apps/mana/apps/web/src/lib/modules/invoices/stores/invoices.svelte.ts b/apps/mana/apps/web/src/lib/modules/invoices/stores/invoices.svelte.ts index 12dbb2d43..96dfa408e 100644 --- a/apps/mana/apps/web/src/lib/modules/invoices/stores/invoices.svelte.ts +++ b/apps/mana/apps/web/src/lib/modules/invoices/stores/invoices.svelte.ts @@ -17,6 +17,7 @@ import { encryptRecord } from '$lib/data/crypto'; import { emitDomainEvent } from '$lib/data/events'; import { invoiceTable } from '../collections'; import { computeInvoiceTotals } from '../totals'; +import { generateSCORReference } from '../pdf/qr-bill'; import type { LocalInvoice, LocalInvoiceLine, @@ -63,6 +64,12 @@ export const invoicesStore = { const lines = input.lines ?? []; const totals = computeInvoiceTotals(lines); + // Pre-compute the SCOR reference so it's stable across re-renders of + // the PDF — the number is derived from invoice.number, which is + // already locked in at this point. Only used for CHF/EUR QR-Bills; + // other currencies ignore it. + const referenceNumber = generateSCORReference(number); + const newLocal: LocalInvoice = { id: crypto.randomUUID(), number, @@ -79,7 +86,7 @@ export const invoicesStore = { subject: input.subject ?? null, notes: input.notes ?? null, terms: input.terms ?? defaults.terms, - referenceNumber: null, + referenceNumber, pdfBlobKey: null, totals, }; diff --git a/apps/mana/apps/web/src/lib/modules/invoices/views/DetailView.svelte b/apps/mana/apps/web/src/lib/modules/invoices/views/DetailView.svelte index dd883a048..c186e27a6 100644 --- a/apps/mana/apps/web/src/lib/modules/invoices/views/DetailView.svelte +++ b/apps/mana/apps/web/src/lib/modules/invoices/views/DetailView.svelte @@ -7,7 +7,7 @@ import StatusBadge from '../components/StatusBadge.svelte'; import { invoicesStore } from '../stores/invoices.svelte'; import { invoiceSettingsStore } from '../stores/settings.svelte'; - import { renderInvoicePdfBlob } from '../pdf/renderer'; + import { renderInvoicePdfBlob, qrBillStatus } from '../pdf/renderer'; import { formatAmount } from '../queries'; import type { Invoice, InvoiceSettings } from '../types'; import { STATUS_LABELS } from '../constants'; @@ -29,12 +29,18 @@ let pdfUrl = $state(null); let pdfError = $state(null); let renderingPdf = $state(false); + let qrWarning = $state(null); async function renderPdf() { renderingPdf = true; pdfError = null; try { const settings: InvoiceSettings = await invoiceSettingsStore.get(); + // Compute QR-Bill eligibility first so we can show a warning even + // if the rest of the PDF renders fine. The renderer will silently + // omit the Zahlteil when not eligible. + const status = qrBillStatus(invoice, settings); + qrWarning = status.ok ? null : status.message; const blob = await renderInvoicePdfBlob(invoice, settings); if (pdfUrl) URL.revokeObjectURL(pdfUrl); pdfUrl = URL.createObjectURL(blob); @@ -170,6 +176,13 @@ Rendert … {/if} + {#if qrWarning} +
+ QR-Rechnung nicht eingefügt: + {qrWarning} + Einstellungen öffnen → +
+ {/if} {#if pdfError}
PDF-Fehler: {pdfError}
{:else if pdfUrl} @@ -355,6 +368,25 @@ gap: 0.5rem; } + .warning { + background: #fffbeb; + border: 1px solid #fde68a; + color: #92400e; + padding: 0.65rem 0.9rem; + border-radius: 0.4rem; + font-size: 0.85rem; + display: flex; + gap: 0.5rem; + flex-wrap: wrap; + align-items: center; + } + + .warning a { + color: #92400e; + font-weight: 500; + text-decoration: underline; + } + .preview-head { display: flex; justify-content: space-between;