feat(invoices): M5 Swiss QR-Bill — SCOR reference + PDF overlay

Adds swissqrbill integration so CHF/EUR invoices get the Zahlteil (payment
part) rendered in the bottom 105mm of the last page.

Integration path (pdf/qr-bill.ts)
- swissqrbill/pdf targets PDFKit, not pdf-lib; so we use swissqrbill/svg,
  rasterise the SVG to PNG in a browser canvas at ~300 DPI target, then
  embed the PNG via pdf-lib's embedPng
- Eligibility gate via QRBillError: validates currency (CHF/EUR), IBAN
  (swissqrbill's isIBANValid), parseable sender address, positive amount
- Address parser: heuristic for two-line Swiss/DE addresses
  (street + number on line 1, "{zip} {city}" on line 2). Fails loud —
  the renderer silently omits the Zahlteil and the UI surfaces a warning
- SCOR reference (ISO 11649) generated from invoice.number as payload,
  truncated to 21 chars, checksum via swissqrbill/utils. Persisted on
  invoice.referenceNumber at create time so it stays stable across edits
  and re-renders

Renderer wiring
- renderInvoicePdf(..., { includeQRBill?: boolean }) — defaults true
- QRBillError is caught and absorbed; other errors propagate
- qrBillStatus(invoice, settings) — cheap pure check, returns
  { ok: true } or { ok: false, message, reason } for UI hints

DetailView
- Warning banner above PDF preview when QR-Bill is not eligible, with
  a "Einstellungen öffnen →" deep link
- Preview iframe now shows the PNG-embedded Zahlteil on CHF/EUR
  invoices

Addressed §"Offene Fragen" from the plan
- QR-Bill-Scope: CHF + EUR per swissqrbill spec, not USD
- Address parsing: heuristic now, structured fields to be added in M7
  (tracked in renderer warning path — user sees exactly what's missing)

Plan: docs/plans/invoices-module.md §M5.
Next: M6 send flow (open mail compose with PDF attached).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-20 16:07:35 +02:00
parent 166d6c6ffb
commit 5af23d30b6
6 changed files with 398 additions and 5 deletions

View file

@ -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"

View file

@ -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';

View file

@ -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: "<zip> <city>" 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<Uint8Array> {
// 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<HTMLImageElement> {
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<void> {
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,
});
}

View file

@ -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<Uint8Array> {
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<Blob> {
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;
}
}

View file

@ -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,
};

View file

@ -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<string | null>(null);
let pdfError = $state<string | null>(null);
let renderingPdf = $state(false);
let qrWarning = $state<string | null>(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 @@
<span class="preview-status">Rendert …</span>
{/if}
</div>
{#if qrWarning}
<div class="warning">
<strong>QR-Rechnung nicht eingefügt:</strong>
{qrWarning}
<a href="/invoices/settings">Einstellungen öffnen →</a>
</div>
{/if}
{#if pdfError}
<div class="error">PDF-Fehler: {pdfError}</div>
{: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;