@laranex/myanmar-payments 4.0.0-dev.3 → 4.0.0-dev.4
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 +7 -0
- package/dist/cjs/aya-pay/aya-pay.d.ts +5 -2
- package/dist/cjs/aya-pay/aya-pay.js +5 -1
- package/dist/cjs/core/amount.d.ts +4 -3
- package/dist/cjs/core/amount.js +13 -4
- package/dist/cjs/core/callback.d.ts +7 -2
- package/dist/cjs/core/callback.js +14 -7
- package/dist/cjs/core/crypto.d.ts +6 -1
- package/dist/cjs/core/crypto.js +17 -3
- package/dist/cjs/core/env.d.ts +7 -0
- package/dist/cjs/core/env.js +14 -2
- package/dist/cjs/core/json.d.ts +2 -2
- package/dist/cjs/core/json.js +3 -3
- 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 +5 -2
- package/dist/cjs/cyber-source/cyber-source.js +1 -1
- package/dist/cjs/kbz-pay/kbz-pay.d.ts +6 -2
- package/dist/cjs/kbz-pay/kbz-pay.js +1 -1
- 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 +6 -3
- package/dist/cjs/wave-money/wave-money.js +5 -3
- package/dist/cjs/yoma-mmqr/yoma-mmqr.d.ts +5 -2
- package/dist/cjs/yoma-mmqr/yoma-mmqr.js +8 -5
- package/dist/esm/aya-pay/aya-pay.d.ts +5 -2
- package/dist/esm/aya-pay/aya-pay.js +7 -3
- package/dist/esm/core/amount.d.ts +4 -3
- package/dist/esm/core/amount.js +13 -4
- package/dist/esm/core/callback.d.ts +7 -2
- package/dist/esm/core/callback.js +14 -7
- package/dist/esm/core/crypto.d.ts +6 -1
- package/dist/esm/core/crypto.js +17 -3
- package/dist/esm/core/env.d.ts +7 -0
- package/dist/esm/core/env.js +13 -2
- package/dist/esm/core/json.d.ts +2 -2
- package/dist/esm/core/json.js +3 -3
- 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 +5 -2
- package/dist/esm/cyber-source/cyber-source.js +2 -2
- package/dist/esm/kbz-pay/kbz-pay.d.ts +6 -2
- package/dist/esm/kbz-pay/kbz-pay.js +2 -2
- 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 +6 -3
- package/dist/esm/wave-money/wave-money.js +7 -5
- package/dist/esm/yoma-mmqr/yoma-mmqr.d.ts +5 -2
- package/dist/esm/yoma-mmqr/yoma-mmqr.js +10 -7
- package/package.json +1 -1
- package/skills/node-myanmar-payments/SKILL.md +21 -20
|
@@ -42,8 +42,10 @@ export interface WebRequestLike {
|
|
|
42
42
|
* {@link CallbackRequest.fromNodeRequest} or {@link CallbackRequest.fromWebRequest}.
|
|
43
43
|
*/
|
|
44
44
|
export declare class CallbackRequest {
|
|
45
|
-
/** The
|
|
45
|
+
/** The request body as text: a string body as given, bytes decoded as UTF-8. */
|
|
46
46
|
readonly body: string;
|
|
47
|
+
/** The exact bytes received. A string body is encoded as UTF-8. */
|
|
48
|
+
readonly rawBody: Uint8Array;
|
|
47
49
|
/** The request headers, with lowercase names. Repeated headers are joined with `, `. */
|
|
48
50
|
readonly headers: Readonly<Record<string, string>>;
|
|
49
51
|
/** The query string parameters (the first value of each). */
|
|
@@ -71,7 +73,10 @@ export declare class CallbackRequest {
|
|
|
71
73
|
static fromWebRequest(request: WebRequestLike): Promise<CallbackRequest>;
|
|
72
74
|
/** The named header, matched case-insensitively, or `undefined`. */
|
|
73
75
|
header(name: string): string | undefined;
|
|
74
|
-
/**
|
|
76
|
+
/**
|
|
77
|
+
* The body decoded as JSON or as a urlencoded form. JSON numbers keep their exact text, as strings
|
|
78
|
+
* (`1000.50` stays `"1000.50"`).
|
|
79
|
+
*/
|
|
75
80
|
parsedBody(): Record<string, unknown>;
|
|
76
81
|
/** The parsed body merged over the query string. */
|
|
77
82
|
input(): Record<string, unknown>;
|
|
@@ -5,14 +5,17 @@ import { parseJsonObject, setKey, toPlainObject } from './json.js';
|
|
|
5
5
|
* {@link CallbackRequest.fromNodeRequest} or {@link CallbackRequest.fromWebRequest}.
|
|
6
6
|
*/
|
|
7
7
|
export class CallbackRequest {
|
|
8
|
-
/** The
|
|
8
|
+
/** The request body as text: a string body as given, bytes decoded as UTF-8. */
|
|
9
9
|
body;
|
|
10
|
+
/** The exact bytes received. A string body is encoded as UTF-8. */
|
|
11
|
+
rawBody;
|
|
10
12
|
/** The request headers, with lowercase names. Repeated headers are joined with `, `. */
|
|
11
13
|
headers;
|
|
12
14
|
/** The query string parameters (the first value of each). */
|
|
13
15
|
query;
|
|
14
16
|
constructor(init = {}) {
|
|
15
|
-
this.
|
|
17
|
+
this.rawBody = bodyBytes(init.body);
|
|
18
|
+
this.body = typeof init.body === 'string' ? init.body : new TextDecoder().decode(this.rawBody);
|
|
16
19
|
this.headers = Object.freeze(normalizeHeaders(init.headers));
|
|
17
20
|
this.query = Object.freeze(normalizeQuery(init.query));
|
|
18
21
|
}
|
|
@@ -68,7 +71,10 @@ export class CallbackRequest {
|
|
|
68
71
|
header(name) {
|
|
69
72
|
return this.headers[name.toLowerCase()];
|
|
70
73
|
}
|
|
71
|
-
/**
|
|
74
|
+
/**
|
|
75
|
+
* The body decoded as JSON or as a urlencoded form. JSON numbers keep their exact text, as strings
|
|
76
|
+
* (`1000.50` stays `"1000.50"`).
|
|
77
|
+
*/
|
|
72
78
|
parsedBody() {
|
|
73
79
|
return toPlainObject(losslessBody(this));
|
|
74
80
|
}
|
|
@@ -207,14 +213,15 @@ export class PaymentStatusResult {
|
|
|
207
213
|
return this.status === 'successful';
|
|
208
214
|
}
|
|
209
215
|
}
|
|
210
|
-
function
|
|
216
|
+
function bodyBytes(body) {
|
|
211
217
|
if (body === undefined || body === null) {
|
|
212
|
-
return
|
|
218
|
+
return new Uint8Array();
|
|
213
219
|
}
|
|
214
220
|
if (typeof body === 'string') {
|
|
215
|
-
return body;
|
|
221
|
+
return new TextEncoder().encode(body);
|
|
216
222
|
}
|
|
217
|
-
|
|
223
|
+
// A copy, so later changes to the caller's buffer cannot alter what was verified.
|
|
224
|
+
return body instanceof ArrayBuffer ? new Uint8Array(body.slice(0)) : Uint8Array.from(body);
|
|
218
225
|
}
|
|
219
226
|
function rawBody(value) {
|
|
220
227
|
if (typeof value === 'string' || value instanceof Uint8Array || value instanceof ArrayBuffer) {
|
|
@@ -15,5 +15,10 @@ export declare function randomHex(bytes?: number): string;
|
|
|
15
15
|
* @internal
|
|
16
16
|
*/
|
|
17
17
|
export declare function queryEscape(value: string): string;
|
|
18
|
-
/**
|
|
18
|
+
/**
|
|
19
|
+
* Decodes standard base64 (`A-Z a-z 0-9 + /`), either correctly padded or without any padding, to
|
|
20
|
+
* UTF-8 text. Partial padding, other characters and bytes that are not UTF-8 return `undefined`.
|
|
21
|
+
*
|
|
22
|
+
* @internal
|
|
23
|
+
*/
|
|
19
24
|
export declare function decodeBase64(value: string): string | undefined;
|
package/dist/esm/core/crypto.js
CHANGED
|
@@ -32,10 +32,24 @@ export function queryEscape(value) {
|
|
|
32
32
|
.replace(/[!'()*]/g, (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`)
|
|
33
33
|
.replace(/%20/g, '+');
|
|
34
34
|
}
|
|
35
|
-
|
|
35
|
+
const UTF8 = new TextDecoder('utf-8', { fatal: true });
|
|
36
|
+
/**
|
|
37
|
+
* Decodes standard base64 (`A-Z a-z 0-9 + /`), either correctly padded or without any padding, to
|
|
38
|
+
* UTF-8 text. Partial padding, other characters and bytes that are not UTF-8 return `undefined`.
|
|
39
|
+
*
|
|
40
|
+
* @internal
|
|
41
|
+
*/
|
|
36
42
|
export function decodeBase64(value) {
|
|
37
|
-
if (
|
|
43
|
+
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(value)) {
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
if (value.includes('=') ? value.length % 4 !== 0 : value.length % 4 === 1) {
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
try {
|
|
50
|
+
return UTF8.decode(Buffer.from(value, 'base64'));
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
38
53
|
return undefined;
|
|
39
54
|
}
|
|
40
|
-
return Buffer.from(value, 'base64').toString('utf8');
|
|
41
55
|
}
|
package/dist/esm/core/env.d.ts
CHANGED
|
@@ -9,6 +9,13 @@ export declare function envFirst(env: EnvSource, ...keys: string[]): string;
|
|
|
9
9
|
* @internal
|
|
10
10
|
*/
|
|
11
11
|
export declare function envSandbox(env: EnvSource, key: string): boolean;
|
|
12
|
+
/**
|
|
13
|
+
* A `sandbox` setting: booleans as is, text parsed like a `*_SANDBOX` variable (`false`, `0`, `f`,
|
|
14
|
+
* `no` and `off` in any case select production; anything else means sandbox), unset means sandbox.
|
|
15
|
+
*
|
|
16
|
+
* @internal
|
|
17
|
+
*/
|
|
18
|
+
export declare function parseSandbox(value: boolean | string | undefined | null): boolean;
|
|
12
19
|
/** An integer variable, or `undefined` when unset or not an integer. @internal */
|
|
13
20
|
export declare function envInt(env: EnvSource, key: string): number | undefined;
|
|
14
21
|
/** The trimmed value, or throws a {@link ConfigurationError} naming `key`. @internal */
|
package/dist/esm/core/env.js
CHANGED
|
@@ -16,8 +16,19 @@ export function envFirst(env, ...keys) {
|
|
|
16
16
|
* @internal
|
|
17
17
|
*/
|
|
18
18
|
export function envSandbox(env, key) {
|
|
19
|
-
|
|
20
|
-
|
|
19
|
+
return parseSandbox(env[key] ?? '');
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* A `sandbox` setting: booleans as is, text parsed like a `*_SANDBOX` variable (`false`, `0`, `f`,
|
|
23
|
+
* `no` and `off` in any case select production; anything else means sandbox), unset means sandbox.
|
|
24
|
+
*
|
|
25
|
+
* @internal
|
|
26
|
+
*/
|
|
27
|
+
export function parseSandbox(value) {
|
|
28
|
+
if (typeof value === 'boolean') {
|
|
29
|
+
return value;
|
|
30
|
+
}
|
|
31
|
+
return !['false', '0', 'f', 'no', 'off'].includes((value ?? '').trim().toLowerCase());
|
|
21
32
|
}
|
|
22
33
|
/** An integer variable, or `undefined` when unset or not an integer. @internal */
|
|
23
34
|
export function envInt(env, key) {
|
package/dist/esm/core/json.d.ts
CHANGED
|
@@ -32,8 +32,8 @@ export declare function parseJsonObject(text: string): LosslessObject | undefine
|
|
|
32
32
|
/** @internal */
|
|
33
33
|
export declare function isLosslessObject(value: unknown): value is LosslessObject;
|
|
34
34
|
/**
|
|
35
|
-
* Converts a lossless value to plain JavaScript values
|
|
36
|
-
*
|
|
35
|
+
* Converts a lossless value to plain JavaScript values for the `raw` fields of results. Numbers
|
|
36
|
+
* become their exact text as a string (`1000.50` stays `"1000.50"`), so no precision is lost.
|
|
37
37
|
*
|
|
38
38
|
* @internal
|
|
39
39
|
*/
|
package/dist/esm/core/json.js
CHANGED
|
@@ -46,14 +46,14 @@ export function isLosslessObject(value) {
|
|
|
46
46
|
!(value instanceof JsonNumber));
|
|
47
47
|
}
|
|
48
48
|
/**
|
|
49
|
-
* Converts a lossless value to plain JavaScript values
|
|
50
|
-
*
|
|
49
|
+
* Converts a lossless value to plain JavaScript values for the `raw` fields of results. Numbers
|
|
50
|
+
* become their exact text as a string (`1000.50` stays `"1000.50"`), so no precision is lost.
|
|
51
51
|
*
|
|
52
52
|
* @internal
|
|
53
53
|
*/
|
|
54
54
|
export function toPlain(value) {
|
|
55
55
|
if (value instanceof JsonNumber) {
|
|
56
|
-
return
|
|
56
|
+
return value.text;
|
|
57
57
|
}
|
|
58
58
|
if (Array.isArray(value)) {
|
|
59
59
|
return value.map(toPlain);
|
|
@@ -14,3 +14,5 @@ export declare function optional(source: Record<string, unknown>, key: string):
|
|
|
14
14
|
export declare function trimmed(source: Record<string, unknown>, key: string): string;
|
|
15
15
|
/** The value at `key` when it is an object (not an array). @internal */
|
|
16
16
|
export declare function object(source: Record<string, unknown>, key: string): LosslessObject | undefined;
|
|
17
|
+
/** Whether `value` is an object or array, which a gateway never signs. @internal */
|
|
18
|
+
export declare function isNested(value: unknown): boolean;
|
package/dist/esm/core/values.js
CHANGED
|
@@ -36,3 +36,7 @@ export function object(source, key) {
|
|
|
36
36
|
const value = source[key];
|
|
37
37
|
return isLosslessObject(value) ? value : undefined;
|
|
38
38
|
}
|
|
39
|
+
/** Whether `value` is an object or array, which a gateway never signs. @internal */
|
|
40
|
+
export function isNested(value) {
|
|
41
|
+
return typeof value === 'object' && value !== null && !(value instanceof JsonNumber);
|
|
42
|
+
}
|
|
@@ -10,8 +10,11 @@ export interface CyberSourceConfigOptions {
|
|
|
10
10
|
accessKey: string;
|
|
11
11
|
/** The profile's secret key, which signs the fields. */
|
|
12
12
|
secretKey: string;
|
|
13
|
-
/**
|
|
14
|
-
|
|
13
|
+
/**
|
|
14
|
+
* Use the test environment (default `true`).
|
|
15
|
+
* Text is read like a `*_SANDBOX` variable: `false`, `0`, `f`, `no` or `off` select production.
|
|
16
|
+
*/
|
|
17
|
+
sandbox?: boolean | string | undefined;
|
|
15
18
|
/** Overrides the Secure Acceptance base URL. */
|
|
16
19
|
baseUrl?: string | undefined;
|
|
17
20
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import {} from '../core/amount.js';
|
|
2
2
|
import { CallbackRequest, losslessInput, PaymentCallback } from '../core/callback.js';
|
|
3
3
|
import { hmacSha256Base64, randomHex, safeEqual } from '../core/crypto.js';
|
|
4
|
-
import { defaultEnv, envFirst, envSandbox, optionalSetting, requireSetting, trimUrl, } from '../core/env.js';
|
|
4
|
+
import { defaultEnv, envFirst, envSandbox, parseSandbox, optionalSetting, requireSetting, trimUrl, } from '../core/env.js';
|
|
5
5
|
import { SignatureVerificationError } from '../core/errors.js';
|
|
6
6
|
import { setKey, toPlainObject } from '../core/json.js';
|
|
7
7
|
import { FormPayment } from '../core/results.js';
|
|
@@ -24,7 +24,7 @@ export class CyberSourceConfig {
|
|
|
24
24
|
this.profileId = requireSetting('cyber_source', 'profile_id', options.profileId);
|
|
25
25
|
this.accessKey = requireSetting('cyber_source', 'access_key', options.accessKey);
|
|
26
26
|
this.secretKey = requireSetting('cyber_source', 'secret_key', options.secretKey);
|
|
27
|
-
this.sandbox = options.sandbox
|
|
27
|
+
this.sandbox = parseSandbox(options.sandbox);
|
|
28
28
|
this.baseUrl = trimUrl(optionalSetting(options.baseUrl) ??
|
|
29
29
|
(this.sandbox ? CyberSourceConfig.SANDBOX_URL : CyberSourceConfig.PRODUCTION_URL));
|
|
30
30
|
}
|
|
@@ -12,8 +12,12 @@ export interface KbzPayConfigOptions {
|
|
|
12
12
|
appKey: string;
|
|
13
13
|
/** The `merch_code` KBZ issued. */
|
|
14
14
|
merchantCode: string;
|
|
15
|
-
/**
|
|
16
|
-
|
|
15
|
+
/**
|
|
16
|
+
* Use the UAT endpoints (default `true`). UAT and production issue separate
|
|
17
|
+
* credentials. Text is read like a `*_SANDBOX` variable: `false`, `0`, `f`, `no` or `off`
|
|
18
|
+
* select production.
|
|
19
|
+
*/
|
|
20
|
+
sandbox?: boolean | string | undefined;
|
|
17
21
|
/** Overrides the API base URL, e.g. to go through a proxy. */
|
|
18
22
|
apiUrl?: string | undefined;
|
|
19
23
|
/** Overrides the PWA checkout URL. A trailing `#` or `#/` is normalized to `#/`. */
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import {} from '../core/amount.js';
|
|
2
2
|
import { Acknowledgement, CallbackRequest, losslessBody, PaymentCallback, PaymentStatusResult, } from '../core/callback.js';
|
|
3
3
|
import { queryEscape, randomHex } from '../core/crypto.js';
|
|
4
|
-
import { defaultEnv, envFirst, envSandbox, optionalSetting, requireSetting, trimUrl, } from '../core/env.js';
|
|
4
|
+
import { defaultEnv, envFirst, envSandbox, parseSandbox, optionalSetting, requireSetting, trimUrl, } from '../core/env.js';
|
|
5
5
|
import { ApiError, SignatureVerificationError } from '../core/errors.js';
|
|
6
6
|
import { httpClientFrom, Transport, } from '../core/http.js';
|
|
7
7
|
import { toPlainObject } from '../core/json.js';
|
|
@@ -30,7 +30,7 @@ export class KbzPayConfig {
|
|
|
30
30
|
this.appId = requireSetting('kbz_pay', 'app_id', options.appId);
|
|
31
31
|
this.appKey = requireSetting('kbz_pay', 'app_key', options.appKey);
|
|
32
32
|
this.merchantCode = requireSetting('kbz_pay', 'merchant_code', options.merchantCode);
|
|
33
|
-
this.sandbox = options.sandbox
|
|
33
|
+
this.sandbox = parseSandbox(options.sandbox);
|
|
34
34
|
this.apiUrl = trimUrl(optionalSetting(options.apiUrl) ??
|
|
35
35
|
(this.sandbox ? KbzPayConfig.SANDBOX_API_URL : KbzPayConfig.PRODUCTION_API_URL));
|
|
36
36
|
this.pwaUrl = `${trimUrl(optionalSetting(options.pwaUrl) ??
|
|
@@ -10,6 +10,9 @@ export declare class KbzPaySigner {
|
|
|
10
10
|
signString(fields: Readonly<Record<string, unknown>>): string;
|
|
11
11
|
/** The uppercase SHA-256 signature of `fields`. */
|
|
12
12
|
sign(fields: Readonly<Record<string, unknown>>): string;
|
|
13
|
-
/**
|
|
13
|
+
/**
|
|
14
|
+
* Whether `fields.sign` matches, compared in constant time. Nested values are never signed, so
|
|
15
|
+
* fields holding an object or array fail rather than being partly trusted.
|
|
16
|
+
*/
|
|
14
17
|
verify(fields: Readonly<Record<string, unknown>>): boolean;
|
|
15
18
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { safeEqual, sha256Hex } from '../core/crypto.js';
|
|
2
|
-
import { scalarString } from '../core/values.js';
|
|
2
|
+
import { isNested, scalarString } from '../core/values.js';
|
|
3
3
|
/**
|
|
4
4
|
* KBZ Pay's signature: every non-empty scalar field except `sign` and `sign_type`, sorted by key,
|
|
5
5
|
* joined as raw `key=value` pairs (not URL-encoded), with `&key=<app key>` appended, hashed with
|
|
@@ -24,9 +24,14 @@ export class KbzPaySigner {
|
|
|
24
24
|
sign(fields) {
|
|
25
25
|
return sha256Hex(`${this.signString(fields)}&key=${this.appKey}`).toUpperCase();
|
|
26
26
|
}
|
|
27
|
-
/**
|
|
27
|
+
/**
|
|
28
|
+
* Whether `fields.sign` matches, compared in constant time. Nested values are never signed, so
|
|
29
|
+
* fields holding an object or array fail rather than being partly trusted.
|
|
30
|
+
*/
|
|
28
31
|
verify(fields) {
|
|
29
32
|
const sign = fields.sign;
|
|
30
|
-
return typeof sign === 'string' &&
|
|
33
|
+
return (typeof sign === 'string' &&
|
|
34
|
+
!Object.values(fields).some(isNested) &&
|
|
35
|
+
safeEqual(this.sign(fields), sign.toUpperCase()));
|
|
31
36
|
}
|
|
32
37
|
}
|
|
@@ -13,8 +13,11 @@ export interface WaveMoneyConfigOptions {
|
|
|
13
13
|
merchantName: string;
|
|
14
14
|
/** How long the customer has to pay, in seconds (default 300). */
|
|
15
15
|
timeToLiveSeconds?: number | undefined;
|
|
16
|
-
/**
|
|
17
|
-
|
|
16
|
+
/**
|
|
17
|
+
* Use the test environment (default `true`).
|
|
18
|
+
* Text is read like a `*_SANDBOX` variable: `false`, `0`, `f`, `no` or `off` select production.
|
|
19
|
+
*/
|
|
20
|
+
sandbox?: boolean | string | undefined;
|
|
18
21
|
/** Overrides the API base URL. */
|
|
19
22
|
baseUrl?: string | undefined;
|
|
20
23
|
/** Overrides the host the customer is redirected to. Wave serves it without the API port. */
|
|
@@ -104,7 +107,7 @@ export declare class WaveMoney {
|
|
|
104
107
|
/**
|
|
105
108
|
* Verifies Wave's callback. Only `PAYMENT_CONFIRMED` means the customer paid. `orderId` falls
|
|
106
109
|
* back to `merchantReferenceId` when it is missing, null or empty, because Wave marks `orderId` as
|
|
107
|
-
* optional.
|
|
110
|
+
* optional. A hashed field holding an object or array fails verification.
|
|
108
111
|
*/
|
|
109
112
|
handleCallback(request: CallbackRequest): PaymentCallback;
|
|
110
113
|
private hash;
|
|
@@ -1,14 +1,14 @@
|
|
|
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, envInt, envSandbox, optionalSetting, requireSetting, trimUrl, } from '../core/env.js';
|
|
4
|
+
import { defaultEnv, envFirst, envInt, envSandbox, parseSandbox, optionalSetting, requireSetting, trimUrl, } from '../core/env.js';
|
|
5
5
|
import { ApiError, 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
13
|
* Wave Money credentials and endpoints. A missing credential throws a `ConfigurationError`.
|
|
14
14
|
*/
|
|
@@ -37,7 +37,7 @@ export class WaveMoneyConfig {
|
|
|
37
37
|
ttl !== undefined && Number.isInteger(ttl) && ttl > 0
|
|
38
38
|
? ttl
|
|
39
39
|
: WaveMoneyConfig.DEFAULT_TIME_TO_LIVE_SECONDS;
|
|
40
|
-
this.sandbox = options.sandbox
|
|
40
|
+
this.sandbox = parseSandbox(options.sandbox);
|
|
41
41
|
this.baseUrl = trimUrl(optionalSetting(options.baseUrl) ??
|
|
42
42
|
(this.sandbox ? WaveMoneyConfig.SANDBOX_URL : WaveMoneyConfig.PRODUCTION_URL));
|
|
43
43
|
this.authenticateUrl = trimUrl(optionalSetting(options.authenticateUrl) ??
|
|
@@ -205,13 +205,15 @@ export class WaveMoney {
|
|
|
205
205
|
/**
|
|
206
206
|
* Verifies Wave's callback. Only `PAYMENT_CONFIRMED` means the customer paid. `orderId` falls
|
|
207
207
|
* back to `merchantReferenceId` when it is missing, null or empty, because Wave marks `orderId` as
|
|
208
|
-
* optional.
|
|
208
|
+
* optional. A hashed field holding an object or array fails verification.
|
|
209
209
|
*/
|
|
210
210
|
handleCallback(request) {
|
|
211
211
|
const payload = losslessBody(request);
|
|
212
212
|
const expected = this.hash(CALLBACK_FIELDS.map((field) => scalarString(payload[field]) ?? 'null'));
|
|
213
213
|
const hashValue = payload.hashValue;
|
|
214
|
-
if (typeof hashValue !== 'string' ||
|
|
214
|
+
if (typeof hashValue !== 'string' ||
|
|
215
|
+
CALLBACK_FIELDS.some((field) => isNested(payload[field])) ||
|
|
216
|
+
!safeEqual(expected, hashValue.toLowerCase())) {
|
|
215
217
|
throw new SignatureVerificationError('Wave Money callback hash verification failed.', toPlainObject(payload));
|
|
216
218
|
}
|
|
217
219
|
const gatewayStatus = trimmed(payload, 'status');
|
|
@@ -16,8 +16,11 @@ 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
|
-
|
|
19
|
+
/**
|
|
20
|
+
* Use the UAT payment hub (default `true`).
|
|
21
|
+
* Text is read like a `*_SANDBOX` variable: `false`, `0`, `f`, `no` or `off` select production.
|
|
22
|
+
*/
|
|
23
|
+
sandbox?: boolean | string | undefined;
|
|
21
24
|
/** Overrides the API base URL. */
|
|
22
25
|
baseUrl?: string | undefined;
|
|
23
26
|
/** The `{version}` segment of the API paths (default `v1rc`). */
|
|
@@ -2,14 +2,14 @@ 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, envSandbox, optionalSetting, requireSetting, trimUrl, } from '../core/env.js';
|
|
5
|
+
import { defaultEnv, envFirst, envSandbox, parseSandbox, optionalSetting, requireSetting, 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
14
|
* Yoma MMQR credentials and endpoints. A missing credential throws a `ConfigurationError`.
|
|
15
15
|
*/
|
|
@@ -33,7 +33,7 @@ export class YomaMmqrConfig {
|
|
|
33
33
|
this.clientSecret = requireSetting('yoma_mmqr', 'client_secret', options.clientSecret);
|
|
34
34
|
this.webhookHashKey = requireSetting('yoma_mmqr', 'webhook_hashkey', options.webhookHashKey);
|
|
35
35
|
this.webhookSecret = optionalSetting(options.webhookSecret);
|
|
36
|
-
this.sandbox = options.sandbox
|
|
36
|
+
this.sandbox = parseSandbox(options.sandbox);
|
|
37
37
|
this.baseUrl = trimUrl(optionalSetting(options.baseUrl) ??
|
|
38
38
|
(this.sandbox ? YomaMmqrConfig.SANDBOX_URL : YomaMmqrConfig.PRODUCTION_URL));
|
|
39
39
|
this.apiVersion = optionalSetting(options.apiVersion) ?? YomaMmqrConfig.DEFAULT_API_VERSION;
|
|
@@ -158,7 +158,9 @@ export class YomaMmqr {
|
|
|
158
158
|
const orderNumber = get(payload, 'orderNumber');
|
|
159
159
|
const status = trimmed(payload, 'status');
|
|
160
160
|
const expected = hmacSha256Hex(orderNumber + this.config.webhookHashKey, `orderNumber=${orderNumber}&status=${status}`);
|
|
161
|
-
if (orderNumber === '' ||
|
|
161
|
+
if (orderNumber === '' ||
|
|
162
|
+
isNested(payload.status) ||
|
|
163
|
+
!safeEqual(expected, get(payload, 'hashValue').toLowerCase())) {
|
|
162
164
|
throw new SignatureVerificationError('Yoma MMQR callback hash verification failed.', toPlainObject(payload));
|
|
163
165
|
}
|
|
164
166
|
return new PaymentCallback({
|
|
@@ -207,13 +209,14 @@ export class YomaMmqr {
|
|
|
207
209
|
if (!response.successful() || token === '') {
|
|
208
210
|
throw apiError('token', response.status, body, get(body, 'error'));
|
|
209
211
|
}
|
|
210
|
-
|
|
211
|
-
const
|
|
212
|
+
// The leading digits of `expires_in`; missing, non-numeric or 0 means an hour.
|
|
213
|
+
const expiresIn = Number(/^\d+/.exec(get(body, 'expires_in'))?.[0] ?? 0);
|
|
214
|
+
const ttl = Math.max(60, (expiresIn > 0 ? expiresIn : 3600) - 60);
|
|
212
215
|
await this.cache.set(this.tokenCacheKey(), token, ttl);
|
|
213
216
|
return token;
|
|
214
217
|
}
|
|
215
218
|
tokenCacheKey() {
|
|
216
|
-
return `
|
|
219
|
+
return `myanmar-payments.yoma-mmqr.token.${sha256Hex(`${this.config.baseUrl}|${this.config.clientId}`)}`;
|
|
217
220
|
}
|
|
218
221
|
}
|
|
219
222
|
function apiError(endpoint, status, body, errorCode) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@laranex/myanmar-payments",
|
|
3
|
-
"version": "4.0.0-dev.
|
|
3
|
+
"version": "4.0.0-dev.4",
|
|
4
4
|
"description": "Node.js SDK for Myanmar payment gateways: KBZ Pay, Wave Money, AYA Pay, Yoma MMQR and CyberSource. Typed TypeScript API, ESM and CommonJS, no runtime dependencies.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"laranex",
|
|
@@ -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_*` (the same variables as the PHP, Go and Python SDKs). `sandbox` defaults to `true`; set `*_SANDBOX=false` (or `sandbox: false`) in production.
|
|
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
|
-
-
|
|
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; a string `sandbox` such as `'false'` is read like the variable.
|
|
37
|
+
- Options (second argument): `{ httpClient, fetch, timeoutMs }` (default 30 seconds). Yoma and the facade also take `tokenCache` (any `TokenCache`, default `MemoryTokenCache`; back it with Redis when you run several processes).
|
|
37
38
|
- A missing credential throws `ConfigurationError` (`gateway`, `key`).
|
|
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
|
|
|
@@ -56,19 +59,20 @@ res.writeHead(302, { Location: payment.url }).end();
|
|
|
56
59
|
```
|
|
57
60
|
|
|
58
61
|
- `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)` (
|
|
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
|
-
- Every network method takes `{ signal }` as its last argument.
|
|
64
|
+
- `AppPayment` from `kbz.app(data)`: `res.json(payment)` sends `orderId`, `orderInfo`, `sign` and `signType` to your mobile app.
|
|
63
65
|
|
|
64
|
-
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 })`.
|
|
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.
|
|
65
67
|
|
|
66
68
|
### Handle the callback
|
|
67
69
|
|
|
70
|
+
Build a `CallbackRequest` from the raw request, verify it, then reply:
|
|
71
|
+
|
|
68
72
|
```ts
|
|
69
73
|
import { CallbackRequest, SignatureVerificationError } from '@laranex/myanmar-payments';
|
|
70
74
|
|
|
71
|
-
const request = await CallbackRequest.fromNodeRequest(req); // or fromWebRequest(request)
|
|
75
|
+
const request = await CallbackRequest.fromNodeRequest(req); // or fromWebRequest(request)
|
|
72
76
|
try {
|
|
73
77
|
const callback = kbz.handleCallback(request);
|
|
74
78
|
if (callback.isSuccessful()) {
|
|
@@ -76,23 +80,20 @@ try {
|
|
|
76
80
|
}
|
|
77
81
|
callback.acknowledgement.send(res); // or return callback.acknowledgement.toResponse()
|
|
78
82
|
} catch (error) {
|
|
79
|
-
if (error instanceof SignatureVerificationError)
|
|
80
|
-
|
|
81
|
-
res.end('invalid signature');
|
|
82
|
-
} else throw error;
|
|
83
|
+
if (!(error instanceof SignatureVerificationError)) throw error;
|
|
84
|
+
res.writeHead(400).end('invalid signature');
|
|
83
85
|
}
|
|
84
86
|
```
|
|
85
87
|
|
|
86
|
-
- Give the
|
|
87
|
-
- Fastify consumes the body before your handler: keep it as a string with a content-type parser (`parseAs: 'string'`) and build `CallbackRequest.from({ body, headers: request.headers, query: request.query })`.
|
|
88
|
+
- 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 })`.
|
|
88
89
|
- Check AYA's browser return with `aya.verifyRedirect(request)`.
|
|
89
|
-
- For production, store the verified
|
|
90
|
+
- For production, store the verified callback, acknowledge immediately, then process it once in the background.
|
|
90
91
|
|
|
91
92
|
### Check status and handle errors
|
|
92
93
|
|
|
93
94
|
- `kbz.status(orderId)`, `aya.status(orderId)` and `yoma.status(reference)` return `PaymentStatusResult` with `status` and `isSuccessful()`. Wave Money and CyberSource have no status API.
|
|
94
95
|
- Statuses: `PaymentStatus.Successful`, `Pending`, `Failed`, `Canceled`, `Expired`, `Unknown`; `PaymentStatus.isFinal(status)`.
|
|
95
|
-
- Gateway failures throw `ApiError` (`gatewayCode`, `gatewayMessage`, `httpStatus`, `raw
|
|
96
|
+
- Gateway failures throw `ApiError` (`gatewayCode`, `gatewayMessage`, `httpStatus`, `raw`; network errors and aborts are the `cause`). All errors extend `PaymentError`.
|
|
96
97
|
|
|
97
98
|
## Test your app
|
|
98
99
|
|
|
@@ -105,6 +106,6 @@ try {
|
|
|
105
106
|
- Fulfilling orders from return pages or query strings; fulfill only from a verified callback or a status check.
|
|
106
107
|
- Treating `pending` or `unknown` as paid.
|
|
107
108
|
- Passing floats or `Number(price)` as amounts; use `Amount.parse()`.
|
|
108
|
-
-
|
|
109
|
+
- Building the `CallbackRequest` from `req.body` parsed by `express.json()` instead of the raw body.
|
|
109
110
|
- Reusing a Wave `merchantReferenceId`, or calling Yoma `initiate()` twice for one order (use `renewQr()`).
|
|
110
111
|
- Opening a KBZ Pay PWA link outside a phone with the KBZ Pay app.
|