@laranex/myanmar-payments 4.0.0-dev.0 → 4.0.0-dev.10

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.
Files changed (68) hide show
  1. package/CHANGELOG.md +19 -4
  2. package/README.md +3 -3
  3. package/dist/cjs/aya-pay/aya-pay.d.ts +13 -9
  4. package/dist/cjs/aya-pay/aya-pay.js +16 -12
  5. package/dist/cjs/core/amount.d.ts +4 -3
  6. package/dist/cjs/core/amount.js +13 -4
  7. package/dist/cjs/core/callback.d.ts +14 -8
  8. package/dist/cjs/core/callback.js +19 -11
  9. package/dist/cjs/core/crypto.d.ts +6 -1
  10. package/dist/cjs/core/crypto.js +17 -3
  11. package/dist/cjs/core/env.d.ts +14 -5
  12. package/dist/cjs/core/env.js +35 -11
  13. package/dist/cjs/core/errors.d.ts +9 -3
  14. package/dist/cjs/core/errors.js +12 -4
  15. package/dist/cjs/core/http.d.ts +10 -11
  16. package/dist/cjs/core/http.js +7 -9
  17. package/dist/cjs/core/index.d.ts +2 -2
  18. package/dist/cjs/core/index.js +3 -2
  19. package/dist/cjs/core/json.d.ts +2 -2
  20. package/dist/cjs/core/json.js +3 -3
  21. package/dist/cjs/core/status.d.ts +3 -3
  22. package/dist/cjs/core/status.js +2 -2
  23. package/dist/cjs/core/values.d.ts +2 -0
  24. package/dist/cjs/core/values.js +5 -0
  25. package/dist/cjs/cyber-source/cyber-source.d.ts +12 -14
  26. package/dist/cjs/cyber-source/cyber-source.js +39 -26
  27. package/dist/cjs/kbz-pay/kbz-pay.d.ts +27 -11
  28. package/dist/cjs/kbz-pay/kbz-pay.js +43 -20
  29. package/dist/cjs/kbz-pay/signer.d.ts +4 -1
  30. package/dist/cjs/kbz-pay/signer.js +7 -2
  31. package/dist/cjs/wave-money/wave-money.d.ts +26 -13
  32. package/dist/cjs/wave-money/wave-money.js +29 -29
  33. package/dist/cjs/yoma-mmqr/yoma-mmqr.d.ts +17 -13
  34. package/dist/cjs/yoma-mmqr/yoma-mmqr.js +21 -19
  35. package/dist/esm/aya-pay/aya-pay.d.ts +13 -9
  36. package/dist/esm/aya-pay/aya-pay.js +18 -14
  37. package/dist/esm/core/amount.d.ts +4 -3
  38. package/dist/esm/core/amount.js +13 -4
  39. package/dist/esm/core/callback.d.ts +14 -8
  40. package/dist/esm/core/callback.js +19 -11
  41. package/dist/esm/core/crypto.d.ts +6 -1
  42. package/dist/esm/core/crypto.js +17 -3
  43. package/dist/esm/core/env.d.ts +14 -5
  44. package/dist/esm/core/env.js +32 -9
  45. package/dist/esm/core/errors.d.ts +9 -3
  46. package/dist/esm/core/errors.js +12 -4
  47. package/dist/esm/core/http.d.ts +10 -11
  48. package/dist/esm/core/http.js +6 -8
  49. package/dist/esm/core/index.d.ts +2 -2
  50. package/dist/esm/core/index.js +2 -1
  51. package/dist/esm/core/json.d.ts +2 -2
  52. package/dist/esm/core/json.js +3 -3
  53. package/dist/esm/core/status.d.ts +3 -3
  54. package/dist/esm/core/status.js +2 -2
  55. package/dist/esm/core/values.d.ts +2 -0
  56. package/dist/esm/core/values.js +4 -0
  57. package/dist/esm/cyber-source/cyber-source.d.ts +12 -14
  58. package/dist/esm/cyber-source/cyber-source.js +41 -28
  59. package/dist/esm/kbz-pay/kbz-pay.d.ts +27 -11
  60. package/dist/esm/kbz-pay/kbz-pay.js +45 -22
  61. package/dist/esm/kbz-pay/signer.d.ts +4 -1
  62. package/dist/esm/kbz-pay/signer.js +8 -3
  63. package/dist/esm/wave-money/wave-money.d.ts +26 -13
  64. package/dist/esm/wave-money/wave-money.js +31 -31
  65. package/dist/esm/yoma-mmqr/yoma-mmqr.d.ts +17 -13
  66. package/dist/esm/yoma-mmqr/yoma-mmqr.js +23 -21
  67. package/package.json +2 -2
  68. package/skills/node-myanmar-payments/SKILL.md +26 -22
package/CHANGELOG.md CHANGED
@@ -10,12 +10,27 @@ Initial release. The version number matches the other Laranex Myanmar payments p
10
10
  - Gateways: KBZ Pay (`pwa`, `qr`, `app`, `status`), Wave Money (`initiate`), AYA Payment Gateway (`services`, `initiate`, `status`, `verifyRedirect`), Yoma MMQR (`initiate`, `renewQr`, `status`, `forgetToken`) and CyberSource Secure Acceptance (`initiate`); every gateway validates its payment data (`KbzPay.validate()` and so on) and verifies callbacks with `handleCallback`.
11
11
  - Subpath exports per gateway (`@laranex/myanmar-payments/kbz-pay`, `/wave-money`, `/aya-pay`, `/yoma-mmqr`, `/cyber-source`) next to the root export, as ES modules and CommonJS with type declarations.
12
12
  - Exact `Amount` type (`Amount.kyat()`, `Amount.parse()`) kept as decimal text, so amounts never pass through a float; payment data also takes a whole-number `number` or `bigint` and rejects fractions. Decimals are accepted only where the gateway documents them (KBZ Pay up to 2 places, CyberSource any); every gateway except CyberSource is MMK only.
13
- - A config class per gateway with `fromEnv(process.env)`, reading the same `*_SANDBOX`, credential and URL variables as the PHP, Laravel and Go packages; a missing credential throws `ConfigurationError`. `MyanmarPayments` builds every gateway lazily from one config object or the environment.
13
+ - A config class per gateway with `fromEnv(process.env)`, reading the same credential, setting and URL variables as the PHP, Laravel and Go packages; a missing setting throws `ConfigurationError`. `MyanmarPayments` builds every gateway lazily from one config object or the environment.
14
14
  - Result classes: `RedirectPayment`, `FormPayment` (with `toHtml()`), `QrPayment` (with `qrImageDataUri()`) and `AppPayment`; `PaymentCallback` (with a gateway-independent `PaymentStatus` and the `Acknowledgement` each gateway expects, sent with `send(res)` or `toResponse()`) and `PaymentStatusResult`.
15
- - `CallbackRequest.fromNodeRequest()` (node:http, Express, Fastify, Koa), `CallbackRequest.fromWebRequest()` (Fetch API servers such as Next.js, Hono and Bun), `CallbackRequest.from()` and `CallbackRequest.fromJson()`. JSON numbers in callbacks are read without a float conversion, so signatures and amounts keep the gateway's exact text.
15
+ - `CallbackRequest.fromNodeRequest()` (node:http, Express), `CallbackRequest.fromWebRequest()` (Fetch API servers such as Next.js, Hono and Bun), `CallbackRequest.from()` and `CallbackRequest.fromJson()`. JSON numbers in callbacks are read without a float conversion, so signatures and amounts keep the gateway's exact text.
16
16
  - Errors: `PaymentError` and its subclasses `InvalidPaymentDataError` (per-field `errors`), `ApiError` (`gatewayCode`, `gatewayMessage`, `httpStatus`, `raw`, `cause`), `SignatureVerificationError` and `ConfigurationError`.
17
- - No runtime dependencies: the global `fetch` (or an injected `fetch` / `HttpClient`, 30 second default timeout, `AbortSignal` per call) and `node:crypto`; an injectable `TokenCache` with an in-memory default for Yoma's access token.
17
+ - No runtime dependencies: the global `fetch` (or an injected `fetch` / `HttpClient`, the configured `timeoutSeconds`, `AbortSignal` per call) and `node:crypto`; an injectable `TokenCache` with an in-memory default for Yoma's access token.
18
+ - CyberSource callbacks trust only the fields listed in `signed_field_names`: `decision` and `req_reference_number` must be signed, and unsigned fields are left out of the result and `raw`.
19
+ - AYA Pay reads a `payload` whose `+` signs became spaces in an unencoded return query string.
18
20
  - Callback, return and cancel URLs only need to be valid absolute http or https URLs; there is no HTTPS-only rule (gateways may still require HTTPS in production).
19
- - Wave Money's sandbox is `https://preprodpayments.wavemoney.io:8107`, with checkout at `https://preprodpayments.wavemoney.io/authenticate`.
20
21
  - Agent skill in `skills/node-myanmar-payments` so coding agents use the package correctly; install it with `npx skills add laranex/node-myanmar-payments`.
21
22
  - Requires Node.js 20 or higher.
23
+
24
+ ### Changed since the pre-releases
25
+ - No sandbox switch: the sandbox setting (`sandbox`) and the `*_SANDBOX` variables are removed, and every gateway defaults to its production endpoints. The HTTP timeout (`timeout_in_seconds`, `MYANMAR_PAYMENTS_HTTP_TIMEOUT`) of every gateway that calls an API is optional and defaults to 30 seconds. Every other setting is required: Wave Money's `time_to_live_in_seconds` and `merchant_name` (no `APP_NAME` fallback), Yoma MMQR's `api_version`, and CyberSource's `currency`, `locale` and `transactionType` on each payment. A missing setting throws the configuration error naming it; a time setting that is not a whole number greater than 0 throws `The <gateway> configuration [<key>] must be a whole number greater than 0.` Upgrading: delete `*_SANDBOX`; to keep testing against UAT, set the URL overrides to the UAT URLs listed in the docs; set the newly required settings.
26
+ - In Node: config options `timeToLiveSeconds` and `apiVersion` are required and take a whole number or its text; `timeoutSeconds` (KBZ Pay, Wave Money, AYA Pay, Yoma MMQR) is optional, takes a whole number or its text and defaults to the new `DEFAULT_TIMEOUT_SECONDS` (30) when unset or blank; the `SANDBOX_*`, `DEFAULT_TIME_TO_LIVE_SECONDS` and `DEFAULT_API_VERSION` constants are removed. `GatewayOptions.timeoutMs` (so also the `MyanmarPayments` option) and `DEFAULT_TIMEOUT_MS` are removed; `FetchHttpClient` requires `timeoutMs`. `ConfigurationError` takes a third `invalid` argument for the whole-number message.
27
+ - `PaymentStatus.Cancelled` is renamed to `PaymentStatus.Canceled` and its value from `'cancelled'` to `'canceled'` (American English), with no alias. Code or stored statuses from the `v4.0.0-dev` pre-releases need the new name; gateway status literals such as Wave Money's `PAYMENT_REQUEST_CANCELLED` are unchanged.
28
+ - Callback values that are JSON numbers are kept as their exact text, as strings, in `raw`, `parsedBody()`, `input()`, `queryInput()` and the `raw` of errors (`1000.50` is `'1000.50'`, not `1000.5`), like the PHP SDK.
29
+ - A nested value (object or array) in a signed or hashed callback field now fails verification: KBZ Pay (any callback field), Wave Money (the hashed fields), AYA Pay (the signed payload fields) and Yoma MMQR (`status`). Before, KBZ Pay skipped it, Wave Money hashed it as `null` and AYA Pay signed it as empty.
30
+ - AYA Pay accepts a base64 `payload` without padding as well as with full padding; partial padding, the URL-safe alphabet, line breaks and payloads that are not UTF-8 are rejected.
31
+ - Yoma MMQR caches its access token under `myanmar-payments.yoma-mmqr.token.<sha256 of base URL and client id>` (was `node-myanmar-payments.…`), the same key as the PHP, Go and Python SDKs, and reads `expires_in` from its leading digits.
32
+ - `Amount.equals()` compares by value, ignoring leading zeros too (`'01000'` equals `Amount.kyat(1000)`); text that is not plain digits with an optional fraction is never equal.
33
+ - `CallbackRequest.rawBody` holds the exact bytes received (a string body is encoded as UTF-8), next to `body` as text, like Python's `raw_body` and Go's `Body`.
34
+ - `WaveMoneyPaymentData` takes optional `merchantName` and `timeToLiveSeconds`, which override the configured `merchantName` and `timeToLiveSeconds` for one payment (request hash included); a blank `merchantName` or a `timeToLiveSeconds` that is not a whole number greater than 0 throws `InvalidPaymentDataError`, like the PHP SDK.
35
+ - `KbzPayPaymentData.timeoutMinutes` is renamed to `timeToLiveSeconds` and takes seconds, like Wave Money: 60 to 7200, in whole minutes (a multiple of 60), because KBZ's `timeout_express` counts minutes (`1800` is sent as `30m`). Upgrading: multiply the old value by 60.
36
+ - `KBZ_PAY_TIME_TO_LIVE_IN_SECONDS` (`KbzPayConfig`'s `timeToLiveSeconds`) sets the time to live of every KBZ Pay order, like Wave Money's; a payment's own `timeToLiveSeconds` overrides it. It is optional: unset or blank leaves it to KBZ (7200). A value that is not a multiple of 60 from 60 to 7200 throws `ConfigurationError`: `The kbz_pay configuration [time_to_live_in_seconds] must be a multiple of 60 from 60 to 7200.` `ConfigurationError` takes an optional `requirement` for that message.
package/README.md CHANGED
@@ -1,10 +1,10 @@
1
1
  # Node Myanmar Payments
2
2
 
3
3
  [![npm](https://img.shields.io/npm/v/@laranex/myanmar-payments.svg?style=flat-square)](https://www.npmjs.com/package/@laranex/myanmar-payments)
4
- [![Tests](https://img.shields.io/github/actions/workflow/status/laranex/node-myanmar-payments/tests.yml?label=tests&style=flat-square)](https://github.com/laranex/node-myanmar-payments/actions/workflows/tests.yml)
5
- [![License](https://img.shields.io/github/license/laranex/node-myanmar-payments.svg?style=flat-square)](LICENSE.md)
4
+ [![Tests](https://github.com/laranex/node-myanmar-payments/actions/workflows/tests.yml/badge.svg)](https://github.com/laranex/node-myanmar-payments/actions/workflows/tests.yml)
5
+ [![License](https://img.shields.io/npm/l/@laranex/myanmar-payments.svg?style=flat-square)](LICENSE.md)
6
6
 
7
- A Node.js and TypeScript SDK for Myanmar payment gateways (KBZ Pay, Wave Money, AYA Payment Gateway, Yoma MMQR and CyberSource), with typed payment data, verified callbacks and a gateway-independent payment status. Built for humans and AI agents.
7
+ 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. Built for humans and AI agents.
8
8
 
9
9
  ## Documentation
10
10
 
@@ -9,27 +9,31 @@ export interface AyaPayConfigOptions {
9
9
  appKey: string;
10
10
  /** The secret that signs requests and verifies callbacks. */
11
11
  appSecret: string;
12
- /** Use the UAT environment (default `true`). */
13
- sandbox?: boolean | undefined;
14
- /** Overrides the gateway base URL. */
12
+ /**
13
+ * Seconds before the default HTTP client gives up on a request, a whole number greater than 0
14
+ * (or its text). Defaults to 30 (`DEFAULT_TIMEOUT_SECONDS`) when unset or blank. A
15
+ * client you pass keeps its own timeout.
16
+ */
17
+ timeoutSeconds?: number | string | null | undefined;
18
+ /** Overrides the gateway base URL, e.g. to point at UAT. */
15
19
  baseUrl?: string | undefined;
16
20
  }
17
21
  /**
18
- * AYA Payment Gateway (APG) credentials and endpoints. A missing credential throws a
19
- * `ConfigurationError`.
22
+ * AYA Payment Gateway (APG) credentials and endpoints. A missing setting throws a
23
+ * `ConfigurationError`. The URL defaults to production.
20
24
  */
21
25
  export declare class AyaPayConfig {
22
- static readonly SANDBOX_URL = "https://uat-pgw.ayainnovation.com";
23
26
  static readonly PRODUCTION_URL = "https://pgw.ayainnovation.com";
24
27
  readonly appKey: string;
25
28
  readonly appSecret: string;
26
- readonly sandbox: boolean;
29
+ /** Seconds before the default HTTP client gives up on a request. */
30
+ readonly timeoutSeconds: number;
27
31
  /** The base URL in use. */
28
32
  readonly baseUrl: string;
29
33
  constructor(options: AyaPayConfigOptions);
30
34
  /**
31
- * Reads `AYA_PAY_APP_KEY`, `AYA_PAY_APP_SECRET`, `AYA_PAY_SANDBOX` and `AYA_PAY_BASE_URL`, falling
32
- * back to the `AYA_PGW_*` names.
35
+ * Reads `AYA_PAY_APP_KEY`, `AYA_PAY_APP_SECRET`, `MYANMAR_PAYMENTS_HTTP_TIMEOUT` and
36
+ * `AYA_PAY_BASE_URL`, falling back to the `AYA_PGW_*` names.
33
37
  */
34
38
  static fromEnv(env?: EnvSource): AyaPayConfig;
35
39
  }
@@ -12,33 +12,32 @@ const status_js_1 = require("../core/status.js");
12
12
  const validate_js_1 = require("../core/validate.js");
13
13
  const values_js_1 = require("../core/values.js");
14
14
  /**
15
- * AYA Payment Gateway (APG) credentials and endpoints. A missing credential throws a
16
- * `ConfigurationError`.
15
+ * AYA Payment Gateway (APG) credentials and endpoints. A missing setting throws a
16
+ * `ConfigurationError`. The URL defaults to production.
17
17
  */
18
18
  class AyaPayConfig {
19
- static SANDBOX_URL = 'https://uat-pgw.ayainnovation.com';
20
19
  static PRODUCTION_URL = 'https://pgw.ayainnovation.com';
21
20
  appKey;
22
21
  appSecret;
23
- sandbox;
22
+ /** Seconds before the default HTTP client gives up on a request. */
23
+ timeoutSeconds;
24
24
  /** The base URL in use. */
25
25
  baseUrl;
26
26
  constructor(options) {
27
27
  this.appKey = (0, env_js_1.requireSetting)('aya_pay', 'app_key', options.appKey);
28
28
  this.appSecret = (0, env_js_1.requireSetting)('aya_pay', 'app_secret', options.appSecret);
29
- this.sandbox = options.sandbox ?? true;
30
- this.baseUrl = (0, env_js_1.trimUrl)((0, env_js_1.optionalSetting)(options.baseUrl) ??
31
- (this.sandbox ? AyaPayConfig.SANDBOX_URL : AyaPayConfig.PRODUCTION_URL));
29
+ this.timeoutSeconds = (0, env_js_1.resolveTimeout)('aya_pay', options.timeoutSeconds);
30
+ this.baseUrl = (0, env_js_1.trimUrl)((0, env_js_1.optionalSetting)(options.baseUrl) ?? AyaPayConfig.PRODUCTION_URL);
32
31
  }
33
32
  /**
34
- * Reads `AYA_PAY_APP_KEY`, `AYA_PAY_APP_SECRET`, `AYA_PAY_SANDBOX` and `AYA_PAY_BASE_URL`, falling
35
- * back to the `AYA_PGW_*` names.
33
+ * Reads `AYA_PAY_APP_KEY`, `AYA_PAY_APP_SECRET`, `MYANMAR_PAYMENTS_HTTP_TIMEOUT` and
34
+ * `AYA_PAY_BASE_URL`, falling back to the `AYA_PGW_*` names.
36
35
  */
37
36
  static fromEnv(env = (0, env_js_1.defaultEnv)()) {
38
37
  return new AyaPayConfig({
39
38
  appKey: (0, env_js_1.envFirst)(env, 'AYA_PAY_APP_KEY', 'AYA_PGW_APP_KEY'),
40
39
  appSecret: (0, env_js_1.envFirst)(env, 'AYA_PAY_APP_SECRET', 'AYA_PGW_APP_SECRET'),
41
- sandbox: (0, env_js_1.envSandbox)(env, 'AYA_PAY_SANDBOX'),
40
+ timeoutSeconds: (0, env_js_1.envFirst)(env, env_js_1.HTTP_TIMEOUT_VARIABLE),
42
41
  baseUrl: (0, env_js_1.envFirst)(env, 'AYA_PAY_BASE_URL', 'AYA_PGW_BASE_URL'),
43
42
  });
44
43
  }
@@ -123,7 +122,7 @@ class AyaPay {
123
122
  transport;
124
123
  constructor(config, options = {}) {
125
124
  this.config = config instanceof AyaPayConfig ? config : new AyaPayConfig(config);
126
- this.transport = new http_js_1.Transport((0, http_js_1.httpClientFrom)(options));
125
+ this.transport = new http_js_1.Transport((0, http_js_1.httpClientFrom)(options, this.config.timeoutSeconds));
127
126
  }
128
127
  /** A gateway configured from the `AYA_PAY_*` (or `AYA_PGW_*`) environment variables. */
129
128
  static fromEnv(env = (0, env_js_1.defaultEnv)(), options = {}) {
@@ -246,7 +245,8 @@ class AyaPay {
246
245
  /** Decodes a `payload` + `checkSum` pair and returns the payload if the checksum matches. */
247
246
  verifiedPayload(input, context) {
248
247
  const fail = () => new errors_js_1.SignatureVerificationError(`AYA Pay ${context} checksum verification failed.`, (0, json_js_1.toPlainObject)(input));
249
- const json = (0, crypto_js_1.decodeBase64)((0, values_js_1.get)(input, 'payload'));
248
+ // Base64 never contains spaces: a space is a `+` that an unencoded query string turned into one.
249
+ const json = (0, crypto_js_1.decodeBase64)((0, values_js_1.get)(input, 'payload').replace(/ /g, '+'));
250
250
  const payload = json === undefined ? undefined : (0, json_js_1.parseJsonObject)(json);
251
251
  if (payload === undefined) {
252
252
  throw fail();
@@ -255,6 +255,10 @@ class AyaPay {
255
255
  for (const field of PAYLOAD_FIELDS) {
256
256
  const key = field === 'currencyCode' && 'currenyCode' in payload ? 'currenyCode' : field;
257
257
  if (Object.prototype.hasOwnProperty.call(payload, key)) {
258
+ // A nested value is never signed, so a payload carrying one is rejected.
259
+ if ((0, values_js_1.isNested)(payload[key])) {
260
+ throw fail();
261
+ }
258
262
  parts.push((0, values_js_1.scalarString)(payload[key]) ?? '');
259
263
  }
260
264
  }
@@ -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, ignoring trailing fractional zeros
47
- * (`1000`, `1000.0` and `1000.00` are equal). `undefined` is never equal, so a gateway amount
48
- * that was not sent never matches.
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
  }
@@ -88,20 +88,29 @@ class Amount {
88
88
  return !this.isZero();
89
89
  }
90
90
  /**
91
- * Whether both amounts have the same value, ignoring trailing fractional zeros
92
- * (`1000`, `1000.0` and `1000.00` are equal). `undefined` is never equal, so a gateway amount
93
- * that was not sent never matches.
91
+ * Whether both amounts have the same value. Leading zeros of the whole part and trailing zeros of
92
+ * the fraction are ignored (`1000`, `01000` and `1000.00` are equal). Text must be plain digits
93
+ * with an optional fraction, so `1,000` or ` 1000` is never equal, and neither is `null` or
94
+ * `undefined` (a gateway amount that was not sent never matches).
94
95
  */
95
96
  equals(other) {
96
97
  if (other === undefined || other === null) {
97
98
  return false;
98
99
  }
99
- const normalize = (value) => value.includes('.') ? value.replace(/0+$/, '').replace(/\.$/, '') : value;
100
100
  const text = other instanceof Amount ? other.value : other;
101
+ if (typeof text !== 'string' || !PATTERN.test(text)) {
102
+ return false;
103
+ }
101
104
  return normalize(this.value) === normalize(text);
102
105
  }
103
106
  }
104
107
  exports.Amount = Amount;
108
+ function normalize(value) {
109
+ const [whole = '', fraction = ''] = value.split('.');
110
+ const trimmedWhole = whole.replace(/^0+/, '') || '0';
111
+ const trimmedFraction = fraction.replace(/0+$/, '');
112
+ return trimmedFraction === '' ? trimmedWhole : `${trimmedWhole}.${trimmedFraction}`;
113
+ }
105
114
  function invalid(message) {
106
115
  return new errors_js_1.InvalidPaymentDataError({ amount: message });
107
116
  }
@@ -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, Fastify (`request.raw`)
20
- * and Koa (`ctx.req`) requests work too.
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 raw request body, decoded as UTF-8. */
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`, Fastify `request.raw`, Koa `ctx.req`).
61
- * Reads the raw body from the stream, or from `rawBody`/`body` when middleware captured it as
62
- * text or bytes. When a body parser already consumed the stream, the parsed `body` is encoded
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
- /** The body decoded as JSON or as a urlencoded form. */
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>;
@@ -11,14 +11,17 @@ const json_js_1 = require("./json.js");
11
11
  * {@link CallbackRequest.fromNodeRequest} or {@link CallbackRequest.fromWebRequest}.
12
12
  */
13
13
  class CallbackRequest {
14
- /** The raw request body, decoded as UTF-8. */
14
+ /** The request body as text: a string body as given, bytes decoded as UTF-8. */
15
15
  body;
16
+ /** The exact bytes received. A string body is encoded as UTF-8. */
17
+ rawBody;
16
18
  /** The request headers, with lowercase names. Repeated headers are joined with `, `. */
17
19
  headers;
18
20
  /** The query string parameters (the first value of each). */
19
21
  query;
20
22
  constructor(init = {}) {
21
- this.body = bodyText(init.body);
23
+ this.rawBody = bodyBytes(init.body);
24
+ this.body = typeof init.body === 'string' ? init.body : new TextDecoder().decode(this.rawBody);
22
25
  this.headers = Object.freeze(normalizeHeaders(init.headers));
23
26
  this.query = Object.freeze(normalizeQuery(init.query));
24
27
  }
@@ -37,10 +40,11 @@ class CallbackRequest {
37
40
  });
38
41
  }
39
42
  /**
40
- * Reads a Node `http.IncomingMessage` (also Express `req`, Fastify `request.raw`, Koa `ctx.req`).
41
- * Reads the raw body from the stream, or from `rawBody`/`body` when middleware captured it as
42
- * text or bytes. When a body parser already consumed the stream, the parsed `body` is encoded
43
- * again as JSON or a form.
43
+ * Reads a Node `http.IncomingMessage` (also Express `req`, or Koa `ctx.req` without a body
44
+ * parser). Reads the raw body from the stream, or from `rawBody`/`body` when middleware captured
45
+ * it as text or bytes. When a body parser already consumed the stream, the parsed `body` is
46
+ * encoded again as JSON or a form. Fastify parses the body before your handler runs: use
47
+ * {@link CallbackRequest.from} with the raw body there.
44
48
  */
45
49
  static async fromNodeRequest(request) {
46
50
  const headers = normalizeHeaders(request.headers);
@@ -73,7 +77,10 @@ class CallbackRequest {
73
77
  header(name) {
74
78
  return this.headers[name.toLowerCase()];
75
79
  }
76
- /** The body decoded as JSON or as a urlencoded form. */
80
+ /**
81
+ * The body decoded as JSON or as a urlencoded form. JSON numbers keep their exact text, as strings
82
+ * (`1000.50` stays `"1000.50"`).
83
+ */
77
84
  parsedBody() {
78
85
  return (0, json_js_1.toPlainObject)(losslessBody(this));
79
86
  }
@@ -216,14 +223,15 @@ class PaymentStatusResult {
216
223
  }
217
224
  }
218
225
  exports.PaymentStatusResult = PaymentStatusResult;
219
- function bodyText(body) {
226
+ function bodyBytes(body) {
220
227
  if (body === undefined || body === null) {
221
- return '';
228
+ return new Uint8Array();
222
229
  }
223
230
  if (typeof body === 'string') {
224
- return body;
231
+ return new TextEncoder().encode(body);
225
232
  }
226
- return new TextDecoder().decode(body);
233
+ // A copy, so later changes to the caller's buffer cannot alter what was verified.
234
+ return body instanceof ArrayBuffer ? new Uint8Array(body.slice(0)) : Uint8Array.from(body);
227
235
  }
228
236
  function rawBody(value) {
229
237
  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
- /** Decodes strict standard base64 (with padding), or `undefined`. @internal */
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;
@@ -41,10 +41,24 @@ function queryEscape(value) {
41
41
  .replace(/[!'()*]/g, (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`)
42
42
  .replace(/%20/g, '+');
43
43
  }
44
- /** Decodes strict standard base64 (with padding), or `undefined`. @internal */
44
+ const UTF8 = new TextDecoder('utf-8', { fatal: true });
45
+ /**
46
+ * Decodes standard base64 (`A-Z a-z 0-9 + /`), either correctly padded or without any padding, to
47
+ * UTF-8 text. Partial padding, other characters and bytes that are not UTF-8 return `undefined`.
48
+ *
49
+ * @internal
50
+ */
45
51
  function decodeBase64(value) {
46
- if (value === '' || value.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/.test(value)) {
52
+ if (!/^[A-Za-z0-9+/]+={0,2}$/.test(value)) {
53
+ return undefined;
54
+ }
55
+ if (value.includes('=') ? value.length % 4 !== 0 : value.length % 4 === 1) {
56
+ return undefined;
57
+ }
58
+ try {
59
+ return UTF8.decode(Buffer.from(value, 'base64'));
60
+ }
61
+ catch {
47
62
  return undefined;
48
63
  }
49
- return Buffer.from(value, 'base64').toString('utf8');
50
64
  }
@@ -3,14 +3,23 @@ export type EnvSource = Readonly<Record<string, string | undefined>>;
3
3
  /** The first non-empty value among `keys`, trimmed, or `''`. @internal */
4
4
  export declare function envFirst(env: EnvSource, ...keys: string[]): string;
5
5
  /**
6
- * Reads a `*_SANDBOX` variable. Only `false`, `0`, `f`, `no` and `off` (any case) select
7
- * production; unset or unrecognized values mean sandbox.
6
+ * A required whole number of seconds: an integer greater than 0, or text such as `'300'`. Unset
7
+ * or blank throws the missing {@link ConfigurationError}; anything else throws the invalid one.
8
8
  *
9
9
  * @internal
10
10
  */
11
- export declare function envSandbox(env: EnvSource, key: string): boolean;
12
- /** An integer variable, or `undefined` when unset or not an integer. @internal */
13
- export declare function envInt(env: EnvSource, key: string): number | undefined;
11
+ export declare function requireSeconds(gateway: string, key: string, value: unknown): number;
12
+ /** The variable holding the HTTP timeout of every gateway that calls an API. @internal */
13
+ export declare const HTTP_TIMEOUT_VARIABLE = "MYANMAR_PAYMENTS_HTTP_TIMEOUT";
14
+ /** Seconds before the default HTTP client gives up on a request when no timeout is configured. */
15
+ export declare const DEFAULT_TIMEOUT_SECONDS = 30;
16
+ /**
17
+ * The HTTP timeout in seconds: {@link DEFAULT_TIMEOUT_SECONDS} when unset or blank, otherwise a
18
+ * whole number greater than 0 (or its text), or the invalid {@link ConfigurationError}.
19
+ *
20
+ * @internal
21
+ */
22
+ export declare function resolveTimeout(gateway: string, value: unknown): number;
14
23
  /** The trimmed value, or throws a {@link ConfigurationError} naming `key`. @internal */
15
24
  export declare function requireSetting(gateway: string, key: string, value: unknown): string;
16
25
  /** A trimmed optional string, `undefined` when blank. @internal */
@@ -1,8 +1,9 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.DEFAULT_TIMEOUT_SECONDS = exports.HTTP_TIMEOUT_VARIABLE = void 0;
3
4
  exports.envFirst = envFirst;
4
- exports.envSandbox = envSandbox;
5
- exports.envInt = envInt;
5
+ exports.requireSeconds = requireSeconds;
6
+ exports.resolveTimeout = resolveTimeout;
6
7
  exports.requireSetting = requireSetting;
7
8
  exports.optionalSetting = optionalSetting;
8
9
  exports.trimUrl = trimUrl;
@@ -19,19 +20,42 @@ function envFirst(env, ...keys) {
19
20
  return '';
20
21
  }
21
22
  /**
22
- * Reads a `*_SANDBOX` variable. Only `false`, `0`, `f`, `no` and `off` (any case) select
23
- * production; unset or unrecognized values mean sandbox.
23
+ * A required whole number of seconds: an integer greater than 0, or text such as `'300'`. Unset
24
+ * or blank throws the missing {@link ConfigurationError}; anything else throws the invalid one.
24
25
  *
25
26
  * @internal
26
27
  */
27
- function envSandbox(env, key) {
28
- const value = env[key]?.trim().toLowerCase() ?? '';
29
- return !['false', '0', 'f', 'no', 'off'].includes(value);
28
+ function requireSeconds(gateway, key, value) {
29
+ if (value === undefined || value === null || (typeof value === 'string' && value.trim() === '')) {
30
+ throw new errors_js_1.ConfigurationError(gateway, key);
31
+ }
32
+ let seconds;
33
+ if (typeof value === 'number' && Number.isSafeInteger(value)) {
34
+ seconds = value;
35
+ }
36
+ else if (typeof value === 'string' && /^[+-]?\d+$/.test(value.trim())) {
37
+ seconds = Number.parseInt(value.trim(), 10);
38
+ }
39
+ if (seconds === undefined || !Number.isSafeInteger(seconds) || seconds <= 0) {
40
+ throw new errors_js_1.ConfigurationError(gateway, key, true);
41
+ }
42
+ return seconds;
30
43
  }
31
- /** An integer variable, or `undefined` when unset or not an integer. @internal */
32
- function envInt(env, key) {
33
- const value = env[key]?.trim() ?? '';
34
- return /^[+-]?\d+$/.test(value) ? Number.parseInt(value, 10) : undefined;
44
+ /** The variable holding the HTTP timeout of every gateway that calls an API. @internal */
45
+ exports.HTTP_TIMEOUT_VARIABLE = 'MYANMAR_PAYMENTS_HTTP_TIMEOUT';
46
+ /** Seconds before the default HTTP client gives up on a request when no timeout is configured. */
47
+ exports.DEFAULT_TIMEOUT_SECONDS = 30;
48
+ /**
49
+ * The HTTP timeout in seconds: {@link DEFAULT_TIMEOUT_SECONDS} when unset or blank, otherwise a
50
+ * whole number greater than 0 (or its text), or the invalid {@link ConfigurationError}.
51
+ *
52
+ * @internal
53
+ */
54
+ function resolveTimeout(gateway, value) {
55
+ if (value === undefined || value === null || (typeof value === 'string' && value.trim() === '')) {
56
+ return exports.DEFAULT_TIMEOUT_SECONDS;
57
+ }
58
+ return requireSeconds(gateway, 'timeout_in_seconds', value);
35
59
  }
36
60
  /** The trimmed value, or throws a {@link ConfigurationError} naming `key`. @internal */
37
61
  function requireSetting(gateway, key, value) {
@@ -45,12 +45,18 @@ export declare class SignatureVerificationError extends PaymentError {
45
45
  constructor(message: string, raw?: Record<string, unknown>);
46
46
  }
47
47
  /**
48
- * Thrown when a gateway is missing a credential or setting it needs.
48
+ * Thrown when a gateway is missing a credential or setting it needs, or a whole-number setting
49
+ * is not a whole number greater than 0.
49
50
  */
50
51
  export declare class ConfigurationError extends PaymentError {
51
52
  /** The gateway, e.g. `kbz_pay`. */
52
53
  readonly gateway: string;
53
- /** The missing setting, e.g. `app_key`. */
54
+ /** The missing or invalid setting, e.g. `app_key`. */
54
55
  readonly key: string;
55
- constructor(gateway: string, key: string);
56
+ /**
57
+ * @param invalid `true` when the setting is set but out of range; `false` (the default) when it
58
+ * is missing.
59
+ * @param requirement What a valid value is, used in the message when `invalid`.
60
+ */
61
+ constructor(gateway: string, key: string, invalid?: boolean, requirement?: string);
56
62
  }
@@ -62,15 +62,23 @@ class SignatureVerificationError extends PaymentError {
62
62
  }
63
63
  exports.SignatureVerificationError = SignatureVerificationError;
64
64
  /**
65
- * Thrown when a gateway is missing a credential or setting it needs.
65
+ * Thrown when a gateway is missing a credential or setting it needs, or a whole-number setting
66
+ * is not a whole number greater than 0.
66
67
  */
67
68
  class ConfigurationError extends PaymentError {
68
69
  /** The gateway, e.g. `kbz_pay`. */
69
70
  gateway;
70
- /** The missing setting, e.g. `app_key`. */
71
+ /** The missing or invalid setting, e.g. `app_key`. */
71
72
  key;
72
- constructor(gateway, key) {
73
- super(`The ${gateway} configuration is missing [${key}].`);
73
+ /**
74
+ * @param invalid `true` when the setting is set but out of range; `false` (the default) when it
75
+ * is missing.
76
+ * @param requirement What a valid value is, used in the message when `invalid`.
77
+ */
78
+ constructor(gateway, key, invalid = false, requirement = 'a whole number greater than 0') {
79
+ super(invalid
80
+ ? `The ${gateway} configuration [${key}] must be ${requirement}.`
81
+ : `The ${gateway} configuration is missing [${key}].`);
74
82
  this.gateway = gateway;
75
83
  this.key = key;
76
84
  }
@@ -27,29 +27,28 @@ export type FetchFunction = (input: string, init: RequestInit) => Promise<Respon
27
27
  export interface FetchHttpClientOptions {
28
28
  /** The `fetch` to call; defaults to the global `fetch`. */
29
29
  fetch?: FetchFunction | undefined;
30
- /** Milliseconds before a request is aborted; defaults to 30000. `0` disables the timeout. */
31
- timeoutMs?: number | undefined;
30
+ /** Milliseconds before a request is aborted. `0` disables the timeout. */
31
+ timeoutMs: number;
32
32
  }
33
- /** The default request timeout: 30 seconds. */
34
- export declare const DEFAULT_TIMEOUT_MS = 30000;
35
33
  /**
36
- * The default {@link HttpClient}: the global `fetch` (or the one you pass) with a 30 second
37
- * timeout.
34
+ * The default {@link HttpClient}: the global `fetch` (or the one you pass) with the timeout you
35
+ * give it. Gateways build one with their `timeoutSeconds` setting when you pass no client.
38
36
  */
39
37
  export declare class FetchHttpClient implements HttpClient {
40
38
  private readonly fetchFunction;
41
39
  private readonly timeoutMs;
42
- constructor(options?: FetchHttpClientOptions);
40
+ constructor(options: FetchHttpClientOptions);
43
41
  send(request: HttpRequest): Promise<HttpResponse>;
44
42
  }
45
43
  /** Options every gateway that calls an API accepts. */
46
44
  export interface GatewayOptions {
47
- /** Sends the requests. Takes precedence over `fetch` and `timeoutMs`. */
45
+ /**
46
+ * Sends the requests, with its own timeout. Takes precedence over `fetch`. Without one, the
47
+ * gateway uses a {@link FetchHttpClient} with the config's `timeoutSeconds`.
48
+ */
48
49
  httpClient?: HttpClient | undefined;
49
50
  /** The `fetch` the default client calls, e.g. a mocked one in tests. */
50
51
  fetch?: FetchFunction | undefined;
51
- /** Milliseconds before the default client aborts a request; defaults to 30000. */
52
- timeoutMs?: number | undefined;
53
52
  }
54
53
  /** Per-call options of every network method. */
55
54
  export interface RequestOptions {
@@ -57,7 +56,7 @@ export interface RequestOptions {
57
56
  signal?: AbortSignal | undefined;
58
57
  }
59
58
  /** @internal */
60
- export declare function httpClientFrom(options?: GatewayOptions): HttpClient;
59
+ export declare function httpClientFrom(options: GatewayOptions, timeoutSeconds: number): HttpClient;
61
60
  /** A decoded gateway response. @internal */
62
61
  export declare class GatewayResponse {
63
62
  readonly status: number;
@@ -1,21 +1,19 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.Transport = exports.GatewayResponse = exports.FetchHttpClient = exports.DEFAULT_TIMEOUT_MS = void 0;
3
+ exports.Transport = exports.GatewayResponse = exports.FetchHttpClient = void 0;
4
4
  exports.httpClientFrom = httpClientFrom;
5
5
  const errors_js_1 = require("./errors.js");
6
6
  const json_js_1 = require("./json.js");
7
- /** The default request timeout: 30 seconds. */
8
- exports.DEFAULT_TIMEOUT_MS = 30_000;
9
7
  /**
10
- * The default {@link HttpClient}: the global `fetch` (or the one you pass) with a 30 second
11
- * timeout.
8
+ * The default {@link HttpClient}: the global `fetch` (or the one you pass) with the timeout you
9
+ * give it. Gateways build one with their `timeoutSeconds` setting when you pass no client.
12
10
  */
13
11
  class FetchHttpClient {
14
12
  fetchFunction;
15
13
  timeoutMs;
16
- constructor(options = {}) {
14
+ constructor(options) {
17
15
  this.fetchFunction = options.fetch;
18
- this.timeoutMs = options.timeoutMs ?? exports.DEFAULT_TIMEOUT_MS;
16
+ this.timeoutMs = options.timeoutMs;
19
17
  }
20
18
  async send(request) {
21
19
  const fetchFunction = this.fetchFunction ?? globalThis.fetch;
@@ -43,9 +41,9 @@ class FetchHttpClient {
43
41
  }
44
42
  exports.FetchHttpClient = FetchHttpClient;
45
43
  /** @internal */
46
- function httpClientFrom(options = {}) {
44
+ function httpClientFrom(options, timeoutSeconds) {
47
45
  return (options.httpClient ??
48
- new FetchHttpClient({ fetch: options.fetch, timeoutMs: options.timeoutMs }));
46
+ new FetchHttpClient({ fetch: options.fetch, timeoutMs: timeoutSeconds * 1000 }));
49
47
  }
50
48
  /** A decoded gateway response. @internal */
51
49
  class GatewayResponse {