promptpay-qrcode 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 devhyphenplus
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,217 @@
1
+ # promptpay-qrcode
2
+
3
+ Zero-dependency Node.js generator for **PromptPay QR payload strings** (the
4
+ EMVCo / Thai QR text you encode into a QR image). Three generators plus a
5
+ decoder:
6
+
7
+ 1. **Standard PromptPay** (Tag 29) — mobile number, national/tax ID, or
8
+ e-wallet ID, inspired by [saladpuk/PromptPay](https://github.com/saladpuk/PromptPay).
9
+ 2. **Bill Payment** (Tag 30) — biller ID + reference(s). The merchant
10
+ "bill payment" QR family, the same shape SCB's **แม่มณี (Mae Manee)** and
11
+ similar merchant QRs use (the payer's app shows the shop name).
12
+ 3. **KShop** — the KShop-format merchant QR (Tag 30 + Tag 31). You supply the
13
+ account-identifying fields; the library ships no merchant data.
14
+
15
+ Plus `decode()` to read any PromptPay/Thai QR back into structured fields.
16
+
17
+ See [`docs/promptpay-qr-structure.md`](docs/promptpay-qr-structure.md) for a
18
+ deep dive on the EMVCo / Thai QR tag structure.
19
+
20
+ Output is the **payload string only** — pass it to any QR library, or use the
21
+ optional built-in image helpers (see [Rendering to an image](#rendering-to-an-image-optional)).
22
+
23
+ ## Install
24
+
25
+ ```
26
+ npm install promptpay-qrcode
27
+ ```
28
+
29
+ The core has **no dependencies**. Image rendering uses the optional `qrcode`
30
+ peer dependency (install it only if you need images).
31
+
32
+ ```js
33
+ const { generatePromptPay, generateBillPayment, generateKShopQR } = require('promptpay-qrcode');
34
+ ```
35
+
36
+ ## Standard PromptPay (Tag 29)
37
+
38
+ ```js
39
+ generatePromptPay({ mobile: '0812345678' }); // static (no amount)
40
+ generatePromptPay({ mobile: '0812345678', amount: 100 }); // dynamic, 100.00 THB
41
+ generatePromptPay({ nationalId: '1234567890123' });
42
+ generatePromptPay({ ewallet: '123456789012345', amount: 50.25 });
43
+ ```
44
+
45
+ Provide **exactly one** of `mobile`, `nationalId`, `ewallet`. By default the QR
46
+ is dynamic (POI `12`) when `amount` is present and static (POI `11`) otherwise.
47
+ Pass `dynamic: true | false` to force it either way:
48
+
49
+ ```js
50
+ generatePromptPay({ mobile: '0812345678', amount: 100, dynamic: false }); // static w/ amount
51
+ generatePromptPay({ mobile: '0812345678', dynamic: true }); // dynamic w/o amount
52
+ ```
53
+
54
+ Mobile numbers are normalized to the 13-char proxy form
55
+ (`0812345678` → `0066812345678`).
56
+
57
+ ## Bill Payment (Tag 30) — Mae Manee / SCB merchant style
58
+
59
+ ```js
60
+ generateBillPayment({
61
+ billerId: '000000000000000', // bank-issued Biller ID (usually 15 digits)
62
+ ref1: 'INV20240001', // Reference 1 (mandatory)
63
+ ref2: 'BRANCH01', // Reference 2 (optional)
64
+ amount: 50, // optional; present => dynamic QR (POI 12) by default
65
+ dynamic: true, // optional; force POI (true='12', false='11')
66
+ merchantName: 'MY SHOP', // optional (tag 59)
67
+ merchantCity: 'BANGKOK', // optional (tag 60)
68
+ });
69
+ ```
70
+
71
+ The Biller ID and references are issued/defined by your bank (for SCB, via the
72
+ Mae Manee / Business QR onboarding). `ref1` is required; `ref2` is optional.
73
+
74
+ ## KShop
75
+
76
+ The KShop-format merchant QR (Tag 30 + Tag 31). This library ships **no
77
+ merchant data** — you must supply the account-identifying fields, which your
78
+ bank issues. `billerId`, `merchantRef`, `merchantName` and `merchantCity` are
79
+ required; `generateKShopQR` throws if any are missing.
80
+
81
+ ```js
82
+ const config = {
83
+ billerId: '000000000000000', // bank-issued Biller ID (required)
84
+ merchantRef: 'KB000000000000', // bank merchant reference (required)
85
+ merchantName: 'MY SHOP', // tag 59 (required)
86
+ merchantCity: 'BANGKOK', // tag 60 (required)
87
+ // Optional — emitted only when provided:
88
+ // visaTemplate, mastercardTemplate, unionpayTemplate, cardScheme,
89
+ // mcc, additionalData, dynamic (default true), innovationSubId (default '004')
90
+ };
91
+
92
+ generateKShopQR(100, 'ORDER0000000001', config); // dynamic (POI '12')
93
+ generateKShopQR(100, 'ORDER0000000001', { ...config, dynamic: false }); // static (POI '11')
94
+ ```
95
+
96
+ `amount` (1st arg) and `reference` (2nd arg — the per-order ref placed in tag
97
+ 30/03 and 31/04) vary per call. Structural defaults (`dynamic: true`,
98
+ `currency: '764'`, `countryCode: 'TH'`, `innovationSubId: '004'`) live in
99
+ `KSHOP_DEFAULTS`; the required fields are listed in `REQUIRED_FIELDS`.
100
+
101
+ If you already have a master QR for an account, you can decode it and reuse its
102
+ fields — see [`kshopParamsFrom`](#decoding-a-qr-read-a-master-qr-back) below.
103
+
104
+ ## Decoding a QR (read a master QR back)
105
+
106
+ `decode(payload)` parses any EMVCo / PromptPay / Thai QR string into structured
107
+ fields and validates the CRC:
108
+
109
+ ```js
110
+ const { decode } = require('promptpay-qrcode');
111
+
112
+ const d = decode(masterQrString);
113
+ d.amount; // 100 (null if none)
114
+ d.merchantName; // 'MY SHOP'
115
+ d.poiMethod; // '12' (d.static === false)
116
+ d.crc.valid; // true -> the QR's checksum is correct
117
+ d.fields['30']; // { '00': 'A000000677010112', '01': '000000000000000', ... }
118
+ d.tags; // ordered [{ id, length, value }] of the top level
119
+ ```
120
+
121
+ It throws on a malformed payload (a declared length running past the string).
122
+
123
+ ### Cloning another KShop account from its master QR
124
+
125
+ `kshopParamsFrom(qr)` pulls out exactly the account-identifying fields you'd
126
+ pass to `generateKShopQR` — so you can mint new QRs for an existing account:
127
+
128
+ ```js
129
+ const { kshopParamsFrom, generateKShopQR } = require('promptpay-qrcode');
130
+
131
+ const params = kshopParamsFrom(masterQr);
132
+ // params = { billerId, merchantRef, merchantName, merchantCity,
133
+ // additionalData, visaTemplate, mastercardTemplate,
134
+ // unionpayTemplate, cardScheme, innovationSubId, mcc,
135
+ // currency, countryCode, dynamic } (only those present)
136
+
137
+ // Generate a fresh QR for that account with your own amount + order ref:
138
+ const qr = generateKShopQR(250.5, 'ORDER123', params);
139
+ ```
140
+
141
+ Per-transaction values (`amount`, and the order reference in tag 30/03 & 31/04)
142
+ are **not** included in `params` — you supply those per call. Round-trip is
143
+ exact: `generateKShopQR(amount, ref, kshopParamsFrom(qr))` reproduces the
144
+ original master QR byte-for-byte when given the same amount and ref.
145
+
146
+ ## Rendering to an image (optional)
147
+
148
+ The core is zero-dependency. To turn a payload into an actual QR image, install
149
+ the optional [`qrcode`](https://www.npmjs.com/package/qrcode) package:
150
+
151
+ ```
152
+ npm install qrcode
153
+ ```
154
+
155
+ Then use the built-in helpers — they lazy-load `qrcode` and reject with a clear
156
+ message if it isn't installed:
157
+
158
+ ```js
159
+ const { generatePromptPay, toFile, toDataURL, toSVG, toBuffer, toTerminal } = require('promptpay-qrcode');
160
+
161
+ const payload = generatePromptPay({ mobile: '0812345678', amount: 100 });
162
+
163
+ await toFile('qr.png', payload, { width: 300, margin: 2 }); // PNG file
164
+ const url = await toDataURL(payload); // data:image/png;base64,...
165
+ const svg = await toSVG(payload); // SVG markup string
166
+ const buf = await toBuffer(payload); // PNG Buffer
167
+ console.log(await toTerminal(payload)); // scannable QR in the terminal
168
+ ```
169
+
170
+ The second `options` argument is passed straight through to `qrcode`
171
+ (`width`, `margin`, `color`, `errorCorrectionLevel`, …). See `example-image.js`
172
+ (`npm run example:image`) for a full demo.
173
+
174
+ ## API
175
+
176
+ | Function | Returns |
177
+ | --- | --- |
178
+ | `generatePromptPay({ mobile \| nationalId \| ewallet, amount?, dynamic? })` | payload string |
179
+ | `generateBillPayment({ billerId, ref1, ref2?, amount?, dynamic?, merchantName?, merchantCity? })` | payload string |
180
+ | `generateKShopQR(amount, reference, config)` | payload string |
181
+ | `KSHOP_DEFAULTS` / `REQUIRED_FIELDS` | KShop structural defaults / required field list |
182
+ | `decode(payload)` | structured decode + CRC validation |
183
+ | `parseTLV(payload)` | low-level ordered `[{ id, length, value }]` |
184
+ | `kshopParamsFrom(qr)` | account params to clone a KShop master QR |
185
+ | `crc16Ccitt(str)` / `crc16Hex(str)` | CRC16-CCITT (number / 4-char hex) |
186
+ | `formatMobile(str)` | 13-char PromptPay mobile proxy |
187
+ | `toFile(path, payload, opts?)` | `Promise<void>` — write PNG file *(needs `qrcode`)* |
188
+ | `toDataURL(payload, opts?)` | `Promise<string>` — data URL *(needs `qrcode`)* |
189
+ | `toBuffer(payload, opts?)` | `Promise<Buffer>` — PNG buffer *(needs `qrcode`)* |
190
+ | `toSVG(payload, opts?)` | `Promise<string>` — SVG markup *(needs `qrcode`)* |
191
+ | `toTerminal(payload, opts?)` | `Promise<string>` — terminal QR *(needs `qrcode`)* |
192
+
193
+ ## Files
194
+
195
+ - `crc.js` — CRC16-CCITT (0xFFFF init, 0x1021 poly).
196
+ - `promptpay.js` — standard PromptPay (Tag 29) + bill payment (Tag 30) generators.
197
+ - `kshop.js` — KShop generator (configurable, no bundled merchant data).
198
+ - `decode.js` — decode/parse a payload + `kshopParamsFrom` extractor.
199
+ - `image.js` — optional image helpers (lazy-load `qrcode`).
200
+ - `index.js` — public entry point.
201
+ - `test.js` — `npm test`. `example.js` — `npm run example`.
202
+ `example-image.js` — `npm run example:image` (needs `qrcode`).
203
+ - `docs/promptpay-qr-structure.md` — EMVCo / Thai QR tag-structure reference.
204
+
205
+ ## Tests
206
+
207
+ ```
208
+ npm test
209
+ ```
210
+
211
+ Verifies CRC against the `123456789 → 0x29B1` vector, TLV nesting parity, the
212
+ Tag 29 / Tag 30 / KShop structures, decode + CRC validation, and the
213
+ `kshopParamsFrom` → `generateKShopQR` round-trip.
214
+
215
+ ## License
216
+
217
+ MIT — see [LICENSE](LICENSE).
package/crc.js ADDED
@@ -0,0 +1,37 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * CRC16-CCITT (False) checksum — the standard used for Thai QR codes.
5
+ *
6
+ * Initial value 0xFFFF, polynomial 0x1021, no final XOR.
7
+ * Port of the PHP `crc16_ccitt` implementation.
8
+ *
9
+ * @param {string} data Input string (treated as Latin-1 / single-byte chars).
10
+ * @returns {number} The CRC16 checksum (0..0xFFFF).
11
+ */
12
+ function crc16Ccitt(data) {
13
+ let crc = 0xffff;
14
+ for (let i = 0; i < data.length; i++) {
15
+ crc ^= data.charCodeAt(i) << 8;
16
+ for (let j = 0; j < 8; j++) {
17
+ if (crc & 0x8000) {
18
+ crc = (crc << 1) ^ 0x1021;
19
+ } else {
20
+ crc = crc << 1;
21
+ }
22
+ crc &= 0xffff; // keep it 16-bit (JS uses 32-bit bitwise ops)
23
+ }
24
+ }
25
+ return crc & 0xffff;
26
+ }
27
+
28
+ /**
29
+ * CRC as the uppercase 4-char hex string appended to QR payloads.
30
+ * @param {string} data
31
+ * @returns {string}
32
+ */
33
+ function crc16Hex(data) {
34
+ return crc16Ccitt(data).toString(16).toUpperCase().padStart(4, '0');
35
+ }
36
+
37
+ module.exports = { crc16Ccitt, crc16Hex };
package/decode.js ADDED
@@ -0,0 +1,151 @@
1
+ 'use strict';
2
+
3
+ const { crc16Hex } = require('./crc');
4
+
5
+ // Templates whose value is itself a string of nested TLV sub-fields.
6
+ const NESTED_TAGS = new Set(['26', '27', '28', '29', '30', '31', '32', '33', '34', '35', '36', '37', '38', '39', '40', '41', '42', '43', '44', '45', '46', '47', '48', '49', '50', '51', '62', '64', '80', '81', '82', '83', '84', '85', '86', '87', '88', '89', '90', '91', '92', '93', '94', '95', '96', '97', '98', '99']);
7
+
8
+ /**
9
+ * Parse a TLV string into an array of { id, length, value } in order.
10
+ * Throws if a declared length runs past the end of the string (malformed QR).
11
+ * @param {string} payload
12
+ * @returns {{id:string,length:number,value:string}[]}
13
+ */
14
+ function parseTLV(payload) {
15
+ const out = [];
16
+ let i = 0;
17
+ while (i < payload.length) {
18
+ if (i + 4 > payload.length) {
19
+ throw new Error(`Malformed payload: truncated tag header at offset ${i}`);
20
+ }
21
+ const id = payload.slice(i, i + 2);
22
+ const length = parseInt(payload.slice(i + 2, i + 4), 10);
23
+ if (Number.isNaN(length)) {
24
+ throw new Error(`Malformed payload: bad length for tag ${id} at offset ${i}`);
25
+ }
26
+ const start = i + 4;
27
+ const end = start + length;
28
+ if (end > payload.length) {
29
+ throw new Error(`Malformed payload: tag ${id} length ${length} exceeds payload at offset ${i}`);
30
+ }
31
+ out.push({ id, length, value: payload.slice(start, end) });
32
+ i = end;
33
+ }
34
+ return out;
35
+ }
36
+
37
+ /**
38
+ * Recursively turn a TLV list into a plain object keyed by tag id. Nested
39
+ * templates become nested objects. Where a tag id repeats (rare), values are
40
+ * collected into an array.
41
+ * @param {{id:string,value:string}[]} tags
42
+ * @param {boolean} nested Whether this level may contain sub-templates.
43
+ * @returns {Object}
44
+ */
45
+ function tagsToObject(tags, nested) {
46
+ const obj = {};
47
+ for (const { id, value } of tags) {
48
+ let v = value;
49
+ if (nested && NESTED_TAGS.has(id)) {
50
+ v = tagsToObject(parseTLV(value), true);
51
+ }
52
+ if (id in obj) {
53
+ obj[id] = Array.isArray(obj[id]) ? obj[id].concat([v]) : [obj[id], v];
54
+ } else {
55
+ obj[id] = v;
56
+ }
57
+ }
58
+ return obj;
59
+ }
60
+
61
+ /**
62
+ * Decode an EMVCo / PromptPay / Thai QR payload string.
63
+ *
64
+ * @param {string} payload The raw QR text.
65
+ * @returns {object} Structured decode:
66
+ * - tags: ordered [{id,length,value}] of the top level
67
+ * - fields: nested object keyed by tag id (templates expanded)
68
+ * - crc: { value, expected, valid }
69
+ * - amount, currency, countryCode, merchantName, merchantCity, poiMethod, static
70
+ */
71
+ function decode(payload) {
72
+ if (typeof payload !== 'string' || payload.length < 8) {
73
+ throw new Error('decode() expects a QR payload string');
74
+ }
75
+
76
+ const tags = parseTLV(payload);
77
+ const fields = tagsToObject(tags, true);
78
+
79
+ // CRC: recompute over everything up to (but not including) the 4 CRC chars.
80
+ const crcValue = fields['63'];
81
+ let crc = null;
82
+ if (typeof crcValue === 'string' && payload.endsWith(crcValue)) {
83
+ const body = payload.slice(0, payload.length - crcValue.length);
84
+ const expected = crc16Hex(body);
85
+ crc = { value: crcValue, expected, valid: expected === crcValue.toUpperCase() };
86
+ }
87
+
88
+ const poi = fields['01'];
89
+ return {
90
+ tags,
91
+ fields,
92
+ crc,
93
+ poiMethod: poi || null,
94
+ static: poi === '11',
95
+ amount: fields['54'] != null ? Number(fields['54']) : null,
96
+ currency: fields['53'] || null,
97
+ countryCode: fields['58'] || null,
98
+ merchantName: fields['59'] || null,
99
+ merchantCity: fields['60'] || null,
100
+ };
101
+ }
102
+
103
+ /**
104
+ * Given a KSHOP-style master QR, extract the account-identifying fields needed
105
+ * to regenerate QRs for that account via generateKShopQR(amount, ref, params).
106
+ *
107
+ * Returns ONLY the mandatory / account-specific fields that are present, so the
108
+ * result can be spread straight into the options argument. Per-transaction
109
+ * values (amount, the order reference in 30/03 & 31/04) are intentionally
110
+ * omitted — those are supplied per call.
111
+ *
112
+ * @param {string|object} qr A payload string or a prior decode() result.
113
+ * @returns {object} Options suitable for generateKShopQR's 3rd argument.
114
+ */
115
+ function kshopParamsFrom(qr) {
116
+ const d = typeof qr === 'string' ? decode(qr) : qr;
117
+ const f = d.fields;
118
+ const t30 = f['30'] || {};
119
+ const t31 = f['31'] || {};
120
+ const t51 = f['51'] || {};
121
+
122
+ const params = {};
123
+ if (f['02'] != null) params.visaTemplate = f['02'];
124
+ if (f['04'] != null) params.mastercardTemplate = f['04'];
125
+ if (f['15'] != null) params.unionpayTemplate = f['15'];
126
+ if (t30['01'] != null) params.billerId = t30['01'];
127
+ // Merchant ref appears in 30/02 (and usually mirrored in 31/02).
128
+ if (t30['02'] != null) params.merchantRef = t30['02'];
129
+ if (t31['01'] != null) params.innovationSubId = t31['01'];
130
+ if (Object.keys(t51).length) params.cardScheme = t51;
131
+ if (f['52'] != null) params.mcc = f['52'];
132
+ if (f['53'] != null) params.currency = f['53'];
133
+ if (f['58'] != null) params.countryCode = f['58'];
134
+ if (f['59'] != null) params.merchantName = f['59'];
135
+ if (f['60'] != null) params.merchantCity = f['60'];
136
+ if (f['62'] != null) {
137
+ // tag 62 is stored as an expanded object; re-flatten to its raw string.
138
+ params.additionalData = flattenTag62(d.tags);
139
+ }
140
+ params.dynamic = d.poiMethod === '12';
141
+
142
+ return params;
143
+ }
144
+
145
+ /** Pull tag 62's raw inner string from the ordered top-level tag list. */
146
+ function flattenTag62(tags) {
147
+ const tag = tags.find((t) => t.id === '62');
148
+ return tag ? tag.value : undefined;
149
+ }
150
+
151
+ module.exports = { decode, parseTLV, kshopParamsFrom };
@@ -0,0 +1,231 @@
1
+ # PromptPay / Thai QR Code — Structure Deep Dive
2
+
3
+ PromptPay QR is a profile of the **EMVCo Merchant-Presented Mode (MPM)** QR
4
+ standard, localized by the **Bank of Thailand (BOT) Thai QR Payment Standard**.
5
+ This document is the reference behind this library's generators.
6
+
7
+ Sources: EMVCo *EMV QR Code Specification for Payment Systems — MPM* (v1.1),
8
+ BOT *Thai QR Code Standard* (FPG circular), `dtinth/promptpay-qr`,
9
+ `saladpuk/PromptPay`, and a full decode of the in-repo KSHOP PHP example.
10
+
11
+ ---
12
+
13
+ ## 1. Encoding: TLV (Tag-Length-Value)
14
+
15
+ The whole payload is a flat string of TLV fields:
16
+
17
+ ```
18
+ ID (2 digits) | LEN (2 digits, decimal, zero-padded) | VALUE (LEN chars)
19
+ ```
20
+
21
+ - **ID**: 2 numeric digits (`00`–`99`).
22
+ - **LEN**: 2 **decimal** digits, zero-padded (`05`, `16`). NOT hex. Because it's
23
+ 2 digits, a single value maxes at 99 chars.
24
+ - **VALUE**: exactly LEN characters (ASCII).
25
+
26
+ Some IDs are **templates** — their VALUE is itself a string of nested TLV
27
+ sub-fields using the same rules. PromptPay uses templates at `29`, `30`, `31`,
28
+ `51`, and `62`.
29
+
30
+ Example: `000201` = ID `00`, LEN `02`, VALUE `01`.
31
+
32
+ ---
33
+
34
+ ## 2. Top-level ID allocation (EMVCo)
35
+
36
+ | ID | Meaning | PromptPay usage |
37
+ |----|---------|-----------------|
38
+ | `00` | Payload Format Indicator | always `01` |
39
+ | `01` | Point of Initiation Method | `11` static / `12` dynamic |
40
+ | `02`–`03` | Visa account templates | merchant card schemes (optional) |
41
+ | `04`–`05` | Mastercard account templates | merchant card schemes (optional) |
42
+ | `06`–`08` | EMVCo reserved | |
43
+ | `09`–`10` | Discover | |
44
+ | `11`–`12` | Amex | |
45
+ | `13`–`14` | JCB | |
46
+ | `15`–`16` | UnionPay | merchant card schemes (optional) |
47
+ | `17`–`25` | EMVCo reserved (payment networks) | |
48
+ | `26`–`51` | **Merchant Account Information templates** | **`29` P2P, `30` bill-pay, `31` e-wallet innovation** |
49
+ | `52` | Merchant Category Code (MCC, ISO 18245) | e.g. `5732` electronics |
50
+ | `53` | Transaction Currency (ISO 4217 numeric) | `764` = THB |
51
+ | `54` | Transaction Amount | e.g. `100.00` (omit for static) |
52
+ | `55` | Tip / convenience indicator | rarely used |
53
+ | `56`–`57` | Convenience fee fixed / percentage | rarely used |
54
+ | `58` | Country Code (ISO 3166-1 alpha-2) | `TH` |
55
+ | `59` | Merchant Name | e.g. `MY SHOP` |
56
+ | `60` | Merchant City | e.g. `BANGKOK` |
57
+ | `61` | Postal Code | optional |
58
+ | `62` | Additional Data Field Template | refs / labels (see §6) |
59
+ | `63` | **CRC** | 4 hex chars (see §7) |
60
+ | `64` | Merchant Info — Language Template | optional |
61
+ | `65`–`79` | RFU for EMVCo | |
62
+ | `80`–`99` | Unreserved templates | |
63
+
64
+ `63` (CRC) is always the **last** field.
65
+
66
+ ---
67
+
68
+ ## 3. PromptPay Application IDs (AIDs)
69
+
70
+ PromptPay merchant-account templates begin with sub-tag `00` = the AID.
71
+
72
+ | AID | Purpose | Template |
73
+ |-----|---------|----------|
74
+ | `A000000677010111` | Credit Transfer (mobile / national ID / e-wallet) | tag `29` |
75
+ | `A000000677010112` | Bill Payment (domestic biller) | tag `30` |
76
+ | `A000000677010113` | Payment / e-wallet Innovation | tag `31` |
77
+ | `A000000677010114` | Customer-Presented | (customer-side QR) |
78
+
79
+ ---
80
+
81
+ ## 4. Tag 29 — Credit Transfer (the common P2P PromptPay QR)
82
+
83
+ ```
84
+ 29 LL
85
+ 00 16 A000000677010111 ← AID
86
+ 01 13 0066812345678 ← mobile (one of 01/02/03)
87
+ 02 13 1234567890123 ← national ID / tax ID
88
+ 03 15 123456789012345 ← e-wallet ID
89
+ ```
90
+
91
+ Provide **exactly one** of sub-tags `01`/`02`/`03`.
92
+
93
+ **Mobile normalization** (sub-tag `01`, always 13 chars):
94
+ 1. Strip non-digits.
95
+ 2. Drop a single leading `0`.
96
+ 3. Prepend `66` (Thailand).
97
+ 4. Left-pad with `0` to 13 chars.
98
+
99
+ `0812345678 → 812345678 → 66812345678 → 0066812345678`
100
+
101
+ National ID / tax ID → sub-tag `02`, 13 digits as-is.
102
+ E-wallet ID → sub-tag `03`, 15 digits as-is.
103
+
104
+ ---
105
+
106
+ ## 5. Tag 30 — Bill Payment (biller) + Reference 1 / Reference 2
107
+
108
+ ```
109
+ 30 LL
110
+ 00 16 A000000677010112 ← AID (domestic)
111
+ 01 LL <BillerID> ← Biller ID = 13-digit Tax ID + 2-digit suffix (15)
112
+ 02 LL <Reference1> ← mandatory, biller-defined
113
+ 03 LL <Reference2> ← optional, biller-defined
114
+ ```
115
+
116
+ - **Biller ID**: assigned by the bank; the merchant's 13-digit tax ID plus a
117
+ 2-digit suffix (commonly `00`), so 15 chars.
118
+ - **Reference 1 (`02`)**: mandatory. The biller decides its meaning — usually the
119
+ customer/account/invoice identifier. Alphanumeric.
120
+ - **Reference 2 (`03`)**: optional secondary reference (e.g. branch, terminal,
121
+ order id). Alphanumeric. Omit the whole sub-tag if unused.
122
+
123
+ > The KSHOP PHP puts the bank-issued merchant ref in `02` and the per-order
124
+ > KShop reference in `03`. That's a valid biller-specific choice — not a fixed
125
+ > rule of the standard.
126
+
127
+ ---
128
+
129
+ ## 6. Tag 62 — Additional Data Field Template
130
+
131
+ Nested template carrying references/labels. Common sub-tags:
132
+
133
+ | Sub | Meaning |
134
+ |-----|---------|
135
+ | `01` | Bill Number |
136
+ | `02` | Mobile Number |
137
+ | `03` | Store Label |
138
+ | `04` | Loyalty Number |
139
+ | `05` | Reference Label |
140
+ | `06` | Customer Label |
141
+ | `07` | Terminal Label |
142
+ | `08` | Purpose of Transaction |
143
+ | `09` | Additional Consumer Data Request |
144
+
145
+ In the KSHOP example, `62` decodes as `05` (reference label) + `07` (terminal
146
+ label).
147
+
148
+ ---
149
+
150
+ ## 7. Tag 63 — CRC16
151
+
152
+ Algorithm: **CRC-16/CCITT-FALSE**.
153
+
154
+ - Width 16, polynomial `0x1021`, init `0xFFFF`.
155
+ - **No** input/output reflection, **no** final XOR.
156
+ - Computed over the **entire preceding payload INCLUDING the literal `6304`**
157
+ (the CRC tag id + length), then the 4-char uppercase hex result is appended.
158
+
159
+ Test vector: `CRC("123456789") = 0x29B1`.
160
+
161
+ ```
162
+ payload += "6304" + crc16(payload + "6304").toString(16).toUpperCase().padStart(4,"0")
163
+ ```
164
+
165
+ ---
166
+
167
+ ## 8. Static vs Dynamic (tag 01)
168
+
169
+ | Value | Meaning |
170
+ |-------|---------|
171
+ | `11` | **Static** — reusable; amount usually omitted, payer types it. |
172
+ | `12` | **Dynamic** — single use; amount (`54`) present. |
173
+
174
+ Convention: include `54` ⇒ use `12`; omit `54` ⇒ use `11`. Scanners still read
175
+ an amount even if `01=11` (as the KSHOP payload does), but `12` is the correct
176
+ marker for a one-time amount.
177
+
178
+ ---
179
+
180
+ ## 9. Worked example — KSHOP payload layout
181
+
182
+ Field layout of a KSHOP-format merchant QR. Values shown are **placeholders**;
183
+ the account-identifying fields (biller ID, merchant ref, card templates,
184
+ additional data) are supplied by the caller and issued by the bank.
185
+
186
+ ```
187
+ 00 02 01 Payload format = 01
188
+ 01 02 12 Point of init = dynamic (carries amount)
189
+ 02 .. <visa template> Visa merchant template (optional)
190
+ 04 .. <mastercard template> Mastercard merchant template (optional)
191
+ 15 .. <unionpay template> UnionPay merchant template (optional)
192
+ 30 .. PromptPay BILL PAYMENT
193
+ 00 16 A000000677010112 AID (domestic)
194
+ 01 .. <biller id> Biller ID
195
+ 02 .. <merchant ref> Reference 1 (merchant ref)
196
+ 03 .. <order ref> Reference 2 (per-order ref)
197
+ 31 .. PromptPay PAYMENT INNOVATION
198
+ 00 16 A000000677010113 AID
199
+ 01 03 004 (network/sub-id)
200
+ 02 .. <merchant ref> merchant ref
201
+ 04 .. <order ref> order ref
202
+ 51 .. Card-scheme merchant template (optional)
203
+ 00 14 A0000000041010 RID (Mastercard, public)
204
+ 01 .. <bin> BIN/issuer
205
+ 02 .. <account ref> account ref
206
+ 52 .. <mcc> MCC (optional)
207
+ 53 03 764 Currency = THB
208
+ 54 06 100.00 Amount
209
+ 58 02 TH Country
210
+ 59 .. <merchant name> Merchant name
211
+ 60 .. <merchant city> Merchant city
212
+ 62 .. Additional data (optional)
213
+ 05 .. <reference label> Reference label
214
+ 07 .. <terminal label> Terminal label
215
+ 63 04 <crc> CRC16
216
+ ```
217
+
218
+ This library ships no merchant data: `generateKShopQR` requires the
219
+ account-identifying fields and emits the optional card/MCC/additional-data tags
220
+ only when they are provided.
221
+
222
+ ---
223
+
224
+ ## 10. Validation checklist for a generated payload
225
+
226
+ 1. Starts with `000201`.
227
+ 2. `01` is `11` or `12`, consistent with presence of `54`.
228
+ 3. Exactly one merchant template identifies the payee (`29` *or* `30`/`31`).
229
+ 4. `53` = `764`, `58` = `TH`.
230
+ 5. Every TLV length matches its value; payload re-parses with no leftover bytes.
231
+ 6. Ends with `6304` + valid CRC16 of everything before the 4 CRC chars.
package/image.js ADDED
@@ -0,0 +1,81 @@
1
+ 'use strict';
2
+
3
+ // Optional QR-image helpers. These wrap the `qrcode` npm package, which is a
4
+ // PEER dependency: the core payload generators stay zero-dependency, and
5
+ // `qrcode` is only required the moment one of these functions is called.
6
+ //
7
+ // npm install qrcode
8
+ //
9
+ // All functions take a payload string (from generatePromptPay / generateKShopQR
10
+ // / generateBillPayment) plus an optional `options` object forwarded to the
11
+ // underlying `qrcode` library (e.g. { width, margin, color, errorCorrectionLevel }).
12
+
13
+ /**
14
+ * Lazily load the `qrcode` package, throwing a helpful error if it's missing.
15
+ * @returns {import('qrcode')}
16
+ */
17
+ function loadQrcode() {
18
+ try {
19
+ return require('qrcode');
20
+ } catch (err) {
21
+ throw new Error(
22
+ "The 'qrcode' package is required for image generation. Install it with: npm install qrcode"
23
+ );
24
+ }
25
+ }
26
+
27
+ // Each wrapper is `async` so a missing-`qrcode` error surfaces as a promise
28
+ // rejection (catchable with .catch / try-await), not a synchronous throw.
29
+
30
+ /**
31
+ * Render a payload to a PNG file on disk.
32
+ * @param {string} filePath Destination path (e.g. './qr.png').
33
+ * @param {string} payload QR payload string.
34
+ * @param {object} [options] Options forwarded to qrcode.toFile.
35
+ * @returns {Promise<void>}
36
+ */
37
+ async function toFile(filePath, payload, options = {}) {
38
+ return loadQrcode().toFile(filePath, payload, options);
39
+ }
40
+
41
+ /**
42
+ * Render a payload to a data URL (PNG by default), e.g. for an <img src>.
43
+ * @param {string} payload QR payload string.
44
+ * @param {object} [options] Options forwarded to qrcode.toDataURL.
45
+ * @returns {Promise<string>} data: URL string.
46
+ */
47
+ async function toDataURL(payload, options = {}) {
48
+ return loadQrcode().toDataURL(payload, options);
49
+ }
50
+
51
+ /**
52
+ * Render a payload to a PNG image Buffer.
53
+ * @param {string} payload QR payload string.
54
+ * @param {object} [options] Options forwarded to qrcode.toBuffer.
55
+ * @returns {Promise<Buffer>}
56
+ */
57
+ async function toBuffer(payload, options = {}) {
58
+ return loadQrcode().toBuffer(payload, Object.assign({ type: 'png' }, options));
59
+ }
60
+
61
+ /**
62
+ * Render a payload to an SVG string.
63
+ * @param {string} payload QR payload string.
64
+ * @param {object} [options] Options forwarded to qrcode.toString.
65
+ * @returns {Promise<string>} SVG markup.
66
+ */
67
+ async function toSVG(payload, options = {}) {
68
+ return loadQrcode().toString(payload, Object.assign({ type: 'svg' }, options));
69
+ }
70
+
71
+ /**
72
+ * Render a payload as a scannable QR in the terminal (UTF-8 blocks).
73
+ * @param {string} payload QR payload string.
74
+ * @param {object} [options] Options forwarded to qrcode.toString.
75
+ * @returns {Promise<string>} The terminal-art string (also handy to print).
76
+ */
77
+ async function toTerminal(payload, options = {}) {
78
+ return loadQrcode().toString(payload, Object.assign({ type: 'terminal', small: true }, options));
79
+ }
80
+
81
+ module.exports = { toFile, toDataURL, toBuffer, toSVG, toTerminal };
package/index.js ADDED
@@ -0,0 +1,28 @@
1
+ 'use strict';
2
+
3
+ const { crc16Ccitt, crc16Hex } = require('./crc');
4
+ const { generatePromptPay, generateBillPayment, formatMobile } = require('./promptpay');
5
+ const { generateKShopQR, KSHOP_DEFAULTS } = require('./kshop');
6
+ const { decode, parseTLV, kshopParamsFrom } = require('./decode');
7
+ const image = require('./image');
8
+
9
+ module.exports = {
10
+ crc16Ccitt,
11
+ crc16Hex,
12
+ generatePromptPay,
13
+ generateBillPayment,
14
+ formatMobile,
15
+ generateKShopQR,
16
+ KSHOP_DEFAULTS,
17
+ // Decoding / parsing.
18
+ decode,
19
+ parseTLV,
20
+ kshopParamsFrom,
21
+ // Optional image helpers (require the `qrcode` package).
22
+ image,
23
+ toFile: image.toFile,
24
+ toDataURL: image.toDataURL,
25
+ toBuffer: image.toBuffer,
26
+ toSVG: image.toSVG,
27
+ toTerminal: image.toTerminal,
28
+ };
package/kshop.js ADDED
@@ -0,0 +1,150 @@
1
+ 'use strict';
2
+
3
+ const { crc16Hex } = require('./crc');
4
+
5
+ // ---- Public PromptPay Application IDs (not account-specific) ----
6
+ const AID_MERCHANT_PRESENTED = 'A000000677010111';
7
+ const AID_DOMESTIC = 'A000000677010112'; // bill payment -> tag 30
8
+ const AID_PAYMENT_INNOVATION = 'A000000677010113'; // payment innovation -> tag 31
9
+ const AID_CUSTOMER_PRESENTED = 'A000000677010114';
10
+ const CURRENCY_THB = '764';
11
+ const COUNTRY_CODE = 'TH';
12
+
13
+ /**
14
+ * Build an EMVCo TLV field, mirroring the PHP `buildTagPayload`.
15
+ *
16
+ * - Array form (single positional value): `['value']` -> `id + len + value`.
17
+ * - Object form (sub-tags): `{ '00': v, '01': v }` -> nested sub-TLVs wrapped
18
+ * in the outer `id + len`.
19
+ *
20
+ * @param {string} tagId
21
+ * @param {string[]|Object<string,string>} data
22
+ * @returns {string}
23
+ */
24
+ function buildTagPayload(tagId, data) {
25
+ if (Array.isArray(data)) {
26
+ if (data.length === 0) return '';
27
+ if (data.length === 1) {
28
+ const v = data[0];
29
+ return tagId + String(v.length).padStart(2, '0') + v;
30
+ }
31
+ // Multiple positional values are not used by the PHP; treat as concat.
32
+ data = Object.assign({}, data);
33
+ }
34
+
35
+ const entries = Object.entries(data);
36
+ if (entries.length === 0) return '';
37
+
38
+ let inner = '';
39
+ for (const [subTagId, v] of entries) {
40
+ inner += subTagId + String(v.length).padStart(2, '0') + v;
41
+ }
42
+ return tagId + String(inner.length).padStart(2, '0') + inner;
43
+ }
44
+
45
+ /**
46
+ * Format amount as a fixed 2-decimal string (PHP number_format equivalent).
47
+ * @param {number|string} amount
48
+ * @returns {string}
49
+ */
50
+ function formatAmount(amount) {
51
+ return Number(amount).toFixed(2);
52
+ }
53
+
54
+ // Universal, non-identifying defaults only. All account-specific values must be
55
+ // supplied by the caller (see required fields in generateKShopQR).
56
+ const KSHOP_DEFAULTS = {
57
+ dynamic: true, // tag 01: true -> '12' (dynamic), false -> '11' (static)
58
+ innovationSubId: '004', // tag 31 / 01 (network sub-id used by the KShop format)
59
+ currency: CURRENCY_THB, // tag 53
60
+ countryCode: COUNTRY_CODE, // tag 58
61
+ };
62
+
63
+ // Account-identifying fields the caller MUST provide.
64
+ const REQUIRED_FIELDS = ['billerId', 'merchantRef', 'merchantName', 'merchantCity'];
65
+
66
+ /**
67
+ * Generate a KShop-format PromptPay merchant QR payload (Tag 30 + Tag 31).
68
+ *
69
+ * All account-identifying values must be supplied via `config` — this library
70
+ * ships no merchant data. Card-scheme templates (tags 02/04/15/51), MCC (52)
71
+ * and the additional-data block (62) are optional and only emitted when given.
72
+ *
73
+ * @param {number} amount Transaction amount in THB.
74
+ * @param {string} reference Per-order reference (tag 30/03 and tag 31/04).
75
+ * @param {object} config
76
+ * @param {string} config.billerId Bank-issued Biller ID (required).
77
+ * @param {string} config.merchantRef Bank merchant reference, e.g. "KB..." (required).
78
+ * @param {string} config.merchantName Merchant name, tag 59 (required).
79
+ * @param {string} config.merchantCity Merchant city, tag 60 (required).
80
+ * @param {boolean} [config.dynamic] tag 01: true='12' (default), false='11'.
81
+ * @param {string} [config.innovationSubId] tag 31/01 (default '004').
82
+ * @param {string} [config.currency] tag 53 (default '764' THB).
83
+ * @param {string} [config.countryCode] tag 58 (default 'TH').
84
+ * @param {string} [config.visaTemplate] tag 02 (optional).
85
+ * @param {string} [config.mastercardTemplate] tag 04 (optional).
86
+ * @param {string} [config.unionpayTemplate] tag 15 (optional).
87
+ * @param {Object<string,string>} [config.cardScheme] tag 51 sub-tags (optional).
88
+ * @param {string} [config.mcc] tag 52 merchant category code (optional).
89
+ * @param {string} [config.additionalData] tag 62 raw value (optional).
90
+ * @returns {string} The EMVCo QR payload including CRC.
91
+ */
92
+ function generateKShopQR(amount, reference, config = {}) {
93
+ const cfg = Object.assign({}, KSHOP_DEFAULTS, config);
94
+
95
+ const missing = REQUIRED_FIELDS.filter((k) => cfg[k] == null || cfg[k] === '');
96
+ if (missing.length) {
97
+ throw new Error('generateKShopQR: missing required config field(s): ' + missing.join(', '));
98
+ }
99
+ if (reference == null || reference === '') {
100
+ throw new Error('generateKShopQR: reference is required');
101
+ }
102
+
103
+ let payload = '';
104
+ payload += buildTagPayload('00', ['01']);
105
+ payload += buildTagPayload('01', [cfg.dynamic ? '12' : '11']);
106
+ if (cfg.visaTemplate) payload += buildTagPayload('02', [cfg.visaTemplate]);
107
+ if (cfg.mastercardTemplate) payload += buildTagPayload('04', [cfg.mastercardTemplate]);
108
+ if (cfg.unionpayTemplate) payload += buildTagPayload('15', [cfg.unionpayTemplate]);
109
+ payload += buildTagPayload('30', {
110
+ '00': AID_DOMESTIC,
111
+ '01': cfg.billerId,
112
+ '02': cfg.merchantRef,
113
+ '03': reference,
114
+ });
115
+ payload += buildTagPayload('31', {
116
+ '00': AID_PAYMENT_INNOVATION,
117
+ '01': cfg.innovationSubId,
118
+ '02': cfg.merchantRef,
119
+ '04': reference,
120
+ });
121
+ if (cfg.cardScheme && Object.keys(cfg.cardScheme).length) {
122
+ payload += buildTagPayload('51', cfg.cardScheme);
123
+ }
124
+ if (cfg.mcc) payload += buildTagPayload('52', [cfg.mcc]);
125
+ payload += buildTagPayload('53', [cfg.currency]);
126
+ payload += buildTagPayload('54', [formatAmount(amount)]);
127
+ payload += buildTagPayload('58', [cfg.countryCode]);
128
+ payload += buildTagPayload('59', [cfg.merchantName]);
129
+ payload += buildTagPayload('60', [cfg.merchantCity]);
130
+ if (cfg.additionalData) payload += buildTagPayload('62', [cfg.additionalData]);
131
+
132
+ // CRC tag 63 over payload + '6304', appended.
133
+ payload += buildTagPayload('63', [crc16Hex(payload + '6304')]);
134
+ return payload;
135
+ }
136
+
137
+ module.exports = {
138
+ generateKShopQR,
139
+ buildTagPayload,
140
+ KSHOP_DEFAULTS,
141
+ REQUIRED_FIELDS,
142
+ constants: {
143
+ AID_MERCHANT_PRESENTED,
144
+ AID_DOMESTIC,
145
+ AID_PAYMENT_INNOVATION,
146
+ AID_CUSTOMER_PRESENTED,
147
+ CURRENCY_THB,
148
+ COUNTRY_CODE,
149
+ },
150
+ };
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "promptpay-qrcode",
3
+ "version": "1.0.0",
4
+ "description": "Zero-dependency PromptPay QR payload generator (PromptPay P2P, Tag 30 bill payment / Mae Manee, and KShop)",
5
+ "main": "index.js",
6
+ "type": "commonjs",
7
+ "files": [
8
+ "crc.js",
9
+ "promptpay.js",
10
+ "kshop.js",
11
+ "decode.js",
12
+ "image.js",
13
+ "index.js",
14
+ "docs/promptpay-qr-structure.md",
15
+ "README.md",
16
+ "LICENSE"
17
+ ],
18
+ "scripts": {
19
+ "test": "node test.js",
20
+ "example": "node example.js",
21
+ "example:image": "node example-image.js"
22
+ },
23
+ "peerDependencies": {
24
+ "qrcode": "^1.5.0"
25
+ },
26
+ "peerDependenciesMeta": {
27
+ "qrcode": {
28
+ "optional": true
29
+ }
30
+ },
31
+ "keywords": [
32
+ "promptpay",
33
+ "qrcode",
34
+ "qr",
35
+ "thai",
36
+ "thailand",
37
+ "emvco",
38
+ "maemanee",
39
+ "mae-manee",
40
+ "scb",
41
+ "kshop",
42
+ "payment"
43
+ ],
44
+ "engines": {
45
+ "node": ">=12"
46
+ },
47
+ "license": "MIT",
48
+ "author": "devhyphenplus <dev.hyphenplus@gmail.com>",
49
+ "repository": {
50
+ "type": "git",
51
+ "url": "git+https://github.com/devhyphenplus/promptpay-qrcode.git"
52
+ },
53
+ "homepage": "https://github.com/devhyphenplus/promptpay-qrcode#readme",
54
+ "bugs": {
55
+ "url": "https://github.com/devhyphenplus/promptpay-qrcode/issues"
56
+ }
57
+ }
package/promptpay.js ADDED
@@ -0,0 +1,141 @@
1
+ 'use strict';
2
+
3
+ const { crc16Hex } = require('./crc');
4
+
5
+ // Application IDs (AID) for PromptPay.
6
+ const AID_PERSON = 'A000000677010111'; // credit transfer (mobile, national id, ewallet) -> tag 29
7
+ const AID_BILLPAY = 'A000000677010112'; // bill payment (domestic biller) -> tag 30
8
+
9
+ const CURRENCY_THB = '764';
10
+ const COUNTRY_TH = 'TH';
11
+
12
+ /**
13
+ * Build a single EMVCo TLV field: id (2) + length (2, zero-padded) + value.
14
+ * @param {string} id Two-char tag id.
15
+ * @param {string} value Field value.
16
+ * @returns {string}
17
+ */
18
+ function tlv(id, value) {
19
+ const len = String(value.length).padStart(2, '0');
20
+ return `${id}${len}${value}`;
21
+ }
22
+
23
+ /**
24
+ * Resolve the Point of Initiation Method value (tag 01).
25
+ *
26
+ * `dynamic` overrides when set (`true` -> '12', `false` -> '11'). When left
27
+ * undefined it defaults to dynamic if an amount is present, static otherwise.
28
+ *
29
+ * @param {boolean|undefined} dynamic Explicit override, or undefined to auto.
30
+ * @param {boolean} hasAmount Whether the payload carries an amount.
31
+ * @returns {'11'|'12'}
32
+ */
33
+ function poiMethod(dynamic, hasAmount) {
34
+ if (dynamic === undefined) return hasAmount ? '12' : '11';
35
+ return dynamic ? '12' : '11';
36
+ }
37
+
38
+ /**
39
+ * Format a Thai mobile number into the 13-char PromptPay proxy value.
40
+ * e.g. "081-234-5678" -> "0066812345678"
41
+ * @param {string} mobile
42
+ * @returns {string}
43
+ */
44
+ function formatMobile(mobile) {
45
+ const digits = String(mobile).replace(/\D/g, '').replace(/^0/, '');
46
+ return ('66' + digits).padStart(13, '0');
47
+ }
48
+
49
+ /**
50
+ * Generate a standard PromptPay QR payload string.
51
+ *
52
+ * Provide exactly one of: mobile, nationalId, ewallet.
53
+ * By default the QR is dynamic (POI '12') when `amount` is given and static
54
+ * (POI '11') otherwise; pass `dynamic` to force either.
55
+ *
56
+ * @param {object} opts
57
+ * @param {string} [opts.mobile] Thai mobile number (any format).
58
+ * @param {string} [opts.nationalId] 13-digit national ID / tax ID.
59
+ * @param {string} [opts.ewallet] 15-digit e-Wallet ID.
60
+ * @param {number} [opts.amount] Optional amount in THB.
61
+ * @param {boolean} [opts.dynamic] Force POI: true='12', false='11'. Auto if omitted.
62
+ * @returns {string} The EMVCo QR payload including CRC.
63
+ */
64
+ function generatePromptPay({ mobile, nationalId, ewallet, amount, dynamic } = {}) {
65
+ const provided = [mobile, nationalId, ewallet].filter((v) => v != null);
66
+ if (provided.length !== 1) {
67
+ throw new Error('Provide exactly one of: mobile, nationalId, ewallet');
68
+ }
69
+
70
+ let merchantField;
71
+ if (mobile != null) {
72
+ merchantField = tlv('01', formatMobile(mobile));
73
+ } else if (nationalId != null) {
74
+ merchantField = tlv('02', String(nationalId).replace(/\D/g, ''));
75
+ } else {
76
+ merchantField = tlv('03', String(ewallet).replace(/\D/g, ''));
77
+ }
78
+
79
+ const merchantAccount = tlv('00', AID_PERSON) + merchantField;
80
+ const hasAmount = amount != null && amount !== '';
81
+
82
+ let payload = '';
83
+ payload += tlv('00', '01'); // payload format indicator
84
+ payload += tlv('01', poiMethod(dynamic, hasAmount)); // dynamic vs static
85
+ payload += tlv('29', merchantAccount); // PromptPay merchant account info
86
+ payload += tlv('53', CURRENCY_THB);
87
+ if (hasAmount) {
88
+ payload += tlv('54', Number(amount).toFixed(2));
89
+ }
90
+ payload += tlv('58', COUNTRY_TH);
91
+
92
+ payload += '6304' + crc16Hex(payload + '6304');
93
+ return payload;
94
+ }
95
+
96
+ /**
97
+ * Generate a PromptPay Bill Payment (Tag 30) QR payload string.
98
+ *
99
+ * This is the merchant "bill payment" QR family — the same shape SCB's
100
+ * แม่มณี (Mae Manee) and other merchant QRs use. The payer's app shows the
101
+ * merchant name (tag 59) rather than a person's name.
102
+ *
103
+ * @param {object} opts
104
+ * @param {string} opts.billerId Bank-issued Biller ID (usually 15 digits:
105
+ * 13-digit tax ID + 2-digit suffix).
106
+ * @param {string} opts.ref1 Reference 1 (mandatory, biller-defined).
107
+ * @param {string} [opts.ref2] Reference 2 (optional, biller-defined).
108
+ * @param {number} [opts.amount] Amount in THB. Present => dynamic by default.
109
+ * @param {boolean} [opts.dynamic] Force POI: true='12', false='11'. Auto if omitted.
110
+ * @param {string} [opts.merchantName] Merchant name (tag 59).
111
+ * @param {string} [opts.merchantCity] Merchant city (tag 60).
112
+ * @returns {string} The EMVCo QR payload including CRC.
113
+ */
114
+ function generateBillPayment({ billerId, ref1, ref2, amount, dynamic, merchantName, merchantCity } = {}) {
115
+ if (!billerId) throw new Error('billerId is required');
116
+ if (!ref1) throw new Error('ref1 is required');
117
+
118
+ let merchantAccount = tlv('00', AID_BILLPAY) + tlv('01', String(billerId)) + tlv('02', String(ref1));
119
+ if (ref2 != null && ref2 !== '') {
120
+ merchantAccount += tlv('03', String(ref2));
121
+ }
122
+
123
+ const hasAmount = amount != null && amount !== '';
124
+
125
+ let payload = '';
126
+ payload += tlv('00', '01'); // payload format indicator
127
+ payload += tlv('01', poiMethod(dynamic, hasAmount)); // dynamic vs static
128
+ payload += tlv('30', merchantAccount); // bill payment merchant account info
129
+ payload += tlv('53', CURRENCY_THB);
130
+ if (hasAmount) {
131
+ payload += tlv('54', Number(amount).toFixed(2));
132
+ }
133
+ payload += tlv('58', COUNTRY_TH);
134
+ if (merchantName) payload += tlv('59', merchantName);
135
+ if (merchantCity) payload += tlv('60', merchantCity);
136
+
137
+ payload += '6304' + crc16Hex(payload + '6304');
138
+ return payload;
139
+ }
140
+
141
+ module.exports = { generatePromptPay, generateBillPayment, formatMobile, tlv };