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 +21 -0
- package/README.md +217 -0
- package/crc.js +37 -0
- package/decode.js +151 -0
- package/docs/promptpay-qr-structure.md +231 -0
- package/image.js +81 -0
- package/index.js +28 -0
- package/kshop.js +150 -0
- package/package.json +57 -0
- package/promptpay.js +141 -0
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 };
|