@laranex/myanmar-payments 4.0.0-dev.1 → 4.0.0-dev.11
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/CHANGELOG.md +19 -4
- package/README.md +3 -3
- package/dist/cjs/aya-pay/aya-pay.d.ts +13 -9
- package/dist/cjs/aya-pay/aya-pay.js +16 -12
- package/dist/cjs/core/amount.d.ts +4 -3
- package/dist/cjs/core/amount.js +13 -4
- package/dist/cjs/core/callback.d.ts +14 -8
- package/dist/cjs/core/callback.js +19 -11
- package/dist/cjs/core/crypto.d.ts +6 -1
- package/dist/cjs/core/crypto.js +17 -3
- package/dist/cjs/core/env.d.ts +21 -5
- package/dist/cjs/core/env.js +49 -12
- package/dist/cjs/core/errors.d.ts +9 -3
- package/dist/cjs/core/errors.js +12 -4
- package/dist/cjs/core/http.d.ts +10 -11
- package/dist/cjs/core/http.js +7 -9
- package/dist/cjs/core/index.d.ts +2 -2
- package/dist/cjs/core/index.js +3 -2
- package/dist/cjs/core/json.d.ts +2 -2
- package/dist/cjs/core/json.js +3 -3
- package/dist/cjs/core/status.d.ts +3 -3
- package/dist/cjs/core/status.js +2 -2
- package/dist/cjs/core/values.d.ts +2 -0
- package/dist/cjs/core/values.js +5 -0
- package/dist/cjs/cyber-source/cyber-source.d.ts +12 -14
- package/dist/cjs/cyber-source/cyber-source.js +39 -26
- package/dist/cjs/kbz-pay/kbz-pay.d.ts +36 -12
- package/dist/cjs/kbz-pay/kbz-pay.js +54 -23
- package/dist/cjs/kbz-pay/signer.d.ts +4 -1
- package/dist/cjs/kbz-pay/signer.js +7 -2
- package/dist/cjs/wave-money/wave-money.d.ts +34 -16
- package/dist/cjs/wave-money/wave-money.js +38 -31
- package/dist/cjs/yoma-mmqr/yoma-mmqr.d.ts +17 -13
- package/dist/cjs/yoma-mmqr/yoma-mmqr.js +21 -19
- package/dist/esm/aya-pay/aya-pay.d.ts +13 -9
- package/dist/esm/aya-pay/aya-pay.js +18 -14
- package/dist/esm/core/amount.d.ts +4 -3
- package/dist/esm/core/amount.js +13 -4
- package/dist/esm/core/callback.d.ts +14 -8
- package/dist/esm/core/callback.js +19 -11
- package/dist/esm/core/crypto.d.ts +6 -1
- package/dist/esm/core/crypto.js +17 -3
- package/dist/esm/core/env.d.ts +21 -5
- package/dist/esm/core/env.js +45 -10
- package/dist/esm/core/errors.d.ts +9 -3
- package/dist/esm/core/errors.js +12 -4
- package/dist/esm/core/http.d.ts +10 -11
- package/dist/esm/core/http.js +6 -8
- package/dist/esm/core/index.d.ts +2 -2
- package/dist/esm/core/index.js +2 -1
- package/dist/esm/core/json.d.ts +2 -2
- package/dist/esm/core/json.js +3 -3
- package/dist/esm/core/status.d.ts +3 -3
- package/dist/esm/core/status.js +2 -2
- package/dist/esm/core/values.d.ts +2 -0
- package/dist/esm/core/values.js +4 -0
- package/dist/esm/cyber-source/cyber-source.d.ts +12 -14
- package/dist/esm/cyber-source/cyber-source.js +41 -28
- package/dist/esm/kbz-pay/kbz-pay.d.ts +36 -12
- package/dist/esm/kbz-pay/kbz-pay.js +56 -25
- package/dist/esm/kbz-pay/signer.d.ts +4 -1
- package/dist/esm/kbz-pay/signer.js +8 -3
- package/dist/esm/wave-money/wave-money.d.ts +34 -16
- package/dist/esm/wave-money/wave-money.js +41 -34
- package/dist/esm/yoma-mmqr/yoma-mmqr.d.ts +17 -13
- package/dist/esm/yoma-mmqr/yoma-mmqr.js +23 -21
- package/package.json +2 -2
- package/skills/node-myanmar-payments/SKILL.md +26 -22
|
@@ -11,38 +11,45 @@ export interface WaveMoneyConfigOptions {
|
|
|
11
11
|
secretKey: string;
|
|
12
12
|
/** Your business name, shown on Wave's payment page. */
|
|
13
13
|
merchantName: string;
|
|
14
|
-
/**
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
14
|
+
/**
|
|
15
|
+
* How long the customer has to pay, in seconds: a whole number greater than 0 (or its text). A
|
|
16
|
+
* payment's own `timeToLiveSeconds` overrides it. Unset or blank is allowed, but then every
|
|
17
|
+
* payment must set its own.
|
|
18
|
+
*/
|
|
19
|
+
timeToLiveSeconds?: number | string | null | undefined;
|
|
20
|
+
/**
|
|
21
|
+
* Seconds before the default HTTP client gives up on a request, a whole number greater than 0
|
|
22
|
+
* (or its text). Defaults to 30 (`DEFAULT_TIMEOUT_SECONDS`) when unset or blank. A
|
|
23
|
+
* client you pass keeps its own timeout.
|
|
24
|
+
*/
|
|
25
|
+
timeoutSeconds?: number | string | null | undefined;
|
|
26
|
+
/** Overrides the API base URL, e.g. to point at UAT. */
|
|
19
27
|
baseUrl?: string | undefined;
|
|
20
28
|
/** Overrides the host the customer is redirected to. Wave serves it without the API port. */
|
|
21
29
|
authenticateUrl?: string | undefined;
|
|
22
30
|
}
|
|
23
31
|
/**
|
|
24
|
-
* Wave Money credentials and endpoints. A missing
|
|
32
|
+
* Wave Money credentials and endpoints. A missing setting throws a `ConfigurationError`. The URLs
|
|
33
|
+
* default to production.
|
|
25
34
|
*/
|
|
26
35
|
export declare class WaveMoneyConfig {
|
|
27
|
-
static readonly SANDBOX_URL = "https://preprodpayments.wavemoney.io:8107";
|
|
28
36
|
static readonly PRODUCTION_URL = "https://payments.wavemoney.io";
|
|
29
|
-
static readonly SANDBOX_AUTHENTICATE_URL = "https://preprodpayments.wavemoney.io";
|
|
30
37
|
static readonly PRODUCTION_AUTHENTICATE_URL = "https://payments.wavemoney.io";
|
|
31
|
-
static readonly DEFAULT_TIME_TO_LIVE_SECONDS = 300;
|
|
32
38
|
readonly merchantId: string;
|
|
33
39
|
readonly secretKey: string;
|
|
34
40
|
readonly merchantName: string;
|
|
35
|
-
/** Seconds the customer has to pay. */
|
|
36
|
-
readonly timeToLiveSeconds: number;
|
|
37
|
-
|
|
41
|
+
/** Seconds the customer has to pay unless the payment sets its own; `undefined` when unset. */
|
|
42
|
+
readonly timeToLiveSeconds: number | undefined;
|
|
43
|
+
/** Seconds before the default HTTP client gives up on a request. */
|
|
44
|
+
readonly timeoutSeconds: number;
|
|
38
45
|
/** The API base URL in use. */
|
|
39
46
|
readonly baseUrl: string;
|
|
40
47
|
/** The host the customer is redirected to. */
|
|
41
48
|
readonly authenticateUrl: string;
|
|
42
49
|
constructor(options: WaveMoneyConfigOptions);
|
|
43
50
|
/**
|
|
44
|
-
* Reads `WAVE_MONEY_MERCHANT_ID`, `WAVE_MONEY_SECRET_KEY`, `WAVE_MONEY_MERCHANT_NAME
|
|
45
|
-
*
|
|
51
|
+
* Reads `WAVE_MONEY_MERCHANT_ID`, `WAVE_MONEY_SECRET_KEY`, `WAVE_MONEY_MERCHANT_NAME`,
|
|
52
|
+
* `WAVE_MONEY_TIME_TO_LIVE_IN_SECONDS`, `MYANMAR_PAYMENTS_HTTP_TIMEOUT`, `WAVE_MONEY_BASE_URL`
|
|
46
53
|
* and `WAVE_MONEY_AUTHENTICATE_URL`.
|
|
47
54
|
*/
|
|
48
55
|
static fromEnv(env?: EnvSource): WaveMoneyConfig;
|
|
@@ -76,6 +83,16 @@ export interface WaveMoneyPaymentData {
|
|
|
76
83
|
* this.
|
|
77
84
|
*/
|
|
78
85
|
merchantReferenceId?: string | undefined;
|
|
86
|
+
/**
|
|
87
|
+
* The business name shown on Wave's page for this payment. Defaults to the configured
|
|
88
|
+
* `merchantName` when unset.
|
|
89
|
+
*/
|
|
90
|
+
merchantName?: string | null | undefined;
|
|
91
|
+
/**
|
|
92
|
+
* How long the customer has to pay this payment, in seconds: a whole number greater than 0.
|
|
93
|
+
* Defaults to the configured `timeToLiveSeconds` when unset; one of the two is required.
|
|
94
|
+
*/
|
|
95
|
+
timeToLiveSeconds?: number | null | undefined;
|
|
79
96
|
}
|
|
80
97
|
/**
|
|
81
98
|
* Wave Money (WavePay payment gateway): redirect payments and callbacks. Wave has no status API,
|
|
@@ -98,13 +115,14 @@ export declare class WaveMoney {
|
|
|
98
115
|
/**
|
|
99
116
|
* Creates a payment request and returns Wave's page to redirect the customer to. Once `data`
|
|
100
117
|
* passes validation it fills `data.merchantReferenceId` with a random id when empty, so store it
|
|
101
|
-
* afterwards. Invalid data is left untouched.
|
|
118
|
+
* afterwards. Invalid data is left untouched. Throws `InvalidPaymentDataError` before any request
|
|
119
|
+
* when neither the payment nor the config sets a time to live.
|
|
102
120
|
*/
|
|
103
121
|
initiate(data: WaveMoneyPaymentData, options?: RequestOptions): Promise<RedirectPayment>;
|
|
104
122
|
/**
|
|
105
123
|
* Verifies Wave's callback. Only `PAYMENT_CONFIRMED` means the customer paid. `orderId` falls
|
|
106
124
|
* back to `merchantReferenceId` when it is missing, null or empty, because Wave marks `orderId` as
|
|
107
|
-
* optional.
|
|
125
|
+
* optional. A hashed field holding an object or array fails verification.
|
|
108
126
|
*/
|
|
109
127
|
handleCallback(request: CallbackRequest): PaymentCallback;
|
|
110
128
|
private hash;
|
|
@@ -1,29 +1,28 @@
|
|
|
1
1
|
import { Amount } from '../core/amount.js';
|
|
2
2
|
import { CallbackRequest, losslessBody, PaymentCallback } from '../core/callback.js';
|
|
3
3
|
import { hmacSha256Hex, queryEscape, randomHex, safeEqual } from '../core/crypto.js';
|
|
4
|
-
import { defaultEnv, envFirst,
|
|
5
|
-
import { ApiError, SignatureVerificationError } from '../core/errors.js';
|
|
4
|
+
import { defaultEnv, envFirst, HTTP_TIMEOUT_VARIABLE, optionalSetting, optionalSeconds, requireSetting, resolveTimeout, trimUrl, } from '../core/env.js';
|
|
5
|
+
import { ApiError, InvalidPaymentDataError, SignatureVerificationError } from '../core/errors.js';
|
|
6
6
|
import { httpClientFrom, Transport, } from '../core/http.js';
|
|
7
7
|
import { isLosslessObject, toPlainObject } from '../core/json.js';
|
|
8
8
|
import { RedirectPayment } from '../core/results.js';
|
|
9
9
|
import { resolveStatus } from '../core/status.js';
|
|
10
10
|
import { toAmount, Validator } from '../core/validate.js';
|
|
11
|
-
import { get, optional, scalarString, trimmed } from '../core/values.js';
|
|
11
|
+
import { get, isNested, optional, scalarString, trimmed } from '../core/values.js';
|
|
12
12
|
/**
|
|
13
|
-
* Wave Money credentials and endpoints. A missing
|
|
13
|
+
* Wave Money credentials and endpoints. A missing setting throws a `ConfigurationError`. The URLs
|
|
14
|
+
* default to production.
|
|
14
15
|
*/
|
|
15
16
|
export class WaveMoneyConfig {
|
|
16
|
-
static SANDBOX_URL = 'https://preprodpayments.wavemoney.io:8107';
|
|
17
17
|
static PRODUCTION_URL = 'https://payments.wavemoney.io';
|
|
18
|
-
static SANDBOX_AUTHENTICATE_URL = 'https://preprodpayments.wavemoney.io';
|
|
19
18
|
static PRODUCTION_AUTHENTICATE_URL = 'https://payments.wavemoney.io';
|
|
20
|
-
static DEFAULT_TIME_TO_LIVE_SECONDS = 300;
|
|
21
19
|
merchantId;
|
|
22
20
|
secretKey;
|
|
23
21
|
merchantName;
|
|
24
|
-
/** Seconds the customer has to pay. */
|
|
22
|
+
/** Seconds the customer has to pay unless the payment sets its own; `undefined` when unset. */
|
|
25
23
|
timeToLiveSeconds;
|
|
26
|
-
|
|
24
|
+
/** Seconds before the default HTTP client gives up on a request. */
|
|
25
|
+
timeoutSeconds;
|
|
27
26
|
/** The API base URL in use. */
|
|
28
27
|
baseUrl;
|
|
29
28
|
/** The host the customer is redirected to. */
|
|
@@ -32,31 +31,23 @@ export class WaveMoneyConfig {
|
|
|
32
31
|
this.merchantId = requireSetting('wave_money', 'merchant_id', options.merchantId);
|
|
33
32
|
this.secretKey = requireSetting('wave_money', 'secret_key', options.secretKey);
|
|
34
33
|
this.merchantName = requireSetting('wave_money', 'merchant_name', options.merchantName);
|
|
35
|
-
|
|
36
|
-
this.
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
: WaveMoneyConfig.DEFAULT_TIME_TO_LIVE_SECONDS;
|
|
40
|
-
this.sandbox = options.sandbox ?? true;
|
|
41
|
-
this.baseUrl = trimUrl(optionalSetting(options.baseUrl) ??
|
|
42
|
-
(this.sandbox ? WaveMoneyConfig.SANDBOX_URL : WaveMoneyConfig.PRODUCTION_URL));
|
|
43
|
-
this.authenticateUrl = trimUrl(optionalSetting(options.authenticateUrl) ??
|
|
44
|
-
(this.sandbox
|
|
45
|
-
? WaveMoneyConfig.SANDBOX_AUTHENTICATE_URL
|
|
46
|
-
: WaveMoneyConfig.PRODUCTION_AUTHENTICATE_URL));
|
|
34
|
+
this.timeToLiveSeconds = optionalSeconds('wave_money', 'time_to_live_in_seconds', options.timeToLiveSeconds);
|
|
35
|
+
this.timeoutSeconds = resolveTimeout('wave_money', options.timeoutSeconds);
|
|
36
|
+
this.baseUrl = trimUrl(optionalSetting(options.baseUrl) ?? WaveMoneyConfig.PRODUCTION_URL);
|
|
37
|
+
this.authenticateUrl = trimUrl(optionalSetting(options.authenticateUrl) ?? WaveMoneyConfig.PRODUCTION_AUTHENTICATE_URL);
|
|
47
38
|
}
|
|
48
39
|
/**
|
|
49
|
-
* Reads `WAVE_MONEY_MERCHANT_ID`, `WAVE_MONEY_SECRET_KEY`, `WAVE_MONEY_MERCHANT_NAME
|
|
50
|
-
*
|
|
40
|
+
* Reads `WAVE_MONEY_MERCHANT_ID`, `WAVE_MONEY_SECRET_KEY`, `WAVE_MONEY_MERCHANT_NAME`,
|
|
41
|
+
* `WAVE_MONEY_TIME_TO_LIVE_IN_SECONDS`, `MYANMAR_PAYMENTS_HTTP_TIMEOUT`, `WAVE_MONEY_BASE_URL`
|
|
51
42
|
* and `WAVE_MONEY_AUTHENTICATE_URL`.
|
|
52
43
|
*/
|
|
53
44
|
static fromEnv(env = defaultEnv()) {
|
|
54
45
|
return new WaveMoneyConfig({
|
|
55
46
|
merchantId: envFirst(env, 'WAVE_MONEY_MERCHANT_ID'),
|
|
56
47
|
secretKey: envFirst(env, 'WAVE_MONEY_SECRET_KEY'),
|
|
57
|
-
merchantName: envFirst(env, 'WAVE_MONEY_MERCHANT_NAME'
|
|
58
|
-
timeToLiveSeconds:
|
|
59
|
-
|
|
48
|
+
merchantName: envFirst(env, 'WAVE_MONEY_MERCHANT_NAME'),
|
|
49
|
+
timeToLiveSeconds: envFirst(env, 'WAVE_MONEY_TIME_TO_LIVE_IN_SECONDS'),
|
|
50
|
+
timeoutSeconds: envFirst(env, HTTP_TIMEOUT_VARIABLE),
|
|
60
51
|
baseUrl: envFirst(env, 'WAVE_MONEY_BASE_URL'),
|
|
61
52
|
authenticateUrl: envFirst(env, 'WAVE_MONEY_AUTHENTICATE_URL'),
|
|
62
53
|
});
|
|
@@ -67,7 +58,7 @@ const STATUSES = {
|
|
|
67
58
|
INSUFFICIENT_BALANCE: 'pending',
|
|
68
59
|
ACCOUNT_LOCKED: 'failed',
|
|
69
60
|
BILL_COLLECTION_FAILED: 'failed',
|
|
70
|
-
PAYMENT_REQUEST_CANCELLED: '
|
|
61
|
+
PAYMENT_REQUEST_CANCELLED: 'canceled',
|
|
71
62
|
TRANSACTION_TIMED_OUT: 'expired',
|
|
72
63
|
SCHEDULER_TRANSACTION_TIMED_OUT: 'expired',
|
|
73
64
|
};
|
|
@@ -94,7 +85,7 @@ export class WaveMoney {
|
|
|
94
85
|
transport;
|
|
95
86
|
constructor(config, options = {}) {
|
|
96
87
|
this.config = config instanceof WaveMoneyConfig ? config : new WaveMoneyConfig(config);
|
|
97
|
-
this.transport = new Transport(httpClientFrom(options));
|
|
88
|
+
this.transport = new Transport(httpClientFrom(options, this.config.timeoutSeconds));
|
|
98
89
|
}
|
|
99
90
|
/** A gateway configured from the `WAVE_MONEY_*` environment variables. */
|
|
100
91
|
static fromEnv(env = defaultEnv(), options = {}) {
|
|
@@ -132,7 +123,11 @@ export class WaveMoney {
|
|
|
132
123
|
.url('returnUrl', data.returnUrl)
|
|
133
124
|
.required('description', data.description)
|
|
134
125
|
.when(items.length === 0, 'items', 'The items field must have at least one item.')
|
|
135
|
-
.string('merchantReferenceId', data.merchantReferenceId)
|
|
126
|
+
.string('merchantReferenceId', data.merchantReferenceId)
|
|
127
|
+
.string('merchantName', data.merchantName)
|
|
128
|
+
.when(typeof data.merchantName === 'string' && data.merchantName.trim() === '', 'merchantName', 'The merchantName field is required.')
|
|
129
|
+
.when(isSet(data.timeToLiveSeconds) && !Number.isInteger(data.timeToLiveSeconds), 'timeToLiveSeconds', 'The timeToLiveSeconds field must be an integer.')
|
|
130
|
+
.when(isSet(data.timeToLiveSeconds) && data.timeToLiveSeconds < 1, 'timeToLiveSeconds', 'The timeToLiveSeconds field must be greater than 0.');
|
|
136
131
|
items.forEach((item, index) => {
|
|
137
132
|
validator
|
|
138
133
|
.required(`items.${index}.name`, item?.name)
|
|
@@ -150,15 +145,22 @@ export class WaveMoney {
|
|
|
150
145
|
/**
|
|
151
146
|
* Creates a payment request and returns Wave's page to redirect the customer to. Once `data`
|
|
152
147
|
* passes validation it fills `data.merchantReferenceId` with a random id when empty, so store it
|
|
153
|
-
* afterwards. Invalid data is left untouched.
|
|
148
|
+
* afterwards. Invalid data is left untouched. Throws `InvalidPaymentDataError` before any request
|
|
149
|
+
* when neither the payment nor the config sets a time to live.
|
|
154
150
|
*/
|
|
155
151
|
async initiate(data, options = {}) {
|
|
156
152
|
WaveMoney.validate(data);
|
|
153
|
+
const timeToLiveSeconds = data.timeToLiveSeconds ?? this.config.timeToLiveSeconds;
|
|
154
|
+
if (timeToLiveSeconds === undefined) {
|
|
155
|
+
throw new InvalidPaymentDataError({
|
|
156
|
+
timeToLiveSeconds: 'The timeToLiveSeconds field is required when the wave_money configuration has no [time_to_live_in_seconds].',
|
|
157
|
+
});
|
|
158
|
+
}
|
|
157
159
|
if (!data.merchantReferenceId) {
|
|
158
160
|
data.merchantReferenceId = randomHex();
|
|
159
161
|
}
|
|
160
162
|
const amount = WaveMoney.resolvedAmount(data).toString();
|
|
161
|
-
const ttl = String(
|
|
163
|
+
const ttl = String(timeToLiveSeconds);
|
|
162
164
|
// Wave documents items as [{"name": "...", "amount": 1000}] with a numeric amount; the amount
|
|
163
165
|
// text is written as a JSON number without passing through a float.
|
|
164
166
|
const items = `[${data.items
|
|
@@ -173,7 +175,7 @@ export class WaveMoney {
|
|
|
173
175
|
backend_result_url: data.callbackUrl,
|
|
174
176
|
amount,
|
|
175
177
|
payment_description: data.description,
|
|
176
|
-
merchant_name: this.config.merchantName,
|
|
178
|
+
merchant_name: data.merchantName ?? this.config.merchantName,
|
|
177
179
|
items,
|
|
178
180
|
hash: this.hash([
|
|
179
181
|
ttl,
|
|
@@ -205,13 +207,15 @@ export class WaveMoney {
|
|
|
205
207
|
/**
|
|
206
208
|
* Verifies Wave's callback. Only `PAYMENT_CONFIRMED` means the customer paid. `orderId` falls
|
|
207
209
|
* back to `merchantReferenceId` when it is missing, null or empty, because Wave marks `orderId` as
|
|
208
|
-
* optional.
|
|
210
|
+
* optional. A hashed field holding an object or array fails verification.
|
|
209
211
|
*/
|
|
210
212
|
handleCallback(request) {
|
|
211
213
|
const payload = losslessBody(request);
|
|
212
214
|
const expected = this.hash(CALLBACK_FIELDS.map((field) => scalarString(payload[field]) ?? 'null'));
|
|
213
215
|
const hashValue = payload.hashValue;
|
|
214
|
-
if (typeof hashValue !== 'string' ||
|
|
216
|
+
if (typeof hashValue !== 'string' ||
|
|
217
|
+
CALLBACK_FIELDS.some((field) => isNested(payload[field])) ||
|
|
218
|
+
!safeEqual(expected, hashValue.toLowerCase())) {
|
|
215
219
|
throw new SignatureVerificationError('Wave Money callback hash verification failed.', toPlainObject(payload));
|
|
216
220
|
}
|
|
217
221
|
const gatewayStatus = trimmed(payload, 'status');
|
|
@@ -228,6 +232,9 @@ export class WaveMoney {
|
|
|
228
232
|
return hmacSha256Hex(this.config.secretKey, parts.join(''));
|
|
229
233
|
}
|
|
230
234
|
}
|
|
235
|
+
function isSet(value) {
|
|
236
|
+
return value !== undefined && value !== null;
|
|
237
|
+
}
|
|
231
238
|
function errorMessage(body) {
|
|
232
239
|
const errors = body.errors;
|
|
233
240
|
if (isLosslessObject(errors)) {
|
|
@@ -16,35 +16,39 @@ export interface YomaMmqrConfigOptions {
|
|
|
16
16
|
webhookHashKey: string;
|
|
17
17
|
/** The secret you shared with Yoma; when set, callbacks must carry it in `X-Webhook-Secret`. */
|
|
18
18
|
webhookSecret?: string | undefined;
|
|
19
|
-
/**
|
|
20
|
-
|
|
21
|
-
/**
|
|
19
|
+
/** The `{version}` segment of the API paths, e.g. `v1rc`. */
|
|
20
|
+
apiVersion: string;
|
|
21
|
+
/**
|
|
22
|
+
* Seconds before the default HTTP client gives up on a request, a whole number greater than 0
|
|
23
|
+
* (or its text). Defaults to 30 (`DEFAULT_TIMEOUT_SECONDS`) when unset or blank. A
|
|
24
|
+
* client you pass keeps its own timeout.
|
|
25
|
+
*/
|
|
26
|
+
timeoutSeconds?: number | string | null | undefined;
|
|
27
|
+
/** Overrides the API base URL, e.g. to point at UAT. */
|
|
22
28
|
baseUrl?: string | undefined;
|
|
23
|
-
/** The `{version}` segment of the API paths (default `v1rc`). */
|
|
24
|
-
apiVersion?: string | undefined;
|
|
25
29
|
}
|
|
26
30
|
/**
|
|
27
|
-
* Yoma MMQR credentials and endpoints. A missing
|
|
31
|
+
* Yoma MMQR credentials and endpoints. A missing setting throws a `ConfigurationError`. The URL
|
|
32
|
+
* defaults to production.
|
|
28
33
|
*/
|
|
29
34
|
export declare class YomaMmqrConfig {
|
|
30
|
-
static readonly SANDBOX_URL = "https://devapi.yomabank.net";
|
|
31
35
|
static readonly PRODUCTION_URL = "https://paymenthubapi.yomabank.com";
|
|
32
|
-
static readonly DEFAULT_API_VERSION = "v1rc";
|
|
33
36
|
readonly merchantId: string;
|
|
34
37
|
readonly clientId: string;
|
|
35
38
|
readonly clientSecret: string;
|
|
36
39
|
readonly webhookHashKey: string;
|
|
37
40
|
readonly webhookSecret: string | undefined;
|
|
38
|
-
|
|
41
|
+
/** The `{version}` segment of the API paths. */
|
|
42
|
+
readonly apiVersion: string;
|
|
43
|
+
/** Seconds before the default HTTP client gives up on a request. */
|
|
44
|
+
readonly timeoutSeconds: number;
|
|
39
45
|
/** The base URL in use. */
|
|
40
46
|
readonly baseUrl: string;
|
|
41
|
-
/** The API version in use. */
|
|
42
|
-
readonly apiVersion: string;
|
|
43
47
|
constructor(options: YomaMmqrConfigOptions);
|
|
44
48
|
/**
|
|
45
49
|
* Reads `YOMA_MMQR_MERCHANT_ID`, `YOMA_MMQR_CLIENT_ID`, `YOMA_MMQR_CLIENT_SECRET`,
|
|
46
|
-
* `YOMA_MMQR_WEBHOOK_HASHKEY`, `
|
|
47
|
-
* `
|
|
50
|
+
* `YOMA_MMQR_WEBHOOK_HASHKEY`, `YOMA_MMQR_API_VERSION`, `MYANMAR_PAYMENTS_HTTP_TIMEOUT`,
|
|
51
|
+
* `YOMA_MMQR_WEBHOOK_SECRET` and `YOMA_MMQR_BASE_URL`.
|
|
48
52
|
*/
|
|
49
53
|
static fromEnv(env?: EnvSource): YomaMmqrConfig;
|
|
50
54
|
}
|
|
@@ -2,46 +2,45 @@ import {} from '../core/amount.js';
|
|
|
2
2
|
import { MemoryTokenCache } from '../core/cache.js';
|
|
3
3
|
import { CallbackRequest, losslessBody, PaymentCallback, PaymentStatusResult, } from '../core/callback.js';
|
|
4
4
|
import { hmacSha256Hex, safeEqual, sha256Hex } from '../core/crypto.js';
|
|
5
|
-
import { defaultEnv, envFirst,
|
|
5
|
+
import { defaultEnv, envFirst, HTTP_TIMEOUT_VARIABLE, optionalSetting, requireSetting, resolveTimeout, trimUrl, } from '../core/env.js';
|
|
6
6
|
import { ApiError, SignatureVerificationError } from '../core/errors.js';
|
|
7
7
|
import { httpClientFrom, Transport, } from '../core/http.js';
|
|
8
8
|
import { toPlainObject } from '../core/json.js';
|
|
9
9
|
import { QrPayment } from '../core/results.js';
|
|
10
10
|
import { resolveStatus } from '../core/status.js';
|
|
11
11
|
import { toAmount, Validator } from '../core/validate.js';
|
|
12
|
-
import { get, optional, trimmed } from '../core/values.js';
|
|
12
|
+
import { get, isNested, optional, trimmed } from '../core/values.js';
|
|
13
13
|
/**
|
|
14
|
-
* Yoma MMQR credentials and endpoints. A missing
|
|
14
|
+
* Yoma MMQR credentials and endpoints. A missing setting throws a `ConfigurationError`. The URL
|
|
15
|
+
* defaults to production.
|
|
15
16
|
*/
|
|
16
17
|
export class YomaMmqrConfig {
|
|
17
|
-
static SANDBOX_URL = 'https://devapi.yomabank.net';
|
|
18
18
|
static PRODUCTION_URL = 'https://paymenthubapi.yomabank.com';
|
|
19
|
-
static DEFAULT_API_VERSION = 'v1rc';
|
|
20
19
|
merchantId;
|
|
21
20
|
clientId;
|
|
22
21
|
clientSecret;
|
|
23
22
|
webhookHashKey;
|
|
24
23
|
webhookSecret;
|
|
25
|
-
|
|
24
|
+
/** The `{version}` segment of the API paths. */
|
|
25
|
+
apiVersion;
|
|
26
|
+
/** Seconds before the default HTTP client gives up on a request. */
|
|
27
|
+
timeoutSeconds;
|
|
26
28
|
/** The base URL in use. */
|
|
27
29
|
baseUrl;
|
|
28
|
-
/** The API version in use. */
|
|
29
|
-
apiVersion;
|
|
30
30
|
constructor(options) {
|
|
31
31
|
this.merchantId = requireSetting('yoma_mmqr', 'merchant_id', options.merchantId);
|
|
32
32
|
this.clientId = requireSetting('yoma_mmqr', 'client_id', options.clientId);
|
|
33
33
|
this.clientSecret = requireSetting('yoma_mmqr', 'client_secret', options.clientSecret);
|
|
34
34
|
this.webhookHashKey = requireSetting('yoma_mmqr', 'webhook_hashkey', options.webhookHashKey);
|
|
35
|
+
this.apiVersion = requireSetting('yoma_mmqr', 'api_version', options.apiVersion);
|
|
36
|
+
this.timeoutSeconds = resolveTimeout('yoma_mmqr', options.timeoutSeconds);
|
|
35
37
|
this.webhookSecret = optionalSetting(options.webhookSecret);
|
|
36
|
-
this.
|
|
37
|
-
this.baseUrl = trimUrl(optionalSetting(options.baseUrl) ??
|
|
38
|
-
(this.sandbox ? YomaMmqrConfig.SANDBOX_URL : YomaMmqrConfig.PRODUCTION_URL));
|
|
39
|
-
this.apiVersion = optionalSetting(options.apiVersion) ?? YomaMmqrConfig.DEFAULT_API_VERSION;
|
|
38
|
+
this.baseUrl = trimUrl(optionalSetting(options.baseUrl) ?? YomaMmqrConfig.PRODUCTION_URL);
|
|
40
39
|
}
|
|
41
40
|
/**
|
|
42
41
|
* Reads `YOMA_MMQR_MERCHANT_ID`, `YOMA_MMQR_CLIENT_ID`, `YOMA_MMQR_CLIENT_SECRET`,
|
|
43
|
-
* `YOMA_MMQR_WEBHOOK_HASHKEY`, `
|
|
44
|
-
* `
|
|
42
|
+
* `YOMA_MMQR_WEBHOOK_HASHKEY`, `YOMA_MMQR_API_VERSION`, `MYANMAR_PAYMENTS_HTTP_TIMEOUT`,
|
|
43
|
+
* `YOMA_MMQR_WEBHOOK_SECRET` and `YOMA_MMQR_BASE_URL`.
|
|
45
44
|
*/
|
|
46
45
|
static fromEnv(env = defaultEnv()) {
|
|
47
46
|
return new YomaMmqrConfig({
|
|
@@ -49,10 +48,10 @@ export class YomaMmqrConfig {
|
|
|
49
48
|
clientId: envFirst(env, 'YOMA_MMQR_CLIENT_ID'),
|
|
50
49
|
clientSecret: envFirst(env, 'YOMA_MMQR_CLIENT_SECRET'),
|
|
51
50
|
webhookHashKey: envFirst(env, 'YOMA_MMQR_WEBHOOK_HASHKEY'),
|
|
51
|
+
apiVersion: envFirst(env, 'YOMA_MMQR_API_VERSION'),
|
|
52
|
+
timeoutSeconds: envFirst(env, HTTP_TIMEOUT_VARIABLE),
|
|
52
53
|
webhookSecret: envFirst(env, 'YOMA_MMQR_WEBHOOK_SECRET'),
|
|
53
|
-
sandbox: envSandbox(env, 'YOMA_MMQR_SANDBOX'),
|
|
54
54
|
baseUrl: envFirst(env, 'YOMA_MMQR_BASE_URL'),
|
|
55
|
-
apiVersion: envFirst(env, 'YOMA_MMQR_API_VERSION'),
|
|
56
55
|
});
|
|
57
56
|
}
|
|
58
57
|
}
|
|
@@ -76,7 +75,7 @@ export class YomaMmqr {
|
|
|
76
75
|
pendingToken;
|
|
77
76
|
constructor(config, options = {}) {
|
|
78
77
|
this.config = config instanceof YomaMmqrConfig ? config : new YomaMmqrConfig(config);
|
|
79
|
-
this.transport = new Transport(httpClientFrom(options));
|
|
78
|
+
this.transport = new Transport(httpClientFrom(options, this.config.timeoutSeconds));
|
|
80
79
|
this.cache = options.tokenCache ?? new MemoryTokenCache();
|
|
81
80
|
}
|
|
82
81
|
/** A gateway configured from the `YOMA_MMQR_*` environment variables. */
|
|
@@ -158,7 +157,9 @@ export class YomaMmqr {
|
|
|
158
157
|
const orderNumber = get(payload, 'orderNumber');
|
|
159
158
|
const status = trimmed(payload, 'status');
|
|
160
159
|
const expected = hmacSha256Hex(orderNumber + this.config.webhookHashKey, `orderNumber=${orderNumber}&status=${status}`);
|
|
161
|
-
if (orderNumber === '' ||
|
|
160
|
+
if (orderNumber === '' ||
|
|
161
|
+
isNested(payload.status) ||
|
|
162
|
+
!safeEqual(expected, get(payload, 'hashValue').toLowerCase())) {
|
|
162
163
|
throw new SignatureVerificationError('Yoma MMQR callback hash verification failed.', toPlainObject(payload));
|
|
163
164
|
}
|
|
164
165
|
return new PaymentCallback({
|
|
@@ -207,13 +208,14 @@ export class YomaMmqr {
|
|
|
207
208
|
if (!response.successful() || token === '') {
|
|
208
209
|
throw apiError('token', response.status, body, get(body, 'error'));
|
|
209
210
|
}
|
|
210
|
-
|
|
211
|
-
const
|
|
211
|
+
// The leading digits of `expires_in`; missing, non-numeric or 0 means an hour.
|
|
212
|
+
const expiresIn = Number(/^\d+/.exec(get(body, 'expires_in'))?.[0] ?? 0);
|
|
213
|
+
const ttl = Math.max(60, (expiresIn > 0 ? expiresIn : 3600) - 60);
|
|
212
214
|
await this.cache.set(this.tokenCacheKey(), token, ttl);
|
|
213
215
|
return token;
|
|
214
216
|
}
|
|
215
217
|
tokenCacheKey() {
|
|
216
|
-
return `
|
|
218
|
+
return `myanmar-payments.yoma-mmqr.token.${sha256Hex(`${this.config.baseUrl}|${this.config.clientId}`)}`;
|
|
217
219
|
}
|
|
218
220
|
}
|
|
219
221
|
function apiError(endpoint, status, body, errorCode) {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@laranex/myanmar-payments",
|
|
3
|
-
"version": "4.0.0-dev.
|
|
4
|
-
"description": "Node.js
|
|
3
|
+
"version": "4.0.0-dev.11",
|
|
4
|
+
"description": "Node.js SDK for Myanmar payment gateways: KBZ Pay, Wave Money, AYA Pay, Yoma MMQR and CyberSource. Typed requests and results, exact amounts, no runtime dependencies.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"laranex",
|
|
7
7
|
"node-myanmar-payments",
|
|
@@ -11,7 +11,7 @@ metadata:
|
|
|
11
11
|
|
|
12
12
|
## When to use
|
|
13
13
|
|
|
14
|
-
Use this skill when a Node.js or TypeScript app takes payments through KBZ Pay, Wave Money, AYA Payment Gateway, Yoma MMQR or CyberSource. Start payments and verify callbacks with the package's typed API; never build gateway signatures by hand.
|
|
14
|
+
Use this skill when a Node.js or TypeScript app takes payments through KBZ Pay, Wave Money, AYA Payment Gateway, Yoma MMQR or CyberSource. Start payments and verify callbacks with the package's typed API; never build gateway signatures by hand. In NestJS, use `@laranex/nestjs-myanmar-payments` instead; it wraps this package.
|
|
15
15
|
|
|
16
16
|
## Install
|
|
17
17
|
|
|
@@ -19,11 +19,11 @@ Use this skill when a Node.js or TypeScript app takes payments through KBZ Pay,
|
|
|
19
19
|
npm install @laranex/myanmar-payments
|
|
20
20
|
```
|
|
21
21
|
|
|
22
|
-
Requires Node.js 20+ and has no runtime dependencies (global `fetch`, `node:crypto`)
|
|
22
|
+
Requires Node.js 20+ and has no runtime dependencies (global `fetch`, `node:crypto`); it ships ESM and CommonJS with types. Import shared types from the root and each gateway from the root or its subpath: `@laranex/myanmar-payments/kbz-pay`, `/wave-money`, `/aya-pay`, `/yoma-mmqr` and `/cyber-source`.
|
|
23
23
|
|
|
24
24
|
## Configure
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
`MyanmarPayments.fromEnv(process.env)` reads `KBZ_PAY_*`, `WAVE_MONEY_*`, `AYA_PAY_*` (or `AYA_PGW_*`), `YOMA_MMQR_*` and `CYBER_SOURCE_*` plus `MYANMAR_PAYMENTS_HTTP_TIMEOUT` (the same variables as the PHP, Go and Python SDKs). Every gateway defaults to its production URLs; to test against UAT, set the URL overrides (`KBZ_PAY_BASE_URL`, `KBZ_PAY_PWA_BASE_REDIRECT_URL`, `WAVE_MONEY_BASE_URL`, `WAVE_MONEY_AUTHENTICATE_URL`, `AYA_PAY_BASE_URL`, `YOMA_MMQR_BASE_URL`, `CYBER_SOURCE_BASE_URL`) to the gateway's UAT URLs. The HTTP timeout `timeoutSeconds` / `MYANMAR_PAYMENTS_HTTP_TIMEOUT` (seconds, for every gateway except CyberSource) is optional and defaults to 30 (`DEFAULT_TIMEOUT_SECONDS`); an explicit value must be a whole number greater than 0. Time to live: `KBZ_PAY_TIME_TO_LIVE_IN_MINUTES` (KBZ's `timeToLiveMinutes`, 1 to 120) and `WAVE_MONEY_TIME_TO_LIVE_IN_SECONDS` (Wave's `timeToLiveSeconds`, greater than 0) are optional defaults; a payment's `timeToLiveMinutes` / `timeToLiveSeconds` overrides them, and a payment with neither throws `InvalidPaymentDataError` before any request. Every other setting is required, with no defaults: credentials, Wave's `merchantName`, and Yoma's `apiVersion` (e.g. `v1rc`).
|
|
27
27
|
|
|
28
28
|
```ts
|
|
29
29
|
import { MyanmarPayments } from '@laranex/myanmar-payments';
|
|
@@ -32,18 +32,21 @@ const payments = MyanmarPayments.fromEnv(process.env); // create once, share acr
|
|
|
32
32
|
const kbz = payments.kbzPay(); // also waveMoney(), ayaPay(), yomaMmqr(), cyberSource()
|
|
33
33
|
```
|
|
34
34
|
|
|
35
|
-
- Or build one gateway: `new KbzPay({ appId, appKey, merchantCode })`, `KbzPay.fromEnv()
|
|
36
|
-
-
|
|
37
|
-
-
|
|
35
|
+
- Or build one gateway: `new KbzPay(new KbzPayConfig({ appId, appKey, merchantCode }))`, `KbzPay.fromEnv()` or `new KbzPay(KbzPayConfig.fromEnv(process.env))`.
|
|
36
|
+
- Or pass the settings directly: gateways and `new MyanmarPayments({ kbzPay: { appId, appKey, merchantCode } })` take config objects or plain option objects; whole-number settings also take text such as `'30'`.
|
|
37
|
+
- Options (second argument): `{ httpClient, fetch }`. Without `httpClient`, the gateway uses `fetch` with the config's `timeoutSeconds`; a client you pass keeps its own timeout. Yoma and the facade also take `tokenCache` (any `TokenCache`, default `MemoryTokenCache`; back it with Redis when you run several processes).
|
|
38
|
+
- A missing setting throws `ConfigurationError` (`gateway`, `key`); a time setting that is not a whole number greater than 0 throws it too.
|
|
38
39
|
|
|
39
40
|
## Use
|
|
40
41
|
|
|
41
42
|
### Amounts
|
|
42
43
|
|
|
43
|
-
Amounts are `Amount.kyat(1000)`, `Amount.parse('1000.50')
|
|
44
|
+
Amounts are `Amount.kyat(1000)`, `Amount.parse('1000.50')` or a whole `number`/`bigint`; never a float such as `10.5`. Only KBZ Pay (up to 2 decimals) and CyberSource accept decimals; Wave, AYA and Yoma take whole kyat. Invalid data throws `InvalidPaymentDataError` with `errors` per field, before any request is sent. Compare a gateway's amount by value with `amount.equals(callback.amount)`.
|
|
44
45
|
|
|
45
46
|
### Start a payment
|
|
46
47
|
|
|
48
|
+
Each gateway takes a data object (`KbzPayPaymentData`, `WaveMoneyPaymentData` with `WaveMoneyItem`s, `AyaPayPaymentData` with an `AyaPayMethod`, `YomaMmqrPaymentData`, `CyberSourcePaymentData` with a `CyberSourceTransactionType`) and returns a typed result:
|
|
49
|
+
|
|
47
50
|
```ts
|
|
48
51
|
import { Amount } from '@laranex/myanmar-payments';
|
|
49
52
|
|
|
@@ -55,20 +58,23 @@ const payment = await kbz.pwa({
|
|
|
55
58
|
res.writeHead(302, { Location: payment.url }).end();
|
|
56
59
|
```
|
|
57
60
|
|
|
58
|
-
- `RedirectPayment` (`url`) from `kbz.pwa(data)` and `wave.initiate(data)`. Wave fills `data.merchantReferenceId` when empty; store it.
|
|
59
|
-
- `FormPayment` from `aya.initiate(data)` and `cyberSource.initiate(data)` (
|
|
61
|
+
- `RedirectPayment` (`url`) from `kbz.pwa(data)` and `wave.initiate(data)`. Wave fills `data.merchantReferenceId` when empty; store it. Pass `merchantName` or `timeToLiveSeconds` in Wave's payment data to override the configured `WAVE_MONEY_MERCHANT_NAME` or `WAVE_MONEY_TIME_TO_LIVE_IN_SECONDS` for that one payment (the merchant name setting stays required).
|
|
62
|
+
- `FormPayment` from `aya.initiate(data)` and `cyberSource.initiate(data)` (no network call): send `payment.toHtml()` as an auto-submitting page, or render `action`, `fields` and `enctype` yourself.
|
|
60
63
|
- `QrPayment` from `kbz.qr(data)` (encode `qrString`) and `yoma.initiate(data)` (`qrImageDataUri()`, `expiresAt`, `reference`). A Yoma QR lives `YomaMmqr.QR_LIFETIME_SECONDS` (120); renew it with `yoma.renewQr(orderId)`.
|
|
61
|
-
- `AppPayment` from `kbz.app(data)`: `res.json(payment)` sends `orderId`, `orderInfo`, `sign
|
|
62
|
-
|
|
64
|
+
- `AppPayment` from `kbz.app(data)`: `res.json(payment)` sends `orderId`, `orderInfo`, `sign` and `signType` to your mobile app.
|
|
65
|
+
|
|
66
|
+
AYA needs a channel: `await aya.services()` lists `AyaPayService` entries (`key`, `supports(method)`), then `aya.initiate({ orderId: 'ORDER123', amount: 1000, channel: 'kbz_pay', method: AyaPayMethod.Qr })`. Every network method takes `{ signal }` as its last argument.
|
|
63
67
|
|
|
64
|
-
|
|
68
|
+
CyberSource has no payment defaults: pass `currency` (e.g. `'MMK'`), `transactionType` (e.g. `CyberSourceTransactionType.Sale`) and `locale` (e.g. `'en-us'`) on every `cyberSource.initiate(data)`.
|
|
65
69
|
|
|
66
70
|
### Handle the callback
|
|
67
71
|
|
|
72
|
+
Build a `CallbackRequest` from the raw request, verify it, then reply:
|
|
73
|
+
|
|
68
74
|
```ts
|
|
69
75
|
import { CallbackRequest, SignatureVerificationError } from '@laranex/myanmar-payments';
|
|
70
76
|
|
|
71
|
-
const request = await CallbackRequest.fromNodeRequest(req); // or fromWebRequest(request)
|
|
77
|
+
const request = await CallbackRequest.fromNodeRequest(req); // or fromWebRequest(request)
|
|
72
78
|
try {
|
|
73
79
|
const callback = kbz.handleCallback(request);
|
|
74
80
|
if (callback.isSuccessful()) {
|
|
@@ -76,22 +82,20 @@ try {
|
|
|
76
82
|
}
|
|
77
83
|
callback.acknowledgement.send(res); // or return callback.acknowledgement.toResponse()
|
|
78
84
|
} catch (error) {
|
|
79
|
-
if (error instanceof SignatureVerificationError)
|
|
80
|
-
|
|
81
|
-
res.end('invalid signature');
|
|
82
|
-
} else throw error;
|
|
85
|
+
if (!(error instanceof SignatureVerificationError)) throw error;
|
|
86
|
+
res.writeHead(400).end('invalid signature');
|
|
83
87
|
}
|
|
84
88
|
```
|
|
85
89
|
|
|
86
|
-
- Give the
|
|
90
|
+
- Give the package the raw body: in Express use `express.raw({ type: '*/*' })` on the callback route; in Fastify keep the body as a string (`parseAs: 'string'`) and build `CallbackRequest.from({ body, headers, query })`.
|
|
87
91
|
- Check AYA's browser return with `aya.verifyRedirect(request)`.
|
|
88
|
-
- For production, store the verified
|
|
92
|
+
- For production, store the verified callback, acknowledge immediately, then process it once in the background.
|
|
89
93
|
|
|
90
94
|
### Check status and handle errors
|
|
91
95
|
|
|
92
96
|
- `kbz.status(orderId)`, `aya.status(orderId)` and `yoma.status(reference)` return `PaymentStatusResult` with `status` and `isSuccessful()`. Wave Money and CyberSource have no status API.
|
|
93
|
-
- Statuses: `PaymentStatus.Successful`, `Pending`, `Failed`, `
|
|
94
|
-
- Gateway failures throw `ApiError` (`gatewayCode`, `gatewayMessage`, `httpStatus`, `raw
|
|
97
|
+
- Statuses: `PaymentStatus.Successful`, `Pending`, `Failed`, `Canceled`, `Expired`, `Unknown`; `PaymentStatus.isFinal(status)`.
|
|
98
|
+
- Gateway failures throw `ApiError` (`gatewayCode`, `gatewayMessage`, `httpStatus`, `raw`; network errors and aborts are the `cause`). All errors extend `PaymentError`.
|
|
95
99
|
|
|
96
100
|
## Test your app
|
|
97
101
|
|
|
@@ -104,6 +108,6 @@ try {
|
|
|
104
108
|
- Fulfilling orders from return pages or query strings; fulfill only from a verified callback or a status check.
|
|
105
109
|
- Treating `pending` or `unknown` as paid.
|
|
106
110
|
- Passing floats or `Number(price)` as amounts; use `Amount.parse()`.
|
|
107
|
-
-
|
|
111
|
+
- Building the `CallbackRequest` from `req.body` parsed by `express.json()` instead of the raw body.
|
|
108
112
|
- Reusing a Wave `merchantReferenceId`, or calling Yoma `initiate()` twice for one order (use `renewQr()`).
|
|
109
113
|
- Opening a KBZ Pay PWA link outside a phone with the KBZ Pay app.
|