@laranex/myanmar-payments 4.0.0-dev.2 → 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 +13 -1
- package/README.md +1 -1
- package/dist/cjs/aya-pay/aya-pay.d.ts +5 -2
- package/dist/cjs/aya-pay/aya-pay.js +7 -2
- 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 +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/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 +7 -3
- package/dist/cjs/cyber-source/cyber-source.js +27 -13
- package/dist/cjs/kbz-pay/kbz-pay.d.ts +6 -2
- package/dist/cjs/kbz-pay/kbz-pay.js +2 -2
- 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 +6 -4
- 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 +9 -4
- 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 +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/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 +7 -3
- package/dist/esm/cyber-source/cyber-source.js +29 -15
- package/dist/esm/kbz-pay/kbz-pay.d.ts +6 -2
- package/dist/esm/kbz-pay/kbz-pay.js +3 -3
- 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 +8 -6
- 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 +22 -20
|
@@ -40,7 +40,7 @@ class WaveMoneyConfig {
|
|
|
40
40
|
ttl !== undefined && Number.isInteger(ttl) && ttl > 0
|
|
41
41
|
? ttl
|
|
42
42
|
: WaveMoneyConfig.DEFAULT_TIME_TO_LIVE_SECONDS;
|
|
43
|
-
this.sandbox = options.sandbox
|
|
43
|
+
this.sandbox = (0, env_js_1.parseSandbox)(options.sandbox);
|
|
44
44
|
this.baseUrl = (0, env_js_1.trimUrl)((0, env_js_1.optionalSetting)(options.baseUrl) ??
|
|
45
45
|
(this.sandbox ? WaveMoneyConfig.SANDBOX_URL : WaveMoneyConfig.PRODUCTION_URL));
|
|
46
46
|
this.authenticateUrl = (0, env_js_1.trimUrl)((0, env_js_1.optionalSetting)(options.authenticateUrl) ??
|
|
@@ -71,7 +71,7 @@ const STATUSES = {
|
|
|
71
71
|
INSUFFICIENT_BALANCE: 'pending',
|
|
72
72
|
ACCOUNT_LOCKED: 'failed',
|
|
73
73
|
BILL_COLLECTION_FAILED: 'failed',
|
|
74
|
-
PAYMENT_REQUEST_CANCELLED: '
|
|
74
|
+
PAYMENT_REQUEST_CANCELLED: 'canceled',
|
|
75
75
|
TRANSACTION_TIMED_OUT: 'expired',
|
|
76
76
|
SCHEDULER_TRANSACTION_TIMED_OUT: 'expired',
|
|
77
77
|
};
|
|
@@ -209,13 +209,15 @@ class WaveMoney {
|
|
|
209
209
|
/**
|
|
210
210
|
* Verifies Wave's callback. Only `PAYMENT_CONFIRMED` means the customer paid. `orderId` falls
|
|
211
211
|
* back to `merchantReferenceId` when it is missing, null or empty, because Wave marks `orderId` as
|
|
212
|
-
* optional.
|
|
212
|
+
* optional. A hashed field holding an object or array fails verification.
|
|
213
213
|
*/
|
|
214
214
|
handleCallback(request) {
|
|
215
215
|
const payload = (0, callback_js_1.losslessBody)(request);
|
|
216
216
|
const expected = this.hash(CALLBACK_FIELDS.map((field) => (0, values_js_1.scalarString)(payload[field]) ?? 'null'));
|
|
217
217
|
const hashValue = payload.hashValue;
|
|
218
|
-
if (typeof hashValue !== 'string' ||
|
|
218
|
+
if (typeof hashValue !== 'string' ||
|
|
219
|
+
CALLBACK_FIELDS.some((field) => (0, values_js_1.isNested)(payload[field])) ||
|
|
220
|
+
!(0, crypto_js_1.safeEqual)(expected, hashValue.toLowerCase())) {
|
|
219
221
|
throw new errors_js_1.SignatureVerificationError('Wave Money callback hash verification failed.', (0, json_js_1.toPlainObject)(payload));
|
|
220
222
|
}
|
|
221
223
|
const gatewayStatus = (0, values_js_1.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`). */
|
|
@@ -35,7 +35,7 @@ class YomaMmqrConfig {
|
|
|
35
35
|
this.clientSecret = (0, env_js_1.requireSetting)('yoma_mmqr', 'client_secret', options.clientSecret);
|
|
36
36
|
this.webhookHashKey = (0, env_js_1.requireSetting)('yoma_mmqr', 'webhook_hashkey', options.webhookHashKey);
|
|
37
37
|
this.webhookSecret = (0, env_js_1.optionalSetting)(options.webhookSecret);
|
|
38
|
-
this.sandbox = options.sandbox
|
|
38
|
+
this.sandbox = (0, env_js_1.parseSandbox)(options.sandbox);
|
|
39
39
|
this.baseUrl = (0, env_js_1.trimUrl)((0, env_js_1.optionalSetting)(options.baseUrl) ??
|
|
40
40
|
(this.sandbox ? YomaMmqrConfig.SANDBOX_URL : YomaMmqrConfig.PRODUCTION_URL));
|
|
41
41
|
this.apiVersion = (0, env_js_1.optionalSetting)(options.apiVersion) ?? YomaMmqrConfig.DEFAULT_API_VERSION;
|
|
@@ -161,7 +161,9 @@ class YomaMmqr {
|
|
|
161
161
|
const orderNumber = (0, values_js_1.get)(payload, 'orderNumber');
|
|
162
162
|
const status = (0, values_js_1.trimmed)(payload, 'status');
|
|
163
163
|
const expected = (0, crypto_js_1.hmacSha256Hex)(orderNumber + this.config.webhookHashKey, `orderNumber=${orderNumber}&status=${status}`);
|
|
164
|
-
if (orderNumber === '' ||
|
|
164
|
+
if (orderNumber === '' ||
|
|
165
|
+
(0, values_js_1.isNested)(payload.status) ||
|
|
166
|
+
!(0, crypto_js_1.safeEqual)(expected, (0, values_js_1.get)(payload, 'hashValue').toLowerCase())) {
|
|
165
167
|
throw new errors_js_1.SignatureVerificationError('Yoma MMQR callback hash verification failed.', (0, json_js_1.toPlainObject)(payload));
|
|
166
168
|
}
|
|
167
169
|
return new callback_js_1.PaymentCallback({
|
|
@@ -210,13 +212,14 @@ class YomaMmqr {
|
|
|
210
212
|
if (!response.successful() || token === '') {
|
|
211
213
|
throw apiError('token', response.status, body, (0, values_js_1.get)(body, 'error'));
|
|
212
214
|
}
|
|
213
|
-
|
|
214
|
-
const
|
|
215
|
+
// The leading digits of `expires_in`; missing, non-numeric or 0 means an hour.
|
|
216
|
+
const expiresIn = Number(/^\d+/.exec((0, values_js_1.get)(body, 'expires_in'))?.[0] ?? 0);
|
|
217
|
+
const ttl = Math.max(60, (expiresIn > 0 ? expiresIn : 3600) - 60);
|
|
215
218
|
await this.cache.set(this.tokenCacheKey(), token, ttl);
|
|
216
219
|
return token;
|
|
217
220
|
}
|
|
218
221
|
tokenCacheKey() {
|
|
219
|
-
return `
|
|
222
|
+
return `myanmar-payments.yoma-mmqr.token.${(0, crypto_js_1.sha256Hex)(`${this.config.baseUrl}|${this.config.clientId}`)}`;
|
|
220
223
|
}
|
|
221
224
|
}
|
|
222
225
|
exports.YomaMmqr = YomaMmqr;
|
|
@@ -9,8 +9,11 @@ export interface AyaPayConfigOptions {
|
|
|
9
9
|
appKey: string;
|
|
10
10
|
/** The secret that signs requests and verifies callbacks. */
|
|
11
11
|
appSecret: string;
|
|
12
|
-
/**
|
|
13
|
-
|
|
12
|
+
/**
|
|
13
|
+
* Use the UAT environment (default `true`).
|
|
14
|
+
* Text is read like a `*_SANDBOX` variable: `false`, `0`, `f`, `no` or `off` select production.
|
|
15
|
+
*/
|
|
16
|
+
sandbox?: boolean | string | undefined;
|
|
14
17
|
/** Overrides the gateway base URL. */
|
|
15
18
|
baseUrl?: string | undefined;
|
|
16
19
|
}
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import {} from '../core/amount.js';
|
|
2
2
|
import { CallbackRequest, losslessInput, losslessQueryInput, PaymentCallback, PaymentStatusResult, } from '../core/callback.js';
|
|
3
3
|
import { decodeBase64, hmacSha256Hex, 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 { ApiError, SignatureVerificationError } from '../core/errors.js';
|
|
6
6
|
import { httpClientFrom, Transport, } from '../core/http.js';
|
|
7
7
|
import { isLosslessObject, parseJsonObject, toPlainObject, } from '../core/json.js';
|
|
8
8
|
import { FormPayment } from '../core/results.js';
|
|
9
9
|
import { resolveStatus } from '../core/status.js';
|
|
10
10
|
import { toAmount, Validator } from '../core/validate.js';
|
|
11
|
-
import { get, object, optional, scalarString, trimmed } from '../core/values.js';
|
|
11
|
+
import { get, isNested, object, optional, scalarString, trimmed } from '../core/values.js';
|
|
12
12
|
/**
|
|
13
13
|
* AYA Payment Gateway (APG) credentials and endpoints. A missing credential throws a
|
|
14
14
|
* `ConfigurationError`.
|
|
@@ -24,7 +24,7 @@ export class AyaPayConfig {
|
|
|
24
24
|
constructor(options) {
|
|
25
25
|
this.appKey = requireSetting('aya_pay', 'app_key', options.appKey);
|
|
26
26
|
this.appSecret = requireSetting('aya_pay', 'app_secret', options.appSecret);
|
|
27
|
-
this.sandbox = options.sandbox
|
|
27
|
+
this.sandbox = parseSandbox(options.sandbox);
|
|
28
28
|
this.baseUrl = trimUrl(optionalSetting(options.baseUrl) ??
|
|
29
29
|
(this.sandbox ? AyaPayConfig.SANDBOX_URL : AyaPayConfig.PRODUCTION_URL));
|
|
30
30
|
}
|
|
@@ -242,7 +242,8 @@ export class AyaPay {
|
|
|
242
242
|
/** Decodes a `payload` + `checkSum` pair and returns the payload if the checksum matches. */
|
|
243
243
|
verifiedPayload(input, context) {
|
|
244
244
|
const fail = () => new SignatureVerificationError(`AYA Pay ${context} checksum verification failed.`, toPlainObject(input));
|
|
245
|
-
|
|
245
|
+
// Base64 never contains spaces: a space is a `+` that an unencoded query string turned into one.
|
|
246
|
+
const json = decodeBase64(get(input, 'payload').replace(/ /g, '+'));
|
|
246
247
|
const payload = json === undefined ? undefined : parseJsonObject(json);
|
|
247
248
|
if (payload === undefined) {
|
|
248
249
|
throw fail();
|
|
@@ -251,6 +252,10 @@ export class AyaPay {
|
|
|
251
252
|
for (const field of PAYLOAD_FIELDS) {
|
|
252
253
|
const key = field === 'currencyCode' && 'currenyCode' in payload ? 'currenyCode' : field;
|
|
253
254
|
if (Object.prototype.hasOwnProperty.call(payload, key)) {
|
|
255
|
+
// A nested value is never signed, so a payload carrying one is rejected.
|
|
256
|
+
if (isNested(payload[key])) {
|
|
257
|
+
throw fail();
|
|
258
|
+
}
|
|
254
259
|
parts.push(scalarString(payload[key]) ?? '');
|
|
255
260
|
}
|
|
256
261
|
}
|
|
@@ -43,9 +43,10 @@ export declare class Amount {
|
|
|
43
43
|
/** Whether the amount is greater than zero. */
|
|
44
44
|
isPositive(): boolean;
|
|
45
45
|
/**
|
|
46
|
-
* Whether both amounts have the same value
|
|
47
|
-
* (`1000`, `
|
|
48
|
-
*
|
|
46
|
+
* Whether both amounts have the same value. Leading zeros of the whole part and trailing zeros of
|
|
47
|
+
* the fraction are ignored (`1000`, `01000` and `1000.00` are equal). Text must be plain digits
|
|
48
|
+
* with an optional fraction, so `1,000` or ` 1000` is never equal, and neither is `null` or
|
|
49
|
+
* `undefined` (a gateway amount that was not sent never matches).
|
|
49
50
|
*/
|
|
50
51
|
equals(other: Amount | string | null | undefined): boolean;
|
|
51
52
|
}
|
package/dist/esm/core/amount.js
CHANGED
|
@@ -85,19 +85,28 @@ export class Amount {
|
|
|
85
85
|
return !this.isZero();
|
|
86
86
|
}
|
|
87
87
|
/**
|
|
88
|
-
* Whether both amounts have the same value
|
|
89
|
-
* (`1000`, `
|
|
90
|
-
*
|
|
88
|
+
* Whether both amounts have the same value. Leading zeros of the whole part and trailing zeros of
|
|
89
|
+
* the fraction are ignored (`1000`, `01000` and `1000.00` are equal). Text must be plain digits
|
|
90
|
+
* with an optional fraction, so `1,000` or ` 1000` is never equal, and neither is `null` or
|
|
91
|
+
* `undefined` (a gateway amount that was not sent never matches).
|
|
91
92
|
*/
|
|
92
93
|
equals(other) {
|
|
93
94
|
if (other === undefined || other === null) {
|
|
94
95
|
return false;
|
|
95
96
|
}
|
|
96
|
-
const normalize = (value) => value.includes('.') ? value.replace(/0+$/, '').replace(/\.$/, '') : value;
|
|
97
97
|
const text = other instanceof Amount ? other.value : other;
|
|
98
|
+
if (typeof text !== 'string' || !PATTERN.test(text)) {
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
98
101
|
return normalize(this.value) === normalize(text);
|
|
99
102
|
}
|
|
100
103
|
}
|
|
104
|
+
function normalize(value) {
|
|
105
|
+
const [whole = '', fraction = ''] = value.split('.');
|
|
106
|
+
const trimmedWhole = whole.replace(/^0+/, '') || '0';
|
|
107
|
+
const trimmedFraction = fraction.replace(/0+$/, '');
|
|
108
|
+
return trimmedFraction === '' ? trimmedWhole : `${trimmedWhole}.${trimmedFraction}`;
|
|
109
|
+
}
|
|
101
110
|
function invalid(message) {
|
|
102
111
|
return new InvalidPaymentDataError({ amount: message });
|
|
103
112
|
}
|
|
@@ -16,8 +16,8 @@ export interface CallbackRequestInit {
|
|
|
16
16
|
query?: QueryInput | null | undefined;
|
|
17
17
|
}
|
|
18
18
|
/**
|
|
19
|
-
* The parts of a Node `http.IncomingMessage` this package reads, so Express
|
|
20
|
-
*
|
|
19
|
+
* The parts of a Node `http.IncomingMessage` this package reads, so Express requests work too (and
|
|
20
|
+
* Koa's `ctx.req` when no body parser consumed it).
|
|
21
21
|
*/
|
|
22
22
|
export interface NodeRequestLike extends AsyncIterable<unknown> {
|
|
23
23
|
headers: Readonly<Record<string, string | readonly string[] | undefined>>;
|
|
@@ -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). */
|
|
@@ -57,10 +59,11 @@ export declare class CallbackRequest {
|
|
|
57
59
|
*/
|
|
58
60
|
static fromJson(payload: unknown, headers?: HeadersInput | null): CallbackRequest;
|
|
59
61
|
/**
|
|
60
|
-
* Reads a Node `http.IncomingMessage` (also Express `req`,
|
|
61
|
-
* Reads the raw body from the stream, or from `rawBody`/`body` when middleware captured
|
|
62
|
-
* text or bytes. When a body parser already consumed the stream, the parsed `body` is
|
|
63
|
-
* again as JSON or a form.
|
|
62
|
+
* Reads a Node `http.IncomingMessage` (also Express `req`, or Koa `ctx.req` without a body
|
|
63
|
+
* parser). Reads the raw body from the stream, or from `rawBody`/`body` when middleware captured
|
|
64
|
+
* it as text or bytes. When a body parser already consumed the stream, the parsed `body` is
|
|
65
|
+
* encoded again as JSON or a form. Fastify parses the body before your handler runs: use
|
|
66
|
+
* {@link CallbackRequest.from} with the raw body there.
|
|
64
67
|
*/
|
|
65
68
|
static fromNodeRequest(request: NodeRequestLike): Promise<CallbackRequest>;
|
|
66
69
|
/**
|
|
@@ -70,7 +73,10 @@ export declare class CallbackRequest {
|
|
|
70
73
|
static fromWebRequest(request: WebRequestLike): Promise<CallbackRequest>;
|
|
71
74
|
/** The named header, matched case-insensitively, or `undefined`. */
|
|
72
75
|
header(name: string): string | undefined;
|
|
73
|
-
/**
|
|
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
|
+
*/
|
|
74
80
|
parsedBody(): Record<string, unknown>;
|
|
75
81
|
/** The parsed body merged over the query string. */
|
|
76
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
|
}
|
|
@@ -31,10 +34,11 @@ export class CallbackRequest {
|
|
|
31
34
|
});
|
|
32
35
|
}
|
|
33
36
|
/**
|
|
34
|
-
* Reads a Node `http.IncomingMessage` (also Express `req`,
|
|
35
|
-
* Reads the raw body from the stream, or from `rawBody`/`body` when middleware captured
|
|
36
|
-
* text or bytes. When a body parser already consumed the stream, the parsed `body` is
|
|
37
|
-
* again as JSON or a form.
|
|
37
|
+
* Reads a Node `http.IncomingMessage` (also Express `req`, or Koa `ctx.req` without a body
|
|
38
|
+
* parser). Reads the raw body from the stream, or from `rawBody`/`body` when middleware captured
|
|
39
|
+
* it as text or bytes. When a body parser already consumed the stream, the parsed `body` is
|
|
40
|
+
* encoded again as JSON or a form. Fastify parses the body before your handler runs: use
|
|
41
|
+
* {@link CallbackRequest.from} with the raw body there.
|
|
38
42
|
*/
|
|
39
43
|
static async fromNodeRequest(request) {
|
|
40
44
|
const headers = normalizeHeaders(request.headers);
|
|
@@ -67,7 +71,10 @@ export class CallbackRequest {
|
|
|
67
71
|
header(name) {
|
|
68
72
|
return this.headers[name.toLowerCase()];
|
|
69
73
|
}
|
|
70
|
-
/**
|
|
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
|
+
*/
|
|
71
78
|
parsedBody() {
|
|
72
79
|
return toPlainObject(losslessBody(this));
|
|
73
80
|
}
|
|
@@ -206,14 +213,15 @@ export class PaymentStatusResult {
|
|
|
206
213
|
return this.status === 'successful';
|
|
207
214
|
}
|
|
208
215
|
}
|
|
209
|
-
function
|
|
216
|
+
function bodyBytes(body) {
|
|
210
217
|
if (body === undefined || body === null) {
|
|
211
|
-
return
|
|
218
|
+
return new Uint8Array();
|
|
212
219
|
}
|
|
213
220
|
if (typeof body === 'string') {
|
|
214
|
-
return body;
|
|
221
|
+
return new TextEncoder().encode(body);
|
|
215
222
|
}
|
|
216
|
-
|
|
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);
|
|
217
225
|
}
|
|
218
226
|
function rawBody(value) {
|
|
219
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);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* A gateway-independent payment status. Every gateway's own status values are mapped onto these.
|
|
3
3
|
*/
|
|
4
|
-
export type PaymentStatus = 'successful' | 'pending' | 'failed' | '
|
|
4
|
+
export type PaymentStatus = 'successful' | 'pending' | 'failed' | 'canceled' | 'expired' | 'unknown';
|
|
5
5
|
/**
|
|
6
6
|
* The payment statuses, plus helpers.
|
|
7
7
|
*
|
|
@@ -18,13 +18,13 @@ export declare const PaymentStatus: Readonly<{
|
|
|
18
18
|
/** The payment was attempted and failed or was rejected. */
|
|
19
19
|
Failed: "failed";
|
|
20
20
|
/** The payment or order was canceled or closed before completing. */
|
|
21
|
-
|
|
21
|
+
Canceled: "canceled";
|
|
22
22
|
/** The payment window ran out before the customer paid. */
|
|
23
23
|
Expired: "expired";
|
|
24
24
|
/** The gateway sent a status this package does not recognize. Inspect `gatewayStatus`. */
|
|
25
25
|
Unknown: "unknown";
|
|
26
26
|
/** Every status, in the order above. */
|
|
27
|
-
values: readonly ["successful", "pending", "failed", "
|
|
27
|
+
values: readonly ["successful", "pending", "failed", "canceled", "expired", "unknown"];
|
|
28
28
|
/** Whether the status will not change any more: `false` for `pending` and `unknown`. */
|
|
29
29
|
isFinal(status: PaymentStatus): boolean;
|
|
30
30
|
}>;
|
package/dist/esm/core/status.js
CHANGED
|
@@ -14,7 +14,7 @@ export const PaymentStatus = Object.freeze({
|
|
|
14
14
|
/** The payment was attempted and failed or was rejected. */
|
|
15
15
|
Failed: 'failed',
|
|
16
16
|
/** The payment or order was canceled or closed before completing. */
|
|
17
|
-
|
|
17
|
+
Canceled: 'canceled',
|
|
18
18
|
/** The payment window ran out before the customer paid. */
|
|
19
19
|
Expired: 'expired',
|
|
20
20
|
/** The gateway sent a status this package does not recognize. Inspect `gatewayStatus`. */
|
|
@@ -24,7 +24,7 @@ export const PaymentStatus = Object.freeze({
|
|
|
24
24
|
'successful',
|
|
25
25
|
'pending',
|
|
26
26
|
'failed',
|
|
27
|
-
'
|
|
27
|
+
'canceled',
|
|
28
28
|
'expired',
|
|
29
29
|
'unknown',
|
|
30
30
|
]),
|
|
@@ -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
|
}
|
|
@@ -87,7 +90,8 @@ export declare class CyberSource {
|
|
|
87
90
|
initiate(data: CyberSourcePaymentData): FormPayment;
|
|
88
91
|
/**
|
|
89
92
|
* Verifies CyberSource's result post. The same check works for the browser post to your receipt
|
|
90
|
-
* page.
|
|
93
|
+
* page. Only the fields listed in `signed_field_names` are trusted: `decision` and
|
|
94
|
+
* `req_reference_number` must be signed, and unsigned fields are left out of the result.
|
|
91
95
|
*/
|
|
92
96
|
handleCallback(request: CallbackRequest): PaymentCallback;
|
|
93
97
|
/** Signs the fields listed in `signed_field_names`; `undefined` when a listed field is missing. */
|
|
@@ -1,9 +1,9 @@
|
|
|
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
|
-
import { toPlainObject } from '../core/json.js';
|
|
6
|
+
import { setKey, toPlainObject } from '../core/json.js';
|
|
7
7
|
import { FormPayment } from '../core/results.js';
|
|
8
8
|
import { resolveStatus } from '../core/status.js';
|
|
9
9
|
import { toAmount, Validator } from '../core/validate.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
|
}
|
|
@@ -74,7 +74,7 @@ const STATUSES = {
|
|
|
74
74
|
REVIEW: 'pending',
|
|
75
75
|
DECLINE: 'failed',
|
|
76
76
|
ERROR: 'failed',
|
|
77
|
-
CANCEL: '
|
|
77
|
+
CANCEL: 'canceled',
|
|
78
78
|
};
|
|
79
79
|
/**
|
|
80
80
|
* CyberSource Secure Acceptance hosted checkout for card payments: signs the form and verifies the
|
|
@@ -146,31 +146,39 @@ export class CyberSource {
|
|
|
146
146
|
}
|
|
147
147
|
/**
|
|
148
148
|
* Verifies CyberSource's result post. The same check works for the browser post to your receipt
|
|
149
|
-
* page.
|
|
149
|
+
* page. Only the fields listed in `signed_field_names` are trusted: `decision` and
|
|
150
|
+
* `req_reference_number` must be signed, and unsigned fields are left out of the result.
|
|
150
151
|
*/
|
|
151
152
|
handleCallback(request) {
|
|
152
153
|
const payload = losslessInput(request);
|
|
153
154
|
const expected = this.sign(payload);
|
|
154
|
-
|
|
155
|
+
const names = signedNames(payload);
|
|
156
|
+
if (expected === undefined ||
|
|
157
|
+
!safeEqual(expected, get(payload, 'signature')) ||
|
|
158
|
+
!names.includes('decision') ||
|
|
159
|
+
!names.includes('req_reference_number')) {
|
|
155
160
|
throw new SignatureVerificationError('CyberSource callback signature verification failed.', toPlainObject(payload));
|
|
156
161
|
}
|
|
157
|
-
|
|
162
|
+
// A signature only covers the listed fields, so anything else in the post could have been
|
|
163
|
+
// added by the sender (e.g. a `decision` next to the signed request fields).
|
|
164
|
+
const signed = {};
|
|
165
|
+
for (const name of [...names, 'signature']) {
|
|
166
|
+
setKey(signed, name, payload[name]);
|
|
167
|
+
}
|
|
168
|
+
const decision = trimmed(signed, 'decision').toUpperCase();
|
|
158
169
|
return new PaymentCallback({
|
|
159
|
-
orderId: get(
|
|
170
|
+
orderId: get(signed, 'req_reference_number'),
|
|
160
171
|
status: resolveStatus(STATUSES, decision),
|
|
161
172
|
gatewayStatus: decision,
|
|
162
|
-
gatewayReference: optional(
|
|
163
|
-
amount: get(
|
|
164
|
-
raw: toPlainObject(
|
|
173
|
+
gatewayReference: optional(signed, 'transaction_id'),
|
|
174
|
+
amount: get(signed, 'auth_amount') || optional(signed, 'req_amount'),
|
|
175
|
+
raw: toPlainObject(signed),
|
|
165
176
|
});
|
|
166
177
|
}
|
|
167
178
|
/** Signs the fields listed in `signed_field_names`; `undefined` when a listed field is missing. */
|
|
168
179
|
sign(fields) {
|
|
169
180
|
const pairs = [];
|
|
170
|
-
for (const name of
|
|
171
|
-
if (name === '') {
|
|
172
|
-
continue;
|
|
173
|
-
}
|
|
181
|
+
for (const name of signedNames(fields)) {
|
|
174
182
|
const value = scalarString(fields[name]);
|
|
175
183
|
if (value === undefined) {
|
|
176
184
|
return undefined;
|
|
@@ -182,3 +190,9 @@ export class CyberSource {
|
|
|
182
190
|
: hmacSha256Base64(this.config.secretKey, pairs.join(','));
|
|
183
191
|
}
|
|
184
192
|
}
|
|
193
|
+
/** The names listed in `signed_field_names`, in order. */
|
|
194
|
+
function signedNames(fields) {
|
|
195
|
+
return get(fields, 'signed_field_names')
|
|
196
|
+
.split(',')
|
|
197
|
+
.filter((name) => name !== '');
|
|
198
|
+
}
|
|
@@ -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 `#/`. */
|