/** * EPC (SEPA Credit Transfer) QR code payload, a.k.a. GiroCode. * Specification: EPC069-12 v3.0. * * The payload is a fixed-order line-delimited block of fields that any SEPA * banking app can scan to pre-fill a transfer. */ import QRCode from "qrcode"; export interface GiroCodeInput { beneficiaryName: string; iban: string; bic?: string; amount: number; currency?: string; /** Free-form remittance information (e.g. invoice number). Max 140 chars. */ remittance: string; } /** * Replaces CR/LF in a free-text EPC field with a single space and collapses * runs of whitespace, so the line-delimited payload can't be tampered with by * smuggling newlines into user-supplied text (beneficiary name / remittance). */ function sanitizeEpcField(value: string): string { return value.replace(/[\r\n]+/g, " ").replace(/\s+/g, " ").trim(); } export function buildGiroCodePayload(input: GiroCodeInput): string { const currency = input.currency || "EUR"; if (currency !== "EUR") { // EPC069-12 requires EUR; keep going but warn (most invoices are EUR). console.warn(`GiroCode: non-EUR currency ${currency} is non-standard.`); } // Beneficiary name max 70 chars per spec. Strip CR/LF first so injected // newlines can't forge/add EPC fields (the payload is line-delimited). const name = sanitizeEpcField(input.beneficiaryName).slice(0, 70); const iban = input.iban.replace(/\s+/g, "").toUpperCase(); const bic = (input.bic || "").replace(/\s+/g, "").toUpperCase(); const amount = input.amount.toFixed(2); // Unstructured remittance max 140 chars; strip CR/LF for the same reason. const remittance = sanitizeEpcField(input.remittance).slice(0, 140); // Field order is fixed; trailing fields can be empty. // Service tag SCT = SEPA Credit Transfer. const lines = [ "BCD", "002", // version "1", // character set 1 = UTF-8 "SCT", // SEPA Credit Transfer bic, // BIC (optional in v002) name, iban, `EUR${amount}`, "", // purpose (optional) "", // structured remittance remittance, // unstructured remittance ]; return lines.join("\n"); } export async function buildGiroCodeDataUrl( input: GiroCodeInput, ): Promise { const payload = buildGiroCodePayload(input); // EPC requires error correction level M. return QRCode.toDataURL(payload, { errorCorrectionLevel: "M", margin: 1, width: 256, }); } export async function buildGiroCodePngBuffer( input: GiroCodeInput, ): Promise { const payload = buildGiroCodePayload(input); return QRCode.toBuffer(payload, { errorCorrectionLevel: "M", margin: 1, width: 256, type: "png", }); }