Created
June 9, 2026 13:21
-
-
Save ariunbayar/b1b7e333fb89239b4b48fd17b381889e to your computer and use it in GitHub Desktop.
EMVCo Merchant-Presented Mode (MPM) QR payload generator
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /** | |
| * EMVCo Merchant-Presented Mode (MPM) QR payload generator. | |
| * Spec: "EMV QR Code Specification for Payment Systems — Merchant-Presented Mode". | |
| * | |
| * Output is the data string you encode into a QR image (use any QR lib, | |
| * e.g. `qrcode`). The string is plain TLV + a trailing CRC. | |
| */ | |
| /** One TLV element: 2-digit ID + 2-digit length + value. */ | |
| function tlv(id: string, value: string): string { | |
| if (value.length > 99) throw new Error(`Value too long for field ${id} (${value.length})`); | |
| return id + String(value.length).padStart(2, "0") + value; | |
| } | |
| /** CRC-16/CCITT-FALSE: poly 0x1021, init 0xFFFF, no reflection, no final XOR. */ | |
| function crc16(data: string): string { | |
| let crc = 0xffff; | |
| for (let i = 0; i < data.length; i++) { | |
| crc ^= data.charCodeAt(i) << 8; | |
| for (let b = 0; b < 8; b++) { | |
| crc = crc & 0x8000 ? ((crc << 1) ^ 0x1021) & 0xffff : (crc << 1) & 0xffff; | |
| } | |
| } | |
| return crc.toString(16).toUpperCase().padStart(4, "0"); | |
| } | |
| export interface MpmParams { | |
| /** "static" => reusable code (no fixed amount), "dynamic" => one-time. Field 01. */ | |
| mode?: "static" | "dynamic"; | |
| /** | |
| * Merchant Account Information templates, IDs "02"–"51". | |
| * The inner value is scheme-specific (usually sub-tag "00" = GUID/AID, | |
| * plus network-defined sub-tags). Build it with `tlv` of the sub-fields. | |
| * e.g. { "29": tlv("00","D156...") + tlv("01","123456") } | |
| */ | |
| merchantAccounts: Record<string, string>; | |
| merchantCategoryCode: string; // 52, 4-digit MCC ("0000" if unknown) | |
| currency: string; // 53, ISO 4217 numeric (MNT = "496", USD = "840") | |
| amount?: string; // 54, e.g. "100.00" — omit for static codes | |
| countryCode: string; // 58, ISO 3166-1 alpha-2 (e.g. "MN") | |
| merchantName: string; // 59, max 25 chars | |
| merchantCity: string; // 60, max 15 chars | |
| postalCode?: string; // 61 | |
| additionalData?: { // 62 sub-template | |
| billNumber?: string; // 01 | |
| mobileNumber?: string; // 02 | |
| storeLabel?: string; // 03 | |
| loyaltyNumber?: string; // 04 | |
| referenceLabel?: string; // 05 | |
| customerLabel?: string; // 06 | |
| terminalLabel?: string; // 07 | |
| purpose?: string; // 08 | |
| }; | |
| } | |
| export function buildEmvMpm(p: MpmParams): string { | |
| const parts: string[] = []; | |
| parts.push(tlv("00", "01")); // Payload Format Indicator | |
| parts.push(tlv("01", p.mode === "dynamic" ? "12" : "11")); // Point of Initiation Method | |
| // Merchant Account Information templates (02–51), in ascending ID order. | |
| for (const id of Object.keys(p.merchantAccounts).sort()) { | |
| parts.push(tlv(id, p.merchantAccounts[id])); | |
| } | |
| parts.push(tlv("52", p.merchantCategoryCode)); | |
| parts.push(tlv("53", p.currency)); | |
| if (p.amount) parts.push(tlv("54", p.amount)); | |
| parts.push(tlv("58", p.countryCode)); | |
| parts.push(tlv("59", p.merchantName)); | |
| parts.push(tlv("60", p.merchantCity)); | |
| if (p.postalCode) parts.push(tlv("61", p.postalCode)); | |
| if (p.additionalData) { | |
| const a = p.additionalData; | |
| const sub = | |
| (a.billNumber ? tlv("01", a.billNumber) : "") + | |
| (a.mobileNumber ? tlv("02", a.mobileNumber) : "") + | |
| (a.storeLabel ? tlv("03", a.storeLabel) : "") + | |
| (a.loyaltyNumber ? tlv("04", a.loyaltyNumber) : "") + | |
| (a.referenceLabel ? tlv("05", a.referenceLabel) : "") + | |
| (a.customerLabel ? tlv("06", a.customerLabel) : "") + | |
| (a.terminalLabel ? tlv("07", a.terminalLabel) : "") + | |
| (a.purpose ? tlv("08", a.purpose) : ""); | |
| if (sub) parts.push(tlv("62", sub)); | |
| } | |
| // CRC (63) is computed over the whole string INCLUDING the "6304" prefix. | |
| const payload = parts.join("") + "6304"; | |
| return payload + crc16(payload); | |
| } | |
| /* ---- Example (Mongolia, dynamic, MNT 5000) ---- | |
| const data = buildEmvMpm({ | |
| mode: "dynamic", | |
| merchantAccounts: { "29": tlv("00", "A0000007681010") + tlv("01", "MERCHANT-ID") }, | |
| merchantCategoryCode: "0000", | |
| currency: "496", // MNT | |
| amount: "5000", | |
| countryCode: "MN", | |
| merchantName: "MY SHOP", | |
| merchantCity: "ULAANBAATAR", | |
| additionalData: { billNumber: "INV-2026-001", referenceLabel: "ORDER42" }, | |
| }); | |
| // Render with the `qrcode` package: | |
| // import QRCode from "qrcode"; | |
| // await QRCode.toDataURL(data, { errorCorrectionLevel: "M" }); | |
| */ |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment