@vaam-apps/vpay-sdk 0.8.0 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -591,6 +591,22 @@ VPAY_AUDIENCE=https://api.vpay.example/v1/oauth/token \
591
591
 
592
592
  ## Status
593
593
 
594
+ **Behaviour change, 2026-10-21: an empty or all-dots `id` is refused before
595
+ any request.** Every one of the twenty-one methods that put an `id` in the
596
+ path now rejects with a `TypeError` for `""`, `.`, `..` or any longer run of
597
+ dots, without minting a token or sending anything. No signature changed —
598
+ each already returned a `Promise` — but the outcome for those inputs did.
599
+ They used to be sent, and `fetch`'s URL parser resolved them before the
600
+ request left, so `customers.del("..")` was a `DELETE /v1/` and
601
+ `paymentIntents.retrieve("")` a `GET /v1/payment_intents/`; escaping cannot
602
+ help, because the parser reads `%2E%2E` as `..` too. What vpay answered for
603
+ each is on
604
+ [the dated verification page](../../docs/status/verification/2026-10-21-sdk-dot-segment-ids.md).
605
+ A caller catching `VpayApiError` for those inputs now gets a `TypeError`,
606
+ and so does one narrowing on `VpayError`: like the amount refusal, it is not
607
+ one (a dated gap in [docs/sdks/parity.md](../../docs/sdks/parity.md)).
608
+ `sdks/rust` refuses the same ids with `Error::InvalidParams`.
609
+
594
610
  **Corrected 2026-09-03.** This section used to say "what it does **not**
595
611
  have is a single `/v1` resource route" — true when it was written on
596
612
  2026-09-02, and false since Step 2 landed the next day.
@@ -673,6 +689,12 @@ none of them touches the network:
673
689
  `util.inspect` while `JSON.stringify` stays faithful.
674
690
  - The form encoder's nested-object/array/boolean/percent-encoding rules, and
675
691
  that a non-integer `amount` throws `TypeError` before any request is sent.
692
+ - That an empty or all-dots `id` throws `TypeError` on all twenty-one
693
+ id-taking methods before any request, the token request included, with one
694
+ fixed message that never quotes it; that an id with dots in it that is not
695
+ only dots (`a..b`, `.a`, `%2E%2E`) is still sent as exactly one segment;
696
+ and, as the premise, that WHATWG `URL` resolves `..`, `%2E%2E` and `.%2e`
697
+ alike.
676
698
  - Error mapping for a Stripe-shaped `400`, an unexpected `502` HTML body, a
677
699
  connection failure, and a token-endpoint `401` (which is never retried).
678
700
  - Webhook verification: valid/invalid signatures, timestamp tolerance,
@@ -1,3 +1,4 @@
1
+ import { pathSegment } from "../validate.js";
1
2
  const INSPECT_CUSTOM = Symbol.for("nodejs.util.inspect.custom");
2
3
  /**
3
4
  * The `url` of a hosted session with its fragment redacted.
@@ -89,7 +90,7 @@ export class CheckoutSessionsResource {
89
90
  }
90
91
  /** `GET /v1/checkout/sessions/{id}`. Answers the session with `client_secret`. */
91
92
  async retrieve(id) {
92
- return withRedactedInspect(await this.#http.request("GET", `/checkout/sessions/${encodeURIComponent(id)}`));
93
+ return withRedactedInspect(await this.#http.request("GET", `/checkout/sessions/${pathSegment(id)}`));
93
94
  }
94
95
  /** `GET /v1/checkout/sessions`. List items never carry `client_secret`. */
95
96
  async list(params) {
@@ -106,7 +107,7 @@ export class CheckoutSessionsResource {
106
107
  * whose intent already has a live charge is refused `409`.
107
108
  */
108
109
  async expire(id, options) {
109
- return withRedactedInspect(await this.#http.request("POST", `/checkout/sessions/${encodeURIComponent(id)}/expire`, {}, options));
110
+ return withRedactedInspect(await this.#http.request("POST", `/checkout/sessions/${pathSegment(id)}/expire`, {}, options));
110
111
  }
111
112
  }
112
113
  /**
@@ -1,3 +1,4 @@
1
+ import { pathSegment } from "../validate.js";
1
2
  /**
2
3
  * One patch field, in the wire's own three-state encoding.
3
4
  *
@@ -79,7 +80,7 @@ export class CustomersResource {
79
80
  }
80
81
  /** `GET /v1/customers/{id}`. */
81
82
  async retrieve(id) {
82
- return this.#http.request("GET", `/customers/${encodeURIComponent(id)}`);
83
+ return this.#http.request("GET", `/customers/${pathSegment(id)}`);
83
84
  }
84
85
  /**
85
86
  * `POST /v1/customers/{id}` — the update.
@@ -116,7 +117,7 @@ export class CustomersResource {
116
117
  if (params.metadata !== undefined) {
117
118
  body["metadata"] = params.metadata;
118
119
  }
119
- return this.#http.request("POST", `/customers/${encodeURIComponent(id)}`, body, options);
120
+ return this.#http.request("POST", `/customers/${pathSegment(id)}`, body, options);
120
121
  }
121
122
  /** `GET /v1/customers`. */
122
123
  async list(params) {
@@ -152,6 +153,6 @@ export class CustomersResource {
152
153
  * than a `404`.
153
154
  */
154
155
  async del(id, options) {
155
- return this.#http.request("DELETE", `/customers/${encodeURIComponent(id)}`, undefined, options);
156
+ return this.#http.request("DELETE", `/customers/${pathSegment(id)}`, undefined, options);
156
157
  }
157
158
  }
@@ -1,4 +1,4 @@
1
- import { assertIntegerAmount } from "../validate.js";
1
+ import { assertIntegerAmount, pathSegment } from "../validate.js";
2
2
  /**
3
3
  * One patch field, in the wire's own three-state encoding.
4
4
  *
@@ -61,7 +61,7 @@ export class InvoicesResource {
61
61
  * never existed.
62
62
  */
63
63
  async retrieve(id) {
64
- return this.#http.request("GET", `/invoices/${encodeURIComponent(id)}`);
64
+ return this.#http.request("GET", `/invoices/${pathSegment(id)}`);
65
65
  }
66
66
  /**
67
67
  * `POST /v1/invoices/{id}` — patches a **draft**.
@@ -88,7 +88,7 @@ export class InvoicesResource {
88
88
  if (params.metadata !== undefined) {
89
89
  body["metadata"] = params.metadata;
90
90
  }
91
- return this.#http.request("POST", `/invoices/${encodeURIComponent(id)}`, body, options);
91
+ return this.#http.request("POST", `/invoices/${pathSegment(id)}`, body, options);
92
92
  }
93
93
  /** `GET /v1/invoices` — newest first. */
94
94
  async list(params) {
@@ -107,7 +107,7 @@ export class InvoicesResource {
107
107
  * delete answer the original `{deleted: true}` rather than a `404`.
108
108
  */
109
109
  async del(id, options) {
110
- return this.#http.request("DELETE", `/invoices/${encodeURIComponent(id)}`, undefined, options);
110
+ return this.#http.request("DELETE", `/invoices/${pathSegment(id)}`, undefined, options);
111
111
  }
112
112
  /**
113
113
  * `POST /v1/invoices/{id}/finalize` — issues the document. **Draft only.**
@@ -216,7 +216,7 @@ export class InvoicesResource {
216
216
  },
217
217
  }
218
218
  : { success_url: params.success_url, cancel_url: params.cancel_url };
219
- return this.#http.request("POST", `/invoices/${encodeURIComponent(id)}/pay`, body, options);
219
+ return this.#http.request("POST", `/invoices/${pathSegment(id)}/pay`, body, options);
220
220
  }
221
221
  /**
222
222
  * The three transitions that take no parameters, which are one shape:
@@ -228,7 +228,7 @@ export class InvoicesResource {
228
228
  * that is easy to get wrong in only one of them.
229
229
  */
230
230
  async #transition(id, name, options) {
231
- return this.#http.request("POST", `/invoices/${encodeURIComponent(id)}/${name}`, {}, options);
231
+ return this.#http.request("POST", `/invoices/${pathSegment(id)}/${name}`, {}, options);
232
232
  }
233
233
  }
234
234
  /**
@@ -271,7 +271,7 @@ export class InvoiceItemsResource {
271
271
  }
272
272
  /** `GET /v1/invoice_items/{id}` — readable whatever the parent's status. */
273
273
  async retrieve(id) {
274
- return this.#http.request("GET", `/invoice_items/${encodeURIComponent(id)}`);
274
+ return this.#http.request("GET", `/invoice_items/${pathSegment(id)}`);
275
275
  }
276
276
  /**
277
277
  * `POST /v1/invoice_items/{id}` — **draft parent only**, with `amount`
@@ -294,13 +294,13 @@ export class InvoiceItemsResource {
294
294
  if (params.unit_amount !== undefined) {
295
295
  body["unit_amount"] = params.unit_amount;
296
296
  }
297
- return this.#http.request("POST", `/invoice_items/${encodeURIComponent(id)}`, body, options);
297
+ return this.#http.request("POST", `/invoice_items/${pathSegment(id)}`, body, options);
298
298
  }
299
299
  /**
300
300
  * `DELETE /v1/invoice_items/{id}` — **draft parent only**, and the invoice
301
301
  * re-totals.
302
302
  */
303
303
  async del(id, options) {
304
- return this.#http.request("DELETE", `/invoice_items/${encodeURIComponent(id)}`, undefined, options);
304
+ return this.#http.request("DELETE", `/invoice_items/${pathSegment(id)}`, undefined, options);
305
305
  }
306
306
  }
@@ -1,4 +1,4 @@
1
- import { assertIntegerAmount } from "../validate.js";
1
+ import { assertIntegerAmount, pathSegment } from "../validate.js";
2
2
  /** `/v1/payment_intents` — docs/flows/merchant-auth.md, "Resources". */
3
3
  export class PaymentIntentsResource {
4
4
  #http;
@@ -24,7 +24,7 @@ export class PaymentIntentsResource {
24
24
  return this.#http.request("POST", "/payment_intents", body, options);
25
25
  }
26
26
  async retrieve(id) {
27
- return this.#http.request("GET", `/payment_intents/${encodeURIComponent(id)}`);
27
+ return this.#http.request("GET", `/payment_intents/${pathSegment(id)}`);
28
28
  }
29
29
  async confirm(id, params, options) {
30
30
  const body = {
@@ -33,10 +33,10 @@ export class PaymentIntentsResource {
33
33
  if ("return_url" in params) {
34
34
  body["return_url"] = params.return_url;
35
35
  }
36
- return this.#http.request("POST", `/payment_intents/${encodeURIComponent(id)}/confirm`, body, options);
36
+ return this.#http.request("POST", `/payment_intents/${pathSegment(id)}/confirm`, body, options);
37
37
  }
38
38
  async cancel(id, options) {
39
- return this.#http.request("POST", `/payment_intents/${encodeURIComponent(id)}/cancel`, {}, options);
39
+ return this.#http.request("POST", `/payment_intents/${pathSegment(id)}/cancel`, {}, options);
40
40
  }
41
41
  /** `GET /v1/payment_intents`, newest first; `customer` narrows it. */
42
42
  async list(params) {
@@ -10,6 +10,9 @@
10
10
  *
11
11
  * - `orange_money`'s refund is a declared `NotImplemented` token — an Orange
12
12
  * refund is an outbound transfer this repository has no specification for;
13
+ * - `notchpay`'s refund is a declared `NotImplemented` token too — Notch Pay
14
+ * documents refunds in a guide only, with nothing that makes a replay safe
15
+ * (RFC-0012 § 7);
13
16
  * - `mtn_momo`'s is MTN's Disbursements `transfer`, WireMock-proven and
14
17
  * rail-unproven: **no real MTN Disbursements credential exists in this
15
18
  * project** — the only `disbursement_subscription_key` anywhere is the stub
@@ -1,4 +1,4 @@
1
- import { assertIntegerAmount } from "../validate.js";
1
+ import { assertIntegerAmount, pathSegment } from "../validate.js";
2
2
  /**
3
3
  * The rail-agnostic envelope, built around the rail-specific interior.
4
4
  *
@@ -62,7 +62,7 @@ export class RefundsResource {
62
62
  * order.
63
63
  */
64
64
  async retrieve(id) {
65
- return this.#http.request("GET", `/refunds/${encodeURIComponent(id)}`);
65
+ return this.#http.request("GET", `/refunds/${pathSegment(id)}`);
66
66
  }
67
67
  /**
68
68
  * `POST /v1/refunds/{id}` — the **metadata** update, and nothing else.
@@ -82,7 +82,7 @@ export class RefundsResource {
82
82
  if (params.metadata !== undefined) {
83
83
  body["metadata"] = params.metadata;
84
84
  }
85
- return this.#http.request("POST", `/refunds/${encodeURIComponent(id)}`, body, options);
85
+ return this.#http.request("POST", `/refunds/${pathSegment(id)}`, body, options);
86
86
  }
87
87
  /** `GET /v1/refunds` — this merchant's refunds, newest first. */
88
88
  async list(params) {
@@ -101,6 +101,6 @@ export class RefundsResource {
101
101
  * rail was ever told to start. See the module doc.
102
102
  */
103
103
  async cancel(id, options) {
104
- return this.#http.request("POST", `/refunds/${encodeURIComponent(id)}/cancel`, {}, options);
104
+ return this.#http.request("POST", `/refunds/${pathSegment(id)}/cancel`, {}, options);
105
105
  }
106
106
  }
package/dist/types.d.ts CHANGED
@@ -10,7 +10,7 @@
10
10
  */
11
11
  export type PaymentIntentStatus = "requires_payment_method" | "requires_action" | "processing" | "succeeded" | "canceled";
12
12
  /** Rail codes this SDK's `payment_method_types` values may hold. */
13
- export type PaymentMethodType = "mtn_momo" | "orange_money";
13
+ export type PaymentMethodType = "mtn_momo" | "orange_money" | "notchpay";
14
14
  /** Redirect-rail next step, Stripe's own `next_action.redirect_to_url` shape. */
15
15
  export interface NextAction {
16
16
  type: "redirect_to_url";
@@ -1161,8 +1161,21 @@ export type MtnMomoPaymentMethodData = {
1161
1161
  export type OrangeMoneyPaymentMethodData = {
1162
1162
  type: "orange_money";
1163
1163
  };
1164
+ /**
1165
+ * Notch Pay (RFC-0012, 2026-10-20): a push rail, so the same instrument as
1166
+ * MTN's, under this rail's own code. The server's adapter is proven against a
1167
+ * stub only and has never called Notch Pay.
1168
+ */
1169
+ export type NotchPayPaymentMethodData = {
1170
+ type: "notchpay";
1171
+ notchpay: {
1172
+ msisdn: string;
1173
+ };
1174
+ };
1164
1175
  export type ConfirmPaymentIntentParams = {
1165
1176
  payment_method_data: MtnMomoPaymentMethodData;
1177
+ } | {
1178
+ payment_method_data: NotchPayPaymentMethodData;
1166
1179
  } | {
1167
1180
  payment_method_data: OrangeMoneyPaymentMethodData;
1168
1181
  return_url: string;
@@ -1323,8 +1336,8 @@ export type RefundDestination = {
1323
1336
  * # No refund this creates has ever moved money
1324
1337
  *
1325
1338
  * The route is real and so is every refusal it makes, but nothing on the
1326
- * other side of it pays anybody: `orange_money`'s refund is a declared
1327
- * `NotImplemented` token, `mtn_momo`'s is MTN's Disbursements `transfer`
1339
+ * other side of it pays anybody: `orange_money`'s and `notchpay`'s refunds
1340
+ * are declared `NotImplemented` tokens, `mtn_momo`'s is MTN's Disbursements `transfer`
1328
1341
  * which is WireMock-proven and for which **no real MTN credential exists in
1329
1342
  * this project** — the only `disbursement_subscription_key` anywhere is the
1330
1343
  * stub the e2e/demo stack points at a WireMock container, and the product has
@@ -1352,10 +1365,12 @@ export interface CreateRefundParams {
1352
1365
  *
1353
1366
  * Optional because the port allows a rail that refunds to the instrument
1354
1367
  * that paid, and such a rail refuses a `destination` with a `400`.
1355
- * **Neither rail vpay carries today is one**: `mtn_momo` and `orange_money`
1356
- * both declare that a refund needs a payee, so a create with no
1357
- * `destination` is a `400` naming `destination` against every deployment
1358
- * this repository can build.
1368
+ * `mtn_momo` and `orange_money` both declare that a refund needs a payee,
1369
+ * so a create with no `destination` is a `400` naming `destination` on
1370
+ * either. `notchpay` (2026-10-20) is the first shipping rail that refunds
1371
+ * to the instrument that paid and so refuses one — but its refund is a
1372
+ * declared `NotImplemented` token, so a refund there fails whatever it
1373
+ * carries.
1359
1374
  */
1360
1375
  destination?: RefundDestination | undefined;
1361
1376
  metadata?: Record<string, string> | undefined;
@@ -14,3 +14,22 @@
14
14
  * **safe** integer or nothing goes to the wire.
15
15
  */
16
16
  export declare function assertIntegerAmount(amount: number, field?: string): void;
17
+ /**
18
+ * Percent-encodes one merchant-supplied id for a URL path, and refuses the
19
+ * ids escaping cannot keep in place.
20
+ *
21
+ * `encodeURIComponent` keeps a `/`, `?` or `#` inside its segment, but it
22
+ * leaves `.` alone, so `""`, `.` and `..` pass through, and as whole segments
23
+ * `fetch`'s URL parser resolves them before the request leaves:
24
+ * `customers.del("..")` was a `DELETE /v1/`. Escaping the dots would not
25
+ * help, because WHATWG parsing reads `%2E%2E` as `..` too. So an empty or
26
+ * all-dots id throws before anything is sent, the token request included —
27
+ * `sdks/rust`'s `path_segment` refuses the same ids with
28
+ * `Error::InvalidParams`. `...` is refused by decision rather than by the
29
+ * standard: `docs/reference/rails.md` § "A path segment the caller chose"
30
+ * says why.
31
+ *
32
+ * A `TypeError`, like {@link assertIntegerAmount}'s refusal, and not a
33
+ * `VpayError` — the dated gap `docs/sdks/parity.md` records for both.
34
+ */
35
+ export declare function pathSegment(id: string): string;
package/dist/validate.js CHANGED
@@ -18,3 +18,35 @@ export function assertIntegerAmount(amount, field = "amount") {
18
18
  throw new TypeError(`${field} must be a non-negative safe integer in minor units, got ${amount}`);
19
19
  }
20
20
  }
21
+ /**
22
+ * The one message {@link pathSegment}'s refusal carries, whatever the id.
23
+ *
24
+ * Fixed so that it cannot quote the id, and byte-identical to `sdks/rust`'s
25
+ * `DOT_SEGMENT_REFUSAL` behind the same `invalid request parameter id: `
26
+ * prefix that crate's `Error::InvalidParams` displays.
27
+ */
28
+ const DOT_SEGMENT_REFUSAL = "an id that is empty or only dots would address a different endpoint once the URL is parsed; nothing was sent";
29
+ /**
30
+ * Percent-encodes one merchant-supplied id for a URL path, and refuses the
31
+ * ids escaping cannot keep in place.
32
+ *
33
+ * `encodeURIComponent` keeps a `/`, `?` or `#` inside its segment, but it
34
+ * leaves `.` alone, so `""`, `.` and `..` pass through, and as whole segments
35
+ * `fetch`'s URL parser resolves them before the request leaves:
36
+ * `customers.del("..")` was a `DELETE /v1/`. Escaping the dots would not
37
+ * help, because WHATWG parsing reads `%2E%2E` as `..` too. So an empty or
38
+ * all-dots id throws before anything is sent, the token request included —
39
+ * `sdks/rust`'s `path_segment` refuses the same ids with
40
+ * `Error::InvalidParams`. `...` is refused by decision rather than by the
41
+ * standard: `docs/reference/rails.md` § "A path segment the caller chose"
42
+ * says why.
43
+ *
44
+ * A `TypeError`, like {@link assertIntegerAmount}'s refusal, and not a
45
+ * `VpayError` — the dated gap `docs/sdks/parity.md` records for both.
46
+ */
47
+ export function pathSegment(id) {
48
+ if (/^\.*$/u.test(id)) {
49
+ throw new TypeError(`invalid request parameter id: ${DOT_SEGMENT_REFUSAL}`);
50
+ }
51
+ return encodeURIComponent(id);
52
+ }
package/dist/version.d.ts CHANGED
@@ -9,4 +9,4 @@
9
9
  * releases. A hand-written constant has no such landmine — it just needs to
10
10
  * be bumped alongside `package.json`'s own `version` field.
11
11
  */
12
- export declare const SDK_VERSION = "0.8.0";
12
+ export declare const SDK_VERSION = "0.9.1";
package/dist/version.js CHANGED
@@ -9,4 +9,4 @@
9
9
  * releases. A hand-written constant has no such landmine — it just needs to
10
10
  * be bumped alongside `package.json`'s own `version` field.
11
11
  */
12
- export const SDK_VERSION = "0.8.0"; // x-release-please-version
12
+ export const SDK_VERSION = "0.9.1"; // x-release-please-version
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vaam-apps/vpay-sdk",
3
- "version": "0.8.0",
3
+ "version": "0.9.1",
4
4
  "description": "The Node.js merchant SDK for vpay's /v1 API: OAuth2 private_key_jwt, payment intents, checkout sessions, refunds, events and webhook verification.",
5
5
  "license": "Apache-2.0",
6
6
  "publishConfig": {