promptpay-qrcode 1.0.0 → 1.1.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/README.md +101 -4
- package/cli.js +84 -0
- package/decode.js +83 -1
- package/docs/promptpay-qr-structure.md +76 -23
- package/index.js +6 -2
- package/kshop.js +35 -9
- package/package.json +7 -2
- package/promptpay.js +7 -1
package/README.md
CHANGED
|
@@ -86,18 +86,40 @@ const config = {
|
|
|
86
86
|
merchantCity: 'BANGKOK', // tag 60 (required)
|
|
87
87
|
// Optional — emitted only when provided:
|
|
88
88
|
// visaTemplate, mastercardTemplate, unionpayTemplate, cardScheme,
|
|
89
|
-
// mcc, additionalData, dynamic (default
|
|
89
|
+
// mcc, additionalData, dynamic (default false), innovationSubId (default '004'),
|
|
90
|
+
// innovationAid (tag 31 AID — default KShop value; see below)
|
|
90
91
|
};
|
|
91
92
|
|
|
92
|
-
generateKShopQR(100, 'ORDER0000000001', config);
|
|
93
|
-
generateKShopQR(100, 'ORDER0000000001', { ...config, dynamic:
|
|
93
|
+
generateKShopQR(100, 'ORDER0000000001', config); // static (POI '11') — default
|
|
94
|
+
generateKShopQR(100, 'ORDER0000000001', { ...config, dynamic: true }); // dynamic (POI '12')
|
|
94
95
|
```
|
|
95
96
|
|
|
96
97
|
`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:
|
|
98
|
+
30/03 and 31/04) vary per call. Structural defaults (`dynamic: false`,
|
|
98
99
|
`currency: '764'`, `countryCode: 'TH'`, `innovationSubId: '004'`) live in
|
|
99
100
|
`KSHOP_DEFAULTS`; the required fields are listed in `REQUIRED_FIELDS`.
|
|
100
101
|
|
|
102
|
+
> **Tag 31 AID (`innovationAid`).** The Bank of Thailand guideline documents
|
|
103
|
+
> `A000000677012004` for the Payment-Innovation template, but **KBank/KShop QRs
|
|
104
|
+
> in the wild use `A000000677010113`**. The library defaults to the KShop value
|
|
105
|
+
> so real KShop QRs round-trip exactly; pass `innovationAid` to override:
|
|
106
|
+
>
|
|
107
|
+
> ```js
|
|
108
|
+
> const { generateKShopQR, AID_PAYMENT_INNOVATION_BOT } = require('promptpay-qrcode');
|
|
109
|
+
> generateKShopQR(100, 'ORDER1', { ...config, innovationAid: AID_PAYMENT_INNOVATION_BOT });
|
|
110
|
+
> ```
|
|
111
|
+
>
|
|
112
|
+
> Both AIDs are exported: `AID_PAYMENT_INNOVATION` (KShop, default) and
|
|
113
|
+
> `AID_PAYMENT_INNOVATION_BOT` (BOT). `detach`/`kshopParamsFrom` capture whichever
|
|
114
|
+
> the source QR used.
|
|
115
|
+
|
|
116
|
+
> **Static vs dynamic — bank-app compatibility.** KShop defaults to **static
|
|
117
|
+
> (POI `11`) with the amount included**, because that form is accepted by the
|
|
118
|
+
> widest range of apps — including **K PLUS** and the KShop app. In real-device
|
|
119
|
+
> testing, **dynamic (POI `12`) is rejected by K PLUS** for this merchant QR
|
|
120
|
+
> family (though it works in SCB, KTB Next, BBL and UOB). Pass `dynamic: true`
|
|
121
|
+
> only if you specifically target apps that accept POI `12`.
|
|
122
|
+
|
|
101
123
|
If you already have a master QR for an account, you can decode it and reuse its
|
|
102
124
|
fields — see [`kshopParamsFrom`](#decoding-a-qr-read-a-master-qr-back) below.
|
|
103
125
|
|
|
@@ -143,6 +165,78 @@ are **not** included in `params` — you supply those per call. Round-trip is
|
|
|
143
165
|
exact: `generateKShopQR(amount, ref, kshopParamsFrom(qr))` reproduces the
|
|
144
166
|
original master QR byte-for-byte when given the same amount and ref.
|
|
145
167
|
|
|
168
|
+
### Detaching any master QR (all types)
|
|
169
|
+
|
|
170
|
+
`detach(qr)` is the generic version: it auto-detects the QR type and splits it
|
|
171
|
+
into reusable **account** info and the per-transaction values, for **all three**
|
|
172
|
+
families. `kshopParamsFrom` is the KShop-specific case underneath it.
|
|
173
|
+
|
|
174
|
+
```js
|
|
175
|
+
const { detach, generatePromptPay, generateBillPayment, generateKShopQR } = require('promptpay-qrcode');
|
|
176
|
+
|
|
177
|
+
const { type, account, transaction } = detach(masterQr);
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
| `type` | `account` (reusable) | `transaction` (per-call) | Regenerate |
|
|
181
|
+
| --- | --- | --- | --- |
|
|
182
|
+
| `'promptpay'` | `{ mobile \| nationalId \| ewallet }` | `{ amount, dynamic }` | `generatePromptPay({ ...account, ...transaction })` |
|
|
183
|
+
| `'billpayment'` | `{ billerId, merchantName?, merchantCity? }` | `{ ref1, ref2?, amount, dynamic }` | `generateBillPayment({ ...account, ...transaction })` |
|
|
184
|
+
| `'kshop'` | full KShop config (= `kshopParamsFrom`) | `{ amount, reference }` | `generateKShopQR(transaction.amount, transaction.reference, account)` |
|
|
185
|
+
|
|
186
|
+
```js
|
|
187
|
+
// Example: re-issue a bill-payment QR with a new amount, same account
|
|
188
|
+
const { account } = detach(masterBillQr);
|
|
189
|
+
const next = generateBillPayment({ ...account, ref1: 'INV2', amount: 75 });
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
For PromptPay the mobile proxy is reversed (`0066812345678` → `0812345678`) so
|
|
193
|
+
it round-trips through `generatePromptPay`. `detach` accepts a payload string or
|
|
194
|
+
a prior `decode()` result, and also returns the full `decoded` object.
|
|
195
|
+
`detectType(fields)` is exposed separately if you only need the type.
|
|
196
|
+
|
|
197
|
+
## CLI
|
|
198
|
+
|
|
199
|
+
A small command-line inspector ships with the package (`promptpay-qr`, or
|
|
200
|
+
`node cli.js` from the repo). It decodes a payload, validates the CRC, and shows
|
|
201
|
+
the detached account/transaction split and a tag dump — all locally, nothing
|
|
202
|
+
leaves your machine.
|
|
203
|
+
|
|
204
|
+
```
|
|
205
|
+
# from the repo
|
|
206
|
+
node cli.js '00020101021130...C9ED'
|
|
207
|
+
npm run decode -- '00020101...' # via the npm script
|
|
208
|
+
|
|
209
|
+
# installed globally (npm i -g promptpay-qrcode)
|
|
210
|
+
promptpay-qr '00020101...'
|
|
211
|
+
|
|
212
|
+
# pipe it in, or get raw JSON
|
|
213
|
+
echo '00020101...' | promptpay-qr
|
|
214
|
+
promptpay-qr --json '00020101...'
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
Example output:
|
|
218
|
+
|
|
219
|
+
```
|
|
220
|
+
Type : kshop
|
|
221
|
+
CRC : C9ED ✓ valid
|
|
222
|
+
POI : 11 (static — K PLUS compatible)
|
|
223
|
+
Amount : (none — payer enters)
|
|
224
|
+
Merchant : MY SHOP / CITY
|
|
225
|
+
|
|
226
|
+
Account (reusable): { billerId, merchantRef, merchantName, ... }
|
|
227
|
+
Transaction (per-call): { amount, reference }
|
|
228
|
+
|
|
229
|
+
Tags:
|
|
230
|
+
00 02 01
|
|
231
|
+
01 02 11
|
|
232
|
+
30 81
|
|
233
|
+
00 16 A000000677010112
|
|
234
|
+
...
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
Exit code is `0` for a valid CRC, `1` for an invalid/malformed payload — handy
|
|
238
|
+
in scripts.
|
|
239
|
+
|
|
146
240
|
## Rendering to an image (optional)
|
|
147
241
|
|
|
148
242
|
The core is zero-dependency. To turn a payload into an actual QR image, install
|
|
@@ -182,6 +276,8 @@ The second `options` argument is passed straight through to `qrcode`
|
|
|
182
276
|
| `decode(payload)` | structured decode + CRC validation |
|
|
183
277
|
| `parseTLV(payload)` | low-level ordered `[{ id, length, value }]` |
|
|
184
278
|
| `kshopParamsFrom(qr)` | account params to clone a KShop master QR |
|
|
279
|
+
| `detach(qr)` | `{ type, account, transaction, decoded }` for any QR type |
|
|
280
|
+
| `detectType(fields)` | `'promptpay'` \| `'billpayment'` \| `'kshop'` \| `'unknown'` |
|
|
185
281
|
| `crc16Ccitt(str)` / `crc16Hex(str)` | CRC16-CCITT (number / 4-char hex) |
|
|
186
282
|
| `formatMobile(str)` | 13-char PromptPay mobile proxy |
|
|
187
283
|
| `toFile(path, payload, opts?)` | `Promise<void>` — write PNG file *(needs `qrcode`)* |
|
|
@@ -197,6 +293,7 @@ The second `options` argument is passed straight through to `qrcode`
|
|
|
197
293
|
- `kshop.js` — KShop generator (configurable, no bundled merchant data).
|
|
198
294
|
- `decode.js` — decode/parse a payload + `kshopParamsFrom` extractor.
|
|
199
295
|
- `image.js` — optional image helpers (lazy-load `qrcode`).
|
|
296
|
+
- `cli.js` — command-line inspector (`promptpay-qr` / `npm run decode`).
|
|
200
297
|
- `index.js` — public entry point.
|
|
201
298
|
- `test.js` — `npm test`. `example.js` — `npm run example`.
|
|
202
299
|
`example-image.js` — `npm run example:image` (needs `qrcode`).
|
package/cli.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
// Tiny CLI to inspect a PromptPay / Thai QR payload locally.
|
|
5
|
+
//
|
|
6
|
+
// node cli.js <payload> decode + detach a payload string
|
|
7
|
+
// node cli.js --json <payload> print the raw JSON result
|
|
8
|
+
// echo "<payload>" | node cli.js read payload from stdin
|
|
9
|
+
//
|
|
10
|
+
// Nothing leaves your machine.
|
|
11
|
+
|
|
12
|
+
const { decode, detach } = require('./index');
|
|
13
|
+
|
|
14
|
+
function readInput() {
|
|
15
|
+
const args = process.argv.slice(2).filter((a) => a !== '--json');
|
|
16
|
+
if (args.length) return args.join('').trim();
|
|
17
|
+
// Fall back to stdin (e.g. piped input).
|
|
18
|
+
try {
|
|
19
|
+
return require('fs').readFileSync(0, 'utf8').trim();
|
|
20
|
+
} catch (_) {
|
|
21
|
+
return '';
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function tagLine(id, len, value, indent) {
|
|
26
|
+
return `${' '.repeat(indent)}${id} ${String(len).padStart(2, '0')} ${value}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Pretty-print top-level tags, expanding the nested merchant templates.
|
|
30
|
+
function dumpTags(payload) {
|
|
31
|
+
const NESTED = new Set(['29', '30', '31', '51', '62']);
|
|
32
|
+
const { parseTLV } = require('./decode');
|
|
33
|
+
const lines = [];
|
|
34
|
+
for (const t of parseTLV(payload)) {
|
|
35
|
+
if (NESTED.has(t.id)) {
|
|
36
|
+
lines.push(tagLine(t.id, t.length, '', 0).trimEnd());
|
|
37
|
+
for (const s of parseTLV(t.value)) lines.push(tagLine(s.id, s.length, s.value, 1));
|
|
38
|
+
} else {
|
|
39
|
+
lines.push(tagLine(t.id, t.length, t.value, 0));
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return lines.join('\n');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function main() {
|
|
46
|
+
const json = process.argv.includes('--json');
|
|
47
|
+
const payload = readInput();
|
|
48
|
+
|
|
49
|
+
if (!payload) {
|
|
50
|
+
console.error('Usage: node cli.js [--json] <payload> (or pipe the payload via stdin)');
|
|
51
|
+
process.exit(2);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
let d, r;
|
|
55
|
+
try {
|
|
56
|
+
d = decode(payload);
|
|
57
|
+
r = detach(payload);
|
|
58
|
+
} catch (err) {
|
|
59
|
+
console.error('Error: ' + err.message);
|
|
60
|
+
process.exit(1);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (json) {
|
|
64
|
+
console.log(JSON.stringify({ type: r.type, account: r.account, transaction: r.transaction, crc: d.crc }, null, 2));
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const ok = d.crc && d.crc.valid;
|
|
69
|
+
console.log('Type : ' + r.type);
|
|
70
|
+
console.log('CRC : ' + (d.crc ? d.crc.value : '(none)') + (ok ? ' ✓ valid' : ' ✗ INVALID (expected ' + (d.crc && d.crc.expected) + ')'));
|
|
71
|
+
console.log('POI : ' + d.poiMethod + (d.static ? ' (static)' : ' (dynamic)'));
|
|
72
|
+
console.log('Amount : ' + (d.amount == null ? '(none — payer enters)' : d.amount));
|
|
73
|
+
if (d.merchantName) console.log('Merchant : ' + d.merchantName + (d.merchantCity ? ' / ' + d.merchantCity : ''));
|
|
74
|
+
console.log('\nAccount (reusable):');
|
|
75
|
+
console.log(JSON.stringify(r.account, null, 2));
|
|
76
|
+
console.log('\nTransaction (per-call):');
|
|
77
|
+
console.log(JSON.stringify(r.transaction, null, 2));
|
|
78
|
+
console.log('\nTags:');
|
|
79
|
+
console.log(dumpTags(payload));
|
|
80
|
+
|
|
81
|
+
if (!ok) process.exit(1);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
main();
|
package/decode.js
CHANGED
|
@@ -126,6 +126,7 @@ function kshopParamsFrom(qr) {
|
|
|
126
126
|
if (t30['01'] != null) params.billerId = t30['01'];
|
|
127
127
|
// Merchant ref appears in 30/02 (and usually mirrored in 31/02).
|
|
128
128
|
if (t30['02'] != null) params.merchantRef = t30['02'];
|
|
129
|
+
if (t31['00'] != null) params.innovationAid = t31['00'];
|
|
129
130
|
if (t31['01'] != null) params.innovationSubId = t31['01'];
|
|
130
131
|
if (Object.keys(t51).length) params.cardScheme = t51;
|
|
131
132
|
if (f['52'] != null) params.mcc = f['52'];
|
|
@@ -148,4 +149,85 @@ function flattenTag62(tags) {
|
|
|
148
149
|
return tag ? tag.value : undefined;
|
|
149
150
|
}
|
|
150
151
|
|
|
151
|
-
|
|
152
|
+
/**
|
|
153
|
+
* Reverse formatMobile: turn a 13-char PromptPay proxy back into a national
|
|
154
|
+
* mobile number so it round-trips through generatePromptPay.
|
|
155
|
+
* e.g. "0066812345678" -> "0812345678"
|
|
156
|
+
* @param {string} proxy
|
|
157
|
+
* @returns {string}
|
|
158
|
+
*/
|
|
159
|
+
function proxyToMobile(proxy) {
|
|
160
|
+
let s = String(proxy).replace(/^0+/, ''); // drop the left-pad zeros
|
|
161
|
+
if (s.startsWith('66')) s = s.slice(2); // drop the country code
|
|
162
|
+
return '0' + s;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Detect a QR's PromptPay type from its decoded fields.
|
|
167
|
+
* @param {object} fields decode().fields
|
|
168
|
+
* @returns {'promptpay'|'kshop'|'billpayment'|'unknown'}
|
|
169
|
+
*/
|
|
170
|
+
function detectType(fields) {
|
|
171
|
+
if (fields['29']) return 'promptpay';
|
|
172
|
+
if (fields['30'] && fields['31']) return 'kshop';
|
|
173
|
+
if (fields['30']) return 'billpayment';
|
|
174
|
+
return 'unknown';
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Detach a master QR into its reusable account info and its per-transaction
|
|
179
|
+
* values. Works for all three types (auto-detected):
|
|
180
|
+
*
|
|
181
|
+
* - promptpay : account { mobile | nationalId | ewallet }, transaction { amount, dynamic }
|
|
182
|
+
* - billpayment: account { billerId, merchantName?, merchantCity? },
|
|
183
|
+
* transaction { ref1, ref2?, amount, dynamic }
|
|
184
|
+
* - kshop : account = kshopParamsFrom(qr), transaction { amount, reference }
|
|
185
|
+
*
|
|
186
|
+
* Regenerate a fresh QR from the parts:
|
|
187
|
+
* promptpay -> generatePromptPay({ ...account, ...transaction })
|
|
188
|
+
* billpayment -> generateBillPayment({ ...account, ...transaction })
|
|
189
|
+
* kshop -> generateKShopQR(transaction.amount, transaction.reference, account)
|
|
190
|
+
*
|
|
191
|
+
* @param {string|object} qr A payload string or a prior decode() result.
|
|
192
|
+
* @returns {{ type: string, account: object, transaction: object, decoded: object }}
|
|
193
|
+
*/
|
|
194
|
+
function detach(qr) {
|
|
195
|
+
const d = typeof qr === 'string' ? decode(qr) : qr;
|
|
196
|
+
const f = d.fields;
|
|
197
|
+
const type = detectType(f);
|
|
198
|
+
|
|
199
|
+
let account = {};
|
|
200
|
+
let transaction = {};
|
|
201
|
+
|
|
202
|
+
if (type === 'promptpay') {
|
|
203
|
+
const t29 = f['29'] || {};
|
|
204
|
+
if (t29['01'] != null) account.mobile = proxyToMobile(t29['01']);
|
|
205
|
+
else if (t29['02'] != null) account.nationalId = t29['02'];
|
|
206
|
+
else if (t29['03'] != null) account.ewallet = t29['03'];
|
|
207
|
+
transaction = { amount: d.amount, dynamic: d.poiMethod === '12' };
|
|
208
|
+
} else if (type === 'kshop') {
|
|
209
|
+
account = kshopParamsFrom(d); // includes dynamic + all merchant/card fields
|
|
210
|
+
// The order reference lives in 30/03 (mirrored in 31/04).
|
|
211
|
+
const ref = (f['30'] || {})['03'];
|
|
212
|
+
transaction = { amount: d.amount, reference: ref != null ? ref : undefined };
|
|
213
|
+
} else if (type === 'billpayment') {
|
|
214
|
+
const t30 = f['30'] || {};
|
|
215
|
+
if (t30['01'] != null) account.billerId = t30['01'];
|
|
216
|
+
if (f['59'] != null) account.merchantName = f['59'];
|
|
217
|
+
if (f['60'] != null) account.merchantCity = f['60'];
|
|
218
|
+
transaction = {
|
|
219
|
+
ref1: t30['02'],
|
|
220
|
+
ref2: t30['03'] != null ? t30['03'] : undefined,
|
|
221
|
+
amount: d.amount,
|
|
222
|
+
dynamic: d.poiMethod === '12',
|
|
223
|
+
};
|
|
224
|
+
} else {
|
|
225
|
+
// Unknown layout: hand back the raw decoded fields so nothing is lost.
|
|
226
|
+
account = { fields: f };
|
|
227
|
+
transaction = { amount: d.amount };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return { type, account, transaction, decoded: d };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
module.exports = { decode, parseTLV, kshopParamsFrom, detach, detectType };
|
|
@@ -67,14 +67,23 @@ Example: `000201` = ID `00`, LEN `02`, VALUE `01`.
|
|
|
67
67
|
|
|
68
68
|
## 3. PromptPay Application IDs (AIDs)
|
|
69
69
|
|
|
70
|
-
PromptPay merchant-account templates begin with sub-tag `00` = the AID.
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
|
75
|
-
|
|
76
|
-
| `
|
|
77
|
-
| `A000000677010114` |
|
|
70
|
+
PromptPay merchant-account templates begin with sub-tag `00` = the AID. The
|
|
71
|
+
values below are from the **Bank of Thailand "Policy Guideline: Standardized
|
|
72
|
+
Thai QR Code for Payment Transactions" (17 April 2019)** — see §11.
|
|
73
|
+
|
|
74
|
+
| AID | Purpose | Template | Source |
|
|
75
|
+
|-----|---------|----------|--------|
|
|
76
|
+
| `A000000677010111` | Credit Transfer w/ PromptPay ID (merchant-presented) | tag `29` | BOT |
|
|
77
|
+
| `A000000677010114` | Credit Transfer (customer-presented) | tag `29` | BOT |
|
|
78
|
+
| `A000000677010112` | Bill Payment — **domestic** merchant | tag `30` | BOT |
|
|
79
|
+
| `A000000677012006` | Bill Payment — **cross-border** merchant | tag `30` | BOT |
|
|
80
|
+
| `A000000677012004` | Payment Innovation (API) | tag `31` | BOT |
|
|
81
|
+
| `A000000677010113` | Payment Innovation (as seen in KBank/KShop QRs) | tag `31` | **vendor — not in BOT guideline** |
|
|
82
|
+
|
|
83
|
+
> ⚠️ The original PHP and real KShop QRs use `A000000677010113` in tag `31`. That
|
|
84
|
+
> value is **not** documented in the BOT guideline (which lists `A000000677012004`
|
|
85
|
+
> for the Payment-Innovation API template). It appears to be a KBank/KShop-specific
|
|
86
|
+
> usage; the library reproduces whatever the caller/config provides.
|
|
78
87
|
|
|
79
88
|
---
|
|
80
89
|
|
|
@@ -83,12 +92,17 @@ PromptPay merchant-account templates begin with sub-tag `00` = the AID.
|
|
|
83
92
|
```
|
|
84
93
|
29 LL
|
|
85
94
|
00 16 A000000677010111 ← AID
|
|
86
|
-
01 13 0066812345678 ← mobile (one of 01
|
|
95
|
+
01 13 0066812345678 ← mobile (one of 01..05)
|
|
87
96
|
02 13 1234567890123 ← national ID / tax ID
|
|
88
97
|
03 15 123456789012345 ← e-wallet ID
|
|
98
|
+
04 .. <bankcode+accountno> ← bank account (BOT: ans, up to 43)
|
|
99
|
+
05 10 <OTA> ← mandatory if AID = ...010114 (customer-presented)
|
|
89
100
|
```
|
|
90
101
|
|
|
91
|
-
Provide **exactly one** of sub-tags
|
|
102
|
+
Provide **exactly one** of the identifier sub-tags. Per the BOT guideline,
|
|
103
|
+
tag 29 also defines `04` (bank account = 3-digit bank code + account no.) and
|
|
104
|
+
`05` (OTA, mandatory for the customer-presented AID). This library generates
|
|
105
|
+
`01`/`02`/`03` (the common cases).
|
|
92
106
|
|
|
93
107
|
**Mobile normalization** (sub-tag `01`, always 13 chars):
|
|
94
108
|
1. Strip non-digits.
|
|
@@ -113,12 +127,16 @@ E-wallet ID → sub-tag `03`, 15 digits as-is.
|
|
|
113
127
|
03 LL <Reference2> ← optional, biller-defined
|
|
114
128
|
```
|
|
115
129
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
- **
|
|
121
|
-
|
|
130
|
+
Per the BOT guideline, tag 30 fields are: `00` AID (M), `01` Biller ID
|
|
131
|
+
(N, **15**, M), `02` Reference 1 (ans, up to **20**, **M**), `03` Reference 2
|
|
132
|
+
(ans, up to **20**, **O**).
|
|
133
|
+
|
|
134
|
+
- **Biller ID**: "National ID / Tax ID + Suffix" — the merchant's 13-digit tax
|
|
135
|
+
ID plus a 2-digit suffix, so 15 chars. Bank-assigned.
|
|
136
|
+
- **Reference 1 (`02`)**: **mandatory** (per BOT). Biller-defined meaning —
|
|
137
|
+
usually the customer/account/invoice identifier. Alphanumeric, ≤20.
|
|
138
|
+
- **Reference 2 (`03`)**: **optional** (per BOT). Secondary reference (branch,
|
|
139
|
+
terminal, order id). Alphanumeric, ≤20. Omit the whole sub-tag if unused.
|
|
122
140
|
|
|
123
141
|
> The KSHOP PHP puts the bank-issued merchant ref in `02` and the per-order
|
|
124
142
|
> KShop reference in `03`. That's a valid biller-specific choice — not a fixed
|
|
@@ -166,14 +184,30 @@ payload += "6304" + crc16(payload + "6304").toString(16).toUpperCase().padStart(
|
|
|
166
184
|
|
|
167
185
|
## 8. Static vs Dynamic (tag 01)
|
|
168
186
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
|
172
|
-
|
|
187
|
+
Per EMVCo, tag `01` signals **intent**, not a technical lock:
|
|
188
|
+
|
|
189
|
+
| Value | EMVCo definition |
|
|
190
|
+
|-------|------------------|
|
|
191
|
+
| `11` | **Static** — use when *the same QR is shown for more than one transaction*. |
|
|
192
|
+
| `12` | **Dynamic** — use when *a new QR is shown for each transaction* (usually carries amount/reference). |
|
|
173
193
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
194
|
+
Important nuances:
|
|
195
|
+
|
|
196
|
+
- **The payload does not enforce single use.** A `12` payload has no nonce,
|
|
197
|
+
counter, expiry, or session token — it is just static text with `01=12`. So a
|
|
198
|
+
"dynamic" QR is physically **reusable**: re-scanning yields the same valid
|
|
199
|
+
payload. Whether a second payment is *accepted* is decided by the bank /
|
|
200
|
+
issuer back-end, not by the QR. (Observed: SCB accepts repeat payments on a
|
|
201
|
+
`12` QR.)
|
|
202
|
+
- **An amount can appear under either value.** Including tag `54` doesn't
|
|
203
|
+
require `12`; e.g. KShop-style QRs use `11` *with* an amount (see §9), and
|
|
204
|
+
that form has the widest bank-app acceptance — notably **K PLUS rejects `12`**
|
|
205
|
+
for that merchant family.
|
|
206
|
+
- Common shorthand calls `12` "one-time", but that's a convention (POS shows a
|
|
207
|
+
fresh code per sale), not a property of the payload.
|
|
208
|
+
|
|
209
|
+
Practical guidance: omit `54` ⇒ `11` (reusable, payer types amount); fixed
|
|
210
|
+
amount ⇒ either works — choose based on the target apps' acceptance.
|
|
177
211
|
|
|
178
212
|
---
|
|
179
213
|
|
|
@@ -229,3 +263,22 @@ only when they are provided.
|
|
|
229
263
|
4. `53` = `764`, `58` = `TH`.
|
|
230
264
|
5. Every TLV length matches its value; payload re-parses with no leftover bytes.
|
|
231
265
|
6. Ends with `6304` + valid CRC16 of everything before the 4 CRC chars.
|
|
266
|
+
|
|
267
|
+
---
|
|
268
|
+
|
|
269
|
+
## 11. Official source
|
|
270
|
+
|
|
271
|
+
The PromptPay-specific tags (29/30/31 AIDs and sub-tags, biller/reference
|
|
272
|
+
definitions) in this document were verified against the **Bank of Thailand**
|
|
273
|
+
primary source:
|
|
274
|
+
|
|
275
|
+
> **"Policy Guideline: Standardized Thai QR Code for Payment Transactions"**,
|
|
276
|
+
> Bank of Thailand, effective **17 April 2019 (B.E. 2562)**.
|
|
277
|
+
> EN: <https://www.bot.or.th/content/dam/bot/fipcs/documents/FPG/2562/EngPDF/25620084.pdf>
|
|
278
|
+
> TH: <https://www.bot.or.th/content/dam/bot/fipcs/documents/FPG/2562/ThaiPDF/25620084.pdf>
|
|
279
|
+
|
|
280
|
+
All top-level tags not specific to PromptPay (52/53/54/58/59/60/62/63 and the
|
|
281
|
+
Point of Initiation method `01`) are defined by **EMVCo "QR Code Specification
|
|
282
|
+
for Payment Systems: Merchant-Presented Mode (MPM)"**, which the BOT guideline
|
|
283
|
+
references rather than redefines. In particular, the BOT guideline does **not**
|
|
284
|
+
add its own static/dynamic semantics for tag `01` — see §8.
|
package/index.js
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
const { crc16Ccitt, crc16Hex } = require('./crc');
|
|
4
4
|
const { generatePromptPay, generateBillPayment, formatMobile } = require('./promptpay');
|
|
5
|
-
const { generateKShopQR, KSHOP_DEFAULTS } = require('./kshop');
|
|
6
|
-
const { decode, parseTLV, kshopParamsFrom } = require('./decode');
|
|
5
|
+
const { generateKShopQR, KSHOP_DEFAULTS, AID_PAYMENT_INNOVATION, AID_PAYMENT_INNOVATION_BOT } = require('./kshop');
|
|
6
|
+
const { decode, parseTLV, kshopParamsFrom, detach, detectType } = require('./decode');
|
|
7
7
|
const image = require('./image');
|
|
8
8
|
|
|
9
9
|
module.exports = {
|
|
@@ -14,10 +14,14 @@ module.exports = {
|
|
|
14
14
|
formatMobile,
|
|
15
15
|
generateKShopQR,
|
|
16
16
|
KSHOP_DEFAULTS,
|
|
17
|
+
AID_PAYMENT_INNOVATION, // tag 31 AID — KBank/KShop (default)
|
|
18
|
+
AID_PAYMENT_INNOVATION_BOT, // tag 31 AID — Bank of Thailand guideline
|
|
17
19
|
// Decoding / parsing.
|
|
18
20
|
decode,
|
|
19
21
|
parseTLV,
|
|
20
22
|
kshopParamsFrom,
|
|
23
|
+
detach,
|
|
24
|
+
detectType,
|
|
21
25
|
// Optional image helpers (require the `qrcode` package).
|
|
22
26
|
image,
|
|
23
27
|
toFile: image.toFile,
|
package/kshop.js
CHANGED
|
@@ -1,12 +1,18 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const { crc16Hex } = require('./crc');
|
|
4
|
+
const { poiMethod } = require('./promptpay');
|
|
4
5
|
|
|
5
6
|
// ---- Public PromptPay Application IDs (not account-specific) ----
|
|
6
|
-
const AID_MERCHANT_PRESENTED = 'A000000677010111';
|
|
7
|
-
const AID_DOMESTIC = 'A000000677010112'; // bill payment
|
|
8
|
-
const
|
|
9
|
-
|
|
7
|
+
const AID_MERCHANT_PRESENTED = 'A000000677010111'; // credit transfer -> tag 29 (BOT)
|
|
8
|
+
const AID_DOMESTIC = 'A000000677010112'; // bill payment, domestic -> tag 30 (BOT)
|
|
9
|
+
const AID_CUSTOMER_PRESENTED = 'A000000677010114'; // credit transfer, customer-presented (BOT)
|
|
10
|
+
// Tag 31 (Payment Innovation) AID. The Bank of Thailand guideline documents
|
|
11
|
+
// `A000000677012004`; KBank/KShop QRs in the wild use `A000000677010113`. The
|
|
12
|
+
// KShop value is the default so real KShop QRs round-trip; pass
|
|
13
|
+
// `innovationAid` to override (e.g. AID_PAYMENT_INNOVATION_BOT).
|
|
14
|
+
const AID_PAYMENT_INNOVATION = 'A000000677010113'; // tag 31 — KBank/KShop (vendor)
|
|
15
|
+
const AID_PAYMENT_INNOVATION_BOT = 'A000000677012004'; // tag 31 — BOT guideline
|
|
10
16
|
const CURRENCY_THB = '764';
|
|
11
17
|
const COUNTRY_CODE = 'TH';
|
|
12
18
|
|
|
@@ -54,7 +60,14 @@ function formatAmount(amount) {
|
|
|
54
60
|
// Universal, non-identifying defaults only. All account-specific values must be
|
|
55
61
|
// supplied by the caller (see required fields in generateKShopQR).
|
|
56
62
|
const KSHOP_DEFAULTS = {
|
|
57
|
-
|
|
63
|
+
// Point of initiation (tag 01) uses the shared poiMethod rule: the `dynamic`
|
|
64
|
+
// flag wins, else amount-driven. KShop defaults `dynamic` to FALSE (static,
|
|
65
|
+
// POI '11') for maximum bank-app compatibility — notably K PLUS rejects
|
|
66
|
+
// dynamic (POI '12') for this merchant QR family, while static-with-amount
|
|
67
|
+
// works in K PLUS, KShop, SCB, KTB, BBL and UOB. Pass dynamic:true to force
|
|
68
|
+
// POI '12' when targeting apps that accept it.
|
|
69
|
+
dynamic: false,
|
|
70
|
+
innovationAid: AID_PAYMENT_INNOVATION, // tag 31 / 00 (KShop default; see AID_PAYMENT_INNOVATION_BOT)
|
|
58
71
|
innovationSubId: '004', // tag 31 / 01 (network sub-id used by the KShop format)
|
|
59
72
|
currency: CURRENCY_THB, // tag 53
|
|
60
73
|
countryCode: COUNTRY_CODE, // tag 58
|
|
@@ -77,7 +90,12 @@ const REQUIRED_FIELDS = ['billerId', 'merchantRef', 'merchantName', 'merchantCit
|
|
|
77
90
|
* @param {string} config.merchantRef Bank merchant reference, e.g. "KB..." (required).
|
|
78
91
|
* @param {string} config.merchantName Merchant name, tag 59 (required).
|
|
79
92
|
* @param {string} config.merchantCity Merchant city, tag 60 (required).
|
|
80
|
-
* @param {boolean} [config.dynamic] tag 01: true='12'
|
|
93
|
+
* @param {boolean} [config.dynamic] tag 01: force true='12' / false='11'.
|
|
94
|
+
* Auto if omitted (dynamic when amount present).
|
|
95
|
+
* @param {string} [config.innovationAid] tag 31/00 AID. Default is the
|
|
96
|
+
* KBank/KShop value; pass
|
|
97
|
+
* `AID_PAYMENT_INNOVATION_BOT` for the
|
|
98
|
+
* Bank of Thailand-documented AID.
|
|
81
99
|
* @param {string} [config.innovationSubId] tag 31/01 (default '004').
|
|
82
100
|
* @param {string} [config.currency] tag 53 (default '764' THB).
|
|
83
101
|
* @param {string} [config.countryCode] tag 58 (default 'TH').
|
|
@@ -100,9 +118,11 @@ function generateKShopQR(amount, reference, config = {}) {
|
|
|
100
118
|
throw new Error('generateKShopQR: reference is required');
|
|
101
119
|
}
|
|
102
120
|
|
|
121
|
+
const hasAmount = amount != null && amount !== '';
|
|
122
|
+
|
|
103
123
|
let payload = '';
|
|
104
124
|
payload += buildTagPayload('00', ['01']);
|
|
105
|
-
payload += buildTagPayload('01', [cfg.dynamic
|
|
125
|
+
payload += buildTagPayload('01', [poiMethod(cfg.dynamic, hasAmount)]);
|
|
106
126
|
if (cfg.visaTemplate) payload += buildTagPayload('02', [cfg.visaTemplate]);
|
|
107
127
|
if (cfg.mastercardTemplate) payload += buildTagPayload('04', [cfg.mastercardTemplate]);
|
|
108
128
|
if (cfg.unionpayTemplate) payload += buildTagPayload('15', [cfg.unionpayTemplate]);
|
|
@@ -113,7 +133,7 @@ function generateKShopQR(amount, reference, config = {}) {
|
|
|
113
133
|
'03': reference,
|
|
114
134
|
});
|
|
115
135
|
payload += buildTagPayload('31', {
|
|
116
|
-
'00':
|
|
136
|
+
'00': cfg.innovationAid,
|
|
117
137
|
'01': cfg.innovationSubId,
|
|
118
138
|
'02': cfg.merchantRef,
|
|
119
139
|
'04': reference,
|
|
@@ -123,7 +143,9 @@ function generateKShopQR(amount, reference, config = {}) {
|
|
|
123
143
|
}
|
|
124
144
|
if (cfg.mcc) payload += buildTagPayload('52', [cfg.mcc]);
|
|
125
145
|
payload += buildTagPayload('53', [cfg.currency]);
|
|
126
|
-
|
|
146
|
+
// Tag 54 (amount) only when an amount is supplied — a no-amount KShop QR is a
|
|
147
|
+
// valid static "payer enters amount" master.
|
|
148
|
+
if (hasAmount) payload += buildTagPayload('54', [formatAmount(amount)]);
|
|
127
149
|
payload += buildTagPayload('58', [cfg.countryCode]);
|
|
128
150
|
payload += buildTagPayload('59', [cfg.merchantName]);
|
|
129
151
|
payload += buildTagPayload('60', [cfg.merchantCity]);
|
|
@@ -139,10 +161,14 @@ module.exports = {
|
|
|
139
161
|
buildTagPayload,
|
|
140
162
|
KSHOP_DEFAULTS,
|
|
141
163
|
REQUIRED_FIELDS,
|
|
164
|
+
// AIDs exported for callers who want to override tag 31 (e.g. BOT vs KShop).
|
|
165
|
+
AID_PAYMENT_INNOVATION,
|
|
166
|
+
AID_PAYMENT_INNOVATION_BOT,
|
|
142
167
|
constants: {
|
|
143
168
|
AID_MERCHANT_PRESENTED,
|
|
144
169
|
AID_DOMESTIC,
|
|
145
170
|
AID_PAYMENT_INNOVATION,
|
|
171
|
+
AID_PAYMENT_INNOVATION_BOT,
|
|
146
172
|
AID_CUSTOMER_PRESENTED,
|
|
147
173
|
CURRENCY_THB,
|
|
148
174
|
COUNTRY_CODE,
|
package/package.json
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "promptpay-qrcode",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "Zero-dependency PromptPay QR payload generator (PromptPay P2P, Tag 30 bill payment / Mae Manee, and KShop)",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"type": "commonjs",
|
|
7
|
+
"bin": {
|
|
8
|
+
"promptpay-qr": "cli.js"
|
|
9
|
+
},
|
|
7
10
|
"files": [
|
|
8
11
|
"crc.js",
|
|
9
12
|
"promptpay.js",
|
|
@@ -11,6 +14,7 @@
|
|
|
11
14
|
"decode.js",
|
|
12
15
|
"image.js",
|
|
13
16
|
"index.js",
|
|
17
|
+
"cli.js",
|
|
14
18
|
"docs/promptpay-qr-structure.md",
|
|
15
19
|
"README.md",
|
|
16
20
|
"LICENSE"
|
|
@@ -18,7 +22,8 @@
|
|
|
18
22
|
"scripts": {
|
|
19
23
|
"test": "node test.js",
|
|
20
24
|
"example": "node example.js",
|
|
21
|
-
"example:image": "node example-image.js"
|
|
25
|
+
"example:image": "node example-image.js",
|
|
26
|
+
"decode": "node cli.js"
|
|
22
27
|
},
|
|
23
28
|
"peerDependencies": {
|
|
24
29
|
"qrcode": "^1.5.0"
|
package/promptpay.js
CHANGED
|
@@ -23,6 +23,12 @@ function tlv(id, value) {
|
|
|
23
23
|
/**
|
|
24
24
|
* Resolve the Point of Initiation Method value (tag 01).
|
|
25
25
|
*
|
|
26
|
+
* Per EMVCo, '11' (static) = the same QR is shown for more than one
|
|
27
|
+
* transaction; '12' (dynamic) = a new QR is shown for each transaction. This is
|
|
28
|
+
* an intent marker only — the payload does not enforce single use, so a '12' QR
|
|
29
|
+
* is still physically reusable; whether a repeat payment is accepted is up to
|
|
30
|
+
* the bank back-end. An amount may appear under either value.
|
|
31
|
+
*
|
|
26
32
|
* `dynamic` overrides when set (`true` -> '12', `false` -> '11'). When left
|
|
27
33
|
* undefined it defaults to dynamic if an amount is present, static otherwise.
|
|
28
34
|
*
|
|
@@ -138,4 +144,4 @@ function generateBillPayment({ billerId, ref1, ref2, amount, dynamic, merchantNa
|
|
|
138
144
|
return payload;
|
|
139
145
|
}
|
|
140
146
|
|
|
141
|
-
module.exports = { generatePromptPay, generateBillPayment, formatMobile, tlv };
|
|
147
|
+
module.exports = { generatePromptPay, generateBillPayment, formatMobile, tlv, poiMethod };
|