87 lines
2.7 KiB
TypeScript
87 lines
2.7 KiB
TypeScript
/**
|
|
* 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<string> {
|
|
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<Buffer> {
|
|
const payload = buildGiroCodePayload(input);
|
|
return QRCode.toBuffer(payload, {
|
|
errorCorrectionLevel: "M",
|
|
margin: 1,
|
|
width: 256,
|
|
type: "png",
|
|
});
|
|
}
|