promptpay-qrcode 1.1.0 → 1.2.1

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
@@ -65,9 +65,17 @@ generateBillPayment({
65
65
  dynamic: true, // optional; force POI (true='12', false='11')
66
66
  merchantName: 'MY SHOP', // optional (tag 59)
67
67
  merchantCity: 'BANGKOK', // optional (tag 60)
68
+ additionalData: '07160000…', // optional raw tag 62 (e.g. terminal label sub-TLV)
69
+ countryCode: 'TH', // optional (tag 58, default 'TH')
68
70
  });
69
71
  ```
70
72
 
73
+ Tag order matches real Thai bill-payment QRs (`00,01,30,58,53,…,62,63` — note
74
+ `58` before `53`), so a decoded bill-payment QR round-trips **byte-for-byte**:
75
+ `generateBillPayment({ ...account, ...transaction })` from a `detach()` of an
76
+ SCB/Mae Manee QR reproduces the original exactly (including its tag-62 terminal
77
+ label).
78
+
71
79
  The Biller ID and references are issued/defined by your bank (for SCB, via the
72
80
  Mae Manee / Business QR onboarding). `ref1` is required; `ref2` is optional.
73
81
 
@@ -142,6 +150,34 @@ d.tags; // ordered [{ id, length, value }] of the top level
142
150
 
143
151
  It throws on a malformed payload (a declared length running past the string).
144
152
 
153
+ ### Detecting supported payment channels
154
+
155
+ A KShop/merchant QR carries a separate template per enrolled payment rail, so an
156
+ omitted template means that channel isn't offered. `channels(qr)` reports them:
157
+
158
+ ```js
159
+ const { channels } = require('promptpay-qrcode');
160
+
161
+ channels(kshopQr);
162
+ // {
163
+ // promptpay: true,
164
+ // creditCard: true, // false if the merchant isn't card-enabled
165
+ // networks: ['visa', 'mastercard', 'unionpay'],
166
+ // promptpayTemplates: ['30', '31'],
167
+ // cardTemplates: ['02', '04', '15', '51'],
168
+ // }
169
+ ```
170
+
171
+ So a KShop account configured **without** credit-card acceptance produces a QR
172
+ with no card templates, and `channels()` returns `creditCard: false`,
173
+ `networks: []`. PromptPay rails are detected from tags `29`/`30`/`31`; card
174
+ networks from the EMVCo template ranges (`02`–`16`) and from card RIDs in the
175
+ generic `26`–`51` range. `detach(qr)` also includes this under `.channels`.
176
+
177
+ > This reflects what the merchant has **enrolled** (capability advertised by the
178
+ > QR). Whether a specific card actually authorizes is still the acquirer's call
179
+ > at settlement.
180
+
145
181
  ### Cloning another KShop account from its master QR
146
182
 
147
183
  `kshopParamsFrom(qr)` pulls out exactly the account-identifying fields you'd
@@ -270,14 +306,15 @@ The second `options` argument is passed straight through to `qrcode`
270
306
  | Function | Returns |
271
307
  | --- | --- |
272
308
  | `generatePromptPay({ mobile \| nationalId \| ewallet, amount?, dynamic? })` | payload string |
273
- | `generateBillPayment({ billerId, ref1, ref2?, amount?, dynamic?, merchantName?, merchantCity? })` | payload string |
309
+ | `generateBillPayment({ billerId, ref1, ref2?, amount?, dynamic?, merchantName?, merchantCity?, additionalData?, countryCode? })` | payload string |
274
310
  | `generateKShopQR(amount, reference, config)` | payload string |
275
311
  | `KSHOP_DEFAULTS` / `REQUIRED_FIELDS` | KShop structural defaults / required field list |
276
312
  | `decode(payload)` | structured decode + CRC validation |
277
313
  | `parseTLV(payload)` | low-level ordered `[{ id, length, value }]` |
278
314
  | `kshopParamsFrom(qr)` | account params to clone a KShop master QR |
279
- | `detach(qr)` | `{ type, account, transaction, decoded }` for any QR type |
315
+ | `detach(qr)` | `{ type, account, transaction, channels, decoded }` for any QR type |
280
316
  | `detectType(fields)` | `'promptpay'` \| `'billpayment'` \| `'kshop'` \| `'unknown'` |
317
+ | `channels(qr)` | `{ promptpay, creditCard, networks, promptpayTemplates, cardTemplates }` |
281
318
  | `crc16Ccitt(str)` / `crc16Hex(str)` | CRC16-CCITT (number / 4-char hex) |
282
319
  | `formatMobile(str)` | 13-char PromptPay mobile proxy |
283
320
  | `toFile(path, payload, opts?)` | `Promise<void>` — write PNG file *(needs `qrcode`)* |
package/cli.js CHANGED
@@ -61,15 +61,21 @@ function main() {
61
61
  }
62
62
 
63
63
  if (json) {
64
- console.log(JSON.stringify({ type: r.type, account: r.account, transaction: r.transaction, crc: d.crc }, null, 2));
64
+ console.log(JSON.stringify({ type: r.type, channels: r.channels, account: r.account, transaction: r.transaction, crc: d.crc }, null, 2));
65
65
  return;
66
66
  }
67
67
 
68
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)';
69
74
  console.log('Type : ' + r.type);
70
75
  console.log('CRC : ' + (d.crc ? d.crc.value : '(none)') + (ok ? ' ✓ valid' : ' ✗ INVALID (expected ' + (d.crc && d.crc.expected) + ')'));
71
76
  console.log('POI : ' + d.poiMethod + (d.static ? ' (static)' : ' (dynamic)'));
72
77
  console.log('Amount : ' + (d.amount == null ? '(none — payer enters)' : d.amount));
78
+ console.log('Channels : ' + channelStr);
73
79
  if (d.merchantName) console.log('Merchant : ' + d.merchantName + (d.merchantCity ? ' / ' + d.merchantCity : ''));
74
80
  console.log('\nAccount (reusable):');
75
81
  console.log(JSON.stringify(r.account, null, 2));
package/decode.js CHANGED
@@ -174,6 +174,82 @@ function detectType(fields) {
174
174
  return 'unknown';
175
175
  }
176
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
+
177
253
  /**
178
254
  * Detach a master QR into its reusable account info and its per-transaction
179
255
  * values. Works for all three types (auto-detected):
@@ -189,7 +265,7 @@ function detectType(fields) {
189
265
  * kshop -> generateKShopQR(transaction.amount, transaction.reference, account)
190
266
  *
191
267
  * @param {string|object} qr A payload string or a prior decode() result.
192
- * @returns {{ type: string, account: object, transaction: object, decoded: object }}
268
+ * @returns {{ type: string, account: object, transaction: object, channels: object, decoded: object }}
193
269
  */
194
270
  function detach(qr) {
195
271
  const d = typeof qr === 'string' ? decode(qr) : qr;
@@ -215,6 +291,10 @@ function detach(qr) {
215
291
  if (t30['01'] != null) account.billerId = t30['01'];
216
292
  if (f['59'] != null) account.merchantName = f['59'];
217
293
  if (f['60'] != null) account.merchantCity = f['60'];
294
+ if (f['58'] != null) account.countryCode = f['58'];
295
+ // Tag 62 (Additional Data) — keep the raw inner string so it round-trips.
296
+ const raw62 = flattenTag62(d.tags);
297
+ if (raw62 != null) account.additionalData = raw62;
218
298
  transaction = {
219
299
  ref1: t30['02'],
220
300
  ref2: t30['03'] != null ? t30['03'] : undefined,
@@ -227,7 +307,7 @@ function detach(qr) {
227
307
  transaction = { amount: d.amount };
228
308
  }
229
309
 
230
- return { type, account, transaction, decoded: d };
310
+ return { type, account, transaction, channels: channels(d), decoded: d };
231
311
  }
232
312
 
233
- module.exports = { decode, parseTLV, kshopParamsFrom, detach, detectType };
313
+ module.exports = { decode, parseTLV, kshopParamsFrom, detach, detectType, channels };
package/index.js CHANGED
@@ -3,7 +3,7 @@
3
3
  const { crc16Ccitt, crc16Hex } = require('./crc');
4
4
  const { generatePromptPay, generateBillPayment, formatMobile } = require('./promptpay');
5
5
  const { generateKShopQR, KSHOP_DEFAULTS, AID_PAYMENT_INNOVATION, AID_PAYMENT_INNOVATION_BOT } = require('./kshop');
6
- const { decode, parseTLV, kshopParamsFrom, detach, detectType } = require('./decode');
6
+ const { decode, parseTLV, kshopParamsFrom, detach, detectType, channels } = require('./decode');
7
7
  const image = require('./image');
8
8
 
9
9
  module.exports = {
@@ -22,6 +22,7 @@ module.exports = {
22
22
  kshopParamsFrom,
23
23
  detach,
24
24
  detectType,
25
+ channels,
25
26
  // Optional image helpers (require the `qrcode` package).
26
27
  image,
27
28
  toFile: image.toFile,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "promptpay-qrcode",
3
- "version": "1.1.0",
3
+ "version": "1.2.1",
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",
package/promptpay.js CHANGED
@@ -115,9 +115,18 @@ function generatePromptPay({ mobile, nationalId, ewallet, amount, dynamic } = {}
115
115
  * @param {boolean} [opts.dynamic] Force POI: true='12', false='11'. Auto if omitted.
116
116
  * @param {string} [opts.merchantName] Merchant name (tag 59).
117
117
  * @param {string} [opts.merchantCity] Merchant city (tag 60).
118
+ * @param {string} [opts.additionalData] Raw Additional Data Field (tag 62) value,
119
+ * e.g. terminal label sub-TLV "0716...".
120
+ * @param {string} [opts.countryCode] Country code (tag 58). Default 'TH'.
118
121
  * @returns {string} The EMVCo QR payload including CRC.
122
+ *
123
+ * Tag order follows the layout used by real Thai bill-payment QRs (SCB / Mae
124
+ * Manee): `00,01,30,58,53,[54],[59],[60],[62],63` — note `58` precedes `53`.
125
+ * This lets a decoded bill-payment QR round-trip byte-for-byte.
119
126
  */
120
- function generateBillPayment({ billerId, ref1, ref2, amount, dynamic, merchantName, merchantCity } = {}) {
127
+ function generateBillPayment({
128
+ billerId, ref1, ref2, amount, dynamic, merchantName, merchantCity, additionalData, countryCode,
129
+ } = {}) {
121
130
  if (!billerId) throw new Error('billerId is required');
122
131
  if (!ref1) throw new Error('ref1 is required');
123
132
 
@@ -132,13 +141,14 @@ function generateBillPayment({ billerId, ref1, ref2, amount, dynamic, merchantNa
132
141
  payload += tlv('00', '01'); // payload format indicator
133
142
  payload += tlv('01', poiMethod(dynamic, hasAmount)); // dynamic vs static
134
143
  payload += tlv('30', merchantAccount); // bill payment merchant account info
144
+ payload += tlv('58', countryCode || COUNTRY_TH); // country (before currency, per Thai QR layout)
135
145
  payload += tlv('53', CURRENCY_THB);
136
146
  if (hasAmount) {
137
147
  payload += tlv('54', Number(amount).toFixed(2));
138
148
  }
139
- payload += tlv('58', COUNTRY_TH);
140
149
  if (merchantName) payload += tlv('59', merchantName);
141
150
  if (merchantCity) payload += tlv('60', merchantCity);
151
+ if (additionalData) payload += tlv('62', String(additionalData));
142
152
 
143
153
  payload += '6304' + crc16Hex(payload + '6304');
144
154
  return payload;