promptpay-qrcode 1.0.0 → 1.2.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 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 true), innovationSubId (default '004')
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); // dynamic (POI '12')
93
- generateKShopQR(100, 'ORDER0000000001', { ...config, dynamic: false }); // static (POI '11')
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: true`,
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
 
@@ -120,6 +142,34 @@ d.tags; // ordered [{ id, length, value }] of the top level
120
142
 
121
143
  It throws on a malformed payload (a declared length running past the string).
122
144
 
145
+ ### Detecting supported payment channels
146
+
147
+ A KShop/merchant QR carries a separate template per enrolled payment rail, so an
148
+ omitted template means that channel isn't offered. `channels(qr)` reports them:
149
+
150
+ ```js
151
+ const { channels } = require('promptpay-qrcode');
152
+
153
+ channels(kshopQr);
154
+ // {
155
+ // promptpay: true,
156
+ // creditCard: true, // false if the merchant isn't card-enabled
157
+ // networks: ['visa', 'mastercard', 'unionpay'],
158
+ // promptpayTemplates: ['30', '31'],
159
+ // cardTemplates: ['02', '04', '15', '51'],
160
+ // }
161
+ ```
162
+
163
+ So a KShop account configured **without** credit-card acceptance produces a QR
164
+ with no card templates, and `channels()` returns `creditCard: false`,
165
+ `networks: []`. PromptPay rails are detected from tags `29`/`30`/`31`; card
166
+ networks from the EMVCo template ranges (`02`–`16`) and from card RIDs in the
167
+ generic `26`–`51` range. `detach(qr)` also includes this under `.channels`.
168
+
169
+ > This reflects what the merchant has **enrolled** (capability advertised by the
170
+ > QR). Whether a specific card actually authorizes is still the acquirer's call
171
+ > at settlement.
172
+
123
173
  ### Cloning another KShop account from its master QR
124
174
 
125
175
  `kshopParamsFrom(qr)` pulls out exactly the account-identifying fields you'd
@@ -143,6 +193,78 @@ are **not** included in `params` — you supply those per call. Round-trip is
143
193
  exact: `generateKShopQR(amount, ref, kshopParamsFrom(qr))` reproduces the
144
194
  original master QR byte-for-byte when given the same amount and ref.
145
195
 
196
+ ### Detaching any master QR (all types)
197
+
198
+ `detach(qr)` is the generic version: it auto-detects the QR type and splits it
199
+ into reusable **account** info and the per-transaction values, for **all three**
200
+ families. `kshopParamsFrom` is the KShop-specific case underneath it.
201
+
202
+ ```js
203
+ const { detach, generatePromptPay, generateBillPayment, generateKShopQR } = require('promptpay-qrcode');
204
+
205
+ const { type, account, transaction } = detach(masterQr);
206
+ ```
207
+
208
+ | `type` | `account` (reusable) | `transaction` (per-call) | Regenerate |
209
+ | --- | --- | --- | --- |
210
+ | `'promptpay'` | `{ mobile \| nationalId \| ewallet }` | `{ amount, dynamic }` | `generatePromptPay({ ...account, ...transaction })` |
211
+ | `'billpayment'` | `{ billerId, merchantName?, merchantCity? }` | `{ ref1, ref2?, amount, dynamic }` | `generateBillPayment({ ...account, ...transaction })` |
212
+ | `'kshop'` | full KShop config (= `kshopParamsFrom`) | `{ amount, reference }` | `generateKShopQR(transaction.amount, transaction.reference, account)` |
213
+
214
+ ```js
215
+ // Example: re-issue a bill-payment QR with a new amount, same account
216
+ const { account } = detach(masterBillQr);
217
+ const next = generateBillPayment({ ...account, ref1: 'INV2', amount: 75 });
218
+ ```
219
+
220
+ For PromptPay the mobile proxy is reversed (`0066812345678` → `0812345678`) so
221
+ it round-trips through `generatePromptPay`. `detach` accepts a payload string or
222
+ a prior `decode()` result, and also returns the full `decoded` object.
223
+ `detectType(fields)` is exposed separately if you only need the type.
224
+
225
+ ## CLI
226
+
227
+ A small command-line inspector ships with the package (`promptpay-qr`, or
228
+ `node cli.js` from the repo). It decodes a payload, validates the CRC, and shows
229
+ the detached account/transaction split and a tag dump — all locally, nothing
230
+ leaves your machine.
231
+
232
+ ```
233
+ # from the repo
234
+ node cli.js '00020101021130...C9ED'
235
+ npm run decode -- '00020101...' # via the npm script
236
+
237
+ # installed globally (npm i -g promptpay-qrcode)
238
+ promptpay-qr '00020101...'
239
+
240
+ # pipe it in, or get raw JSON
241
+ echo '00020101...' | promptpay-qr
242
+ promptpay-qr --json '00020101...'
243
+ ```
244
+
245
+ Example output:
246
+
247
+ ```
248
+ Type : kshop
249
+ CRC : C9ED ✓ valid
250
+ POI : 11 (static — K PLUS compatible)
251
+ Amount : (none — payer enters)
252
+ Merchant : MY SHOP / CITY
253
+
254
+ Account (reusable): { billerId, merchantRef, merchantName, ... }
255
+ Transaction (per-call): { amount, reference }
256
+
257
+ Tags:
258
+ 00 02 01
259
+ 01 02 11
260
+ 30 81
261
+ 00 16 A000000677010112
262
+ ...
263
+ ```
264
+
265
+ Exit code is `0` for a valid CRC, `1` for an invalid/malformed payload — handy
266
+ in scripts.
267
+
146
268
  ## Rendering to an image (optional)
147
269
 
148
270
  The core is zero-dependency. To turn a payload into an actual QR image, install
@@ -182,6 +304,9 @@ The second `options` argument is passed straight through to `qrcode`
182
304
  | `decode(payload)` | structured decode + CRC validation |
183
305
  | `parseTLV(payload)` | low-level ordered `[{ id, length, value }]` |
184
306
  | `kshopParamsFrom(qr)` | account params to clone a KShop master QR |
307
+ | `detach(qr)` | `{ type, account, transaction, channels, decoded }` for any QR type |
308
+ | `detectType(fields)` | `'promptpay'` \| `'billpayment'` \| `'kshop'` \| `'unknown'` |
309
+ | `channels(qr)` | `{ promptpay, creditCard, networks, promptpayTemplates, cardTemplates }` |
185
310
  | `crc16Ccitt(str)` / `crc16Hex(str)` | CRC16-CCITT (number / 4-char hex) |
186
311
  | `formatMobile(str)` | 13-char PromptPay mobile proxy |
187
312
  | `toFile(path, payload, opts?)` | `Promise<void>` — write PNG file *(needs `qrcode`)* |
@@ -197,6 +322,7 @@ The second `options` argument is passed straight through to `qrcode`
197
322
  - `kshop.js` — KShop generator (configurable, no bundled merchant data).
198
323
  - `decode.js` — decode/parse a payload + `kshopParamsFrom` extractor.
199
324
  - `image.js` — optional image helpers (lazy-load `qrcode`).
325
+ - `cli.js` — command-line inspector (`promptpay-qr` / `npm run decode`).
200
326
  - `index.js` — public entry point.
201
327
  - `test.js` — `npm test`. `example.js` — `npm run example`.
202
328
  `example-image.js` — `npm run example:image` (needs `qrcode`).
package/cli.js ADDED
@@ -0,0 +1,90 @@
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, channels: r.channels, account: r.account, transaction: r.transaction, crc: d.crc }, null, 2));
65
+ return;
66
+ }
67
+
68
+ const ok = d.crc && d.crc.valid;
69
+ const ch = r.channels;
70
+ const channelStr =
71
+ (ch.promptpay ? 'PromptPay' : '') +
72
+ (ch.creditCard ? (ch.promptpay ? ' + ' : '') + 'Card (' + ch.networks.join(', ') + ')' : '') ||
73
+ '(none)';
74
+ console.log('Type : ' + r.type);
75
+ console.log('CRC : ' + (d.crc ? d.crc.value : '(none)') + (ok ? ' ✓ valid' : ' ✗ INVALID (expected ' + (d.crc && d.crc.expected) + ')'));
76
+ console.log('POI : ' + d.poiMethod + (d.static ? ' (static)' : ' (dynamic)'));
77
+ console.log('Amount : ' + (d.amount == null ? '(none — payer enters)' : d.amount));
78
+ console.log('Channels : ' + channelStr);
79
+ if (d.merchantName) console.log('Merchant : ' + d.merchantName + (d.merchantCity ? ' / ' + d.merchantCity : ''));
80
+ console.log('\nAccount (reusable):');
81
+ console.log(JSON.stringify(r.account, null, 2));
82
+ console.log('\nTransaction (per-call):');
83
+ console.log(JSON.stringify(r.transaction, null, 2));
84
+ console.log('\nTags:');
85
+ console.log(dumpTags(payload));
86
+
87
+ if (!ok) process.exit(1);
88
+ }
89
+
90
+ 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,161 @@ function flattenTag62(tags) {
148
149
  return tag ? tag.value : undefined;
149
150
  }
150
151
 
151
- module.exports = { decode, parseTLV, kshopParamsFrom };
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
+ // PromptPay merchant-account templates -> rail name.
178
+ const PROMPTPAY_TAG = { '29': 'credit-transfer', '30': 'bill-payment', '31': 'payment-innovation' };
179
+
180
+ // EMVCo fixed template-ID allocations for card networks.
181
+ const CARD_NETWORK_BY_TAG = {
182
+ '02': 'visa', '03': 'visa',
183
+ '04': 'mastercard', '05': 'mastercard',
184
+ '09': 'discover', '10': 'discover',
185
+ '11': 'amex', '12': 'amex',
186
+ '13': 'jcb', '14': 'jcb',
187
+ '15': 'unionpay', '16': 'unionpay',
188
+ };
189
+
190
+ // Card-network Registered Application Provider IDs (RID = first 10 chars of AID),
191
+ // used to classify the generic merchant-template range 26-51 by its sub-tag 00.
192
+ const CARD_RID = {
193
+ A000000003: 'visa', A000000004: 'mastercard', A000000025: 'amex',
194
+ A000000065: 'jcb', A000000152: 'discover', A000000333: 'unionpay',
195
+ };
196
+
197
+ /**
198
+ * Detect which payment rails a QR advertises. KShop (and other merchant QRs)
199
+ * include a separate template per enrolled rail, so an omitted template means
200
+ * that channel is not offered — e.g. a merchant not configured to accept cards
201
+ * has no card templates.
202
+ *
203
+ * Note: this reflects what the merchant has ENROLLED (capability advertised by
204
+ * the QR). Whether a given card actually authorizes is still the acquirer's
205
+ * decision at settlement.
206
+ *
207
+ * @param {string|object} qr A payload string or a prior decode() result.
208
+ * @returns {{
209
+ * promptpay: boolean, creditCard: boolean,
210
+ * networks: string[],
211
+ * promptpayTemplates: string[], cardTemplates: string[]
212
+ * }}
213
+ */
214
+ function channels(qr) {
215
+ const d = typeof qr === 'string' ? decode(qr) : qr;
216
+ const networks = new Set();
217
+ const promptpayTemplates = [];
218
+ const cardTemplates = [];
219
+
220
+ for (const { id } of d.tags) {
221
+ if (PROMPTPAY_TAG[id]) {
222
+ promptpayTemplates.push(id);
223
+ continue;
224
+ }
225
+ if (CARD_NETWORK_BY_TAG[id]) {
226
+ networks.add(CARD_NETWORK_BY_TAG[id]);
227
+ cardTemplates.push(id);
228
+ continue;
229
+ }
230
+ // Generic merchant-template range 26-51: classify by AID/RID in sub-tag 00.
231
+ const n = parseInt(id, 10);
232
+ if (n >= 26 && n <= 51 && typeof d.fields[id] === 'object') {
233
+ const aid = d.fields[id]['00'] || '';
234
+ if (aid.startsWith('A000000677')) {
235
+ // A PromptPay template living in the 26-51 range (rare); count it too.
236
+ if (!promptpayTemplates.includes(id)) promptpayTemplates.push(id);
237
+ } else if (CARD_RID[aid.slice(0, 10)]) {
238
+ networks.add(CARD_RID[aid.slice(0, 10)]);
239
+ cardTemplates.push(id);
240
+ }
241
+ }
242
+ }
243
+
244
+ return {
245
+ promptpay: promptpayTemplates.length > 0,
246
+ creditCard: networks.size > 0,
247
+ networks: [...networks],
248
+ promptpayTemplates,
249
+ cardTemplates,
250
+ };
251
+ }
252
+
253
+ /**
254
+ * Detach a master QR into its reusable account info and its per-transaction
255
+ * values. Works for all three types (auto-detected):
256
+ *
257
+ * - promptpay : account { mobile | nationalId | ewallet }, transaction { amount, dynamic }
258
+ * - billpayment: account { billerId, merchantName?, merchantCity? },
259
+ * transaction { ref1, ref2?, amount, dynamic }
260
+ * - kshop : account = kshopParamsFrom(qr), transaction { amount, reference }
261
+ *
262
+ * Regenerate a fresh QR from the parts:
263
+ * promptpay -> generatePromptPay({ ...account, ...transaction })
264
+ * billpayment -> generateBillPayment({ ...account, ...transaction })
265
+ * kshop -> generateKShopQR(transaction.amount, transaction.reference, account)
266
+ *
267
+ * @param {string|object} qr A payload string or a prior decode() result.
268
+ * @returns {{ type: string, account: object, transaction: object, channels: object, decoded: object }}
269
+ */
270
+ function detach(qr) {
271
+ const d = typeof qr === 'string' ? decode(qr) : qr;
272
+ const f = d.fields;
273
+ const type = detectType(f);
274
+
275
+ let account = {};
276
+ let transaction = {};
277
+
278
+ if (type === 'promptpay') {
279
+ const t29 = f['29'] || {};
280
+ if (t29['01'] != null) account.mobile = proxyToMobile(t29['01']);
281
+ else if (t29['02'] != null) account.nationalId = t29['02'];
282
+ else if (t29['03'] != null) account.ewallet = t29['03'];
283
+ transaction = { amount: d.amount, dynamic: d.poiMethod === '12' };
284
+ } else if (type === 'kshop') {
285
+ account = kshopParamsFrom(d); // includes dynamic + all merchant/card fields
286
+ // The order reference lives in 30/03 (mirrored in 31/04).
287
+ const ref = (f['30'] || {})['03'];
288
+ transaction = { amount: d.amount, reference: ref != null ? ref : undefined };
289
+ } else if (type === 'billpayment') {
290
+ const t30 = f['30'] || {};
291
+ if (t30['01'] != null) account.billerId = t30['01'];
292
+ if (f['59'] != null) account.merchantName = f['59'];
293
+ if (f['60'] != null) account.merchantCity = f['60'];
294
+ transaction = {
295
+ ref1: t30['02'],
296
+ ref2: t30['03'] != null ? t30['03'] : undefined,
297
+ amount: d.amount,
298
+ dynamic: d.poiMethod === '12',
299
+ };
300
+ } else {
301
+ // Unknown layout: hand back the raw decoded fields so nothing is lost.
302
+ account = { fields: f };
303
+ transaction = { amount: d.amount };
304
+ }
305
+
306
+ return { type, account, transaction, channels: channels(d), decoded: d };
307
+ }
308
+
309
+ module.exports = { decode, parseTLV, kshopParamsFrom, detach, detectType, channels };
@@ -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
- | 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) |
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/02/03)
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 `01`/`02`/`03`.
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
- - **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.
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
- | Value | Meaning |
170
- |-------|---------|
171
- | `11` | **Static** reusable; amount usually omitted, payer types it. |
172
- | `12` | **Dynamic** — single use; amount (`54`) present. |
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
- 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.
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, channels } = require('./decode');
7
7
  const image = require('./image');
8
8
 
9
9
  module.exports = {
@@ -14,10 +14,15 @@ 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,
25
+ channels,
21
26
  // Optional image helpers (require the `qrcode` package).
22
27
  image,
23
28
  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 -> tag 30
8
- const AID_PAYMENT_INNOVATION = 'A000000677010113'; // payment innovation -> tag 31
9
- const AID_CUSTOMER_PRESENTED = 'A000000677010114';
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
- dynamic: true, // tag 01: true -> '12' (dynamic), false -> '11' (static)
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' (default), false='11'.
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 ? '12' : '11']);
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': AID_PAYMENT_INNOVATION,
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
- payload += buildTagPayload('54', [formatAmount(amount)]);
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.0.0",
3
+ "version": "1.2.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 };