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 +39 -2
- package/cli.js +7 -1
- package/decode.js +83 -3
- package/index.js +2 -1
- package/package.json +1 -1
- package/promptpay.js +12 -2
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
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({
|
|
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;
|