@vaam-apps/vpay-sdk 0.9.0 → 0.10.0

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
@@ -225,6 +225,7 @@ works as a catch-all; narrow further with `instanceof` on a specific subclass:
225
225
  | `VpayUnexpectedResponseError` | A response that isn't the shape this SDK expects — a proxy's HTML 502, an empty body, or a token-endpoint 200 that is not `{ access_token, token_type: "Bearer", expires_in }` (a non-`Bearer` `token_type` lands here, not in `VpayAuthError`: nothing failed to authenticate, the SDK simply cannot present that token). | `status`, `bodyPrefix` (first 500 bytes) |
226
226
  | `VpayTransportError` | DNS, TLS, connection refused, timeout. | `cause` (the underlying error) |
227
227
  | `VpayConfigError` | The SDK was misconfigured — caught at construction where possible. | — |
228
+ | `VpayInvalidParamsError` | A request parameter this SDK refuses to send, caught before anything is sent: an amount outside `0..=2^53-1`, a value the form encoder cannot put on the wire exactly (a non-safe-integer number, a `Date`, a `Map`), an empty or all-dots path `id`, a URL beside `outOfBand`. Not a `TypeError` (2026-10-21). | `param`, `message` |
228
229
  | `WebhookSignatureError` | `verifyWebhook` rejected a signature. | — |
229
230
 
230
231
  ```ts
@@ -591,6 +592,40 @@ VPAY_AUDIENCE=https://api.vpay.example/v1/oauth/token \
591
592
 
592
593
  ## Status
593
594
 
595
+ **Breaking, 2026-10-21: every local refusal is a `VpayInvalidParamsError`,
596
+ not a `TypeError`.** An amount outside `0..=2^53-1`, a value the form
597
+ encoder cannot put on the wire exactly, an empty or all-dots path `id` and a
598
+ URL beside `outOfBand` on `invoices.pay` now reject with the new exported
599
+ `VpayInvalidParamsError`. It extends `VpayError`, so the README's
600
+ `instanceof VpayError` catch-all finally catches it, and its `param` names
601
+ the parameter, like `sdks/rust`'s `Error::InvalidParams { param, .. }`. Its
602
+ `message` is that variant's `Display` text,
603
+ `invalid request parameter {param}: {detail}`. **A caller that tested
604
+ `instanceof TypeError` for these no longer matches**, and cannot be made to:
605
+ a class has one superclass, and making `VpayError` a `TypeError` would make
606
+ every error here one, a network failure included. Switch to
607
+ `instanceof VpayInvalidParamsError`. No signature changed and nothing that
608
+ was sent before is refused now.
609
+
610
+ **Behaviour change, 2026-10-21: an empty or all-dots `id` is refused before
611
+ any request.** Every one of the twenty-one methods that put an `id` in the
612
+ path now rejects with a `TypeError` for `""`, `.`, `..` or any longer run of
613
+ dots, without minting a token or sending anything. No signature changed —
614
+ each already returned a `Promise` — but the outcome for those inputs did.
615
+ They used to be sent, and `fetch`'s URL parser resolved them before the
616
+ request left, so `customers.del("..")` was a `DELETE /v1/` and
617
+ `paymentIntents.retrieve("")` a `GET /v1/payment_intents/`; escaping cannot
618
+ help, because the parser reads `%2E%2E` as `..` too. What vpay answered for
619
+ each is on
620
+ [the dated verification page](../../docs/status/verification/2026-10-21-sdk-dot-segment-ids.md).
621
+ A caller catching `VpayApiError` for those inputs now gets a `TypeError`,
622
+ and so does one narrowing on `VpayError`: like the amount refusal, it is not
623
+ one (a dated gap in [docs/sdks/parity.md](../../docs/sdks/parity.md)).
624
+ `sdks/rust` refuses the same ids with `Error::InvalidParams`. _(Corrected
625
+ later the same day: the refusal is a `VpayInvalidParamsError` now, so a
626
+ `VpayError` narrowing catches it and the gap is closed — the paragraph
627
+ above.)_
628
+
594
629
  **Corrected 2026-09-03.** This section used to say "what it does **not**
595
630
  have is a single `/v1` resource route" — true when it was written on
596
631
  2026-09-02, and false since Step 2 landed the next day.
@@ -672,7 +707,17 @@ none of them touches the network:
672
707
  session's `url`, which carries the same value — are redacted from
673
708
  `util.inspect` while `JSON.stringify` stays faithful.
674
709
  - The form encoder's nested-object/array/boolean/percent-encoding rules, and
675
- that a non-integer `amount` throws `TypeError` before any request is sent.
710
+ that a non-integer `amount` throws `VpayInvalidParamsError` before any
711
+ request is sent.
712
+ - That every kind of local refusal — an amount, an id, an out-of-band URL, a
713
+ `Date` in `metadata` — is a `VpayInvalidParamsError` naming its parameter,
714
+ caught by `instanceof VpayError` and not a `TypeError`, with nothing sent.
715
+ - That an empty or all-dots `id` throws `VpayInvalidParamsError` on all twenty-one
716
+ id-taking methods before any request, the token request included, with one
717
+ fixed message that never quotes it; that an id with dots in it that is not
718
+ only dots (`a..b`, `.a`, `%2E%2E`) is still sent as exactly one segment;
719
+ and, as the premise, that WHATWG `URL` resolves `..`, `%2E%2E` and `.%2e`
720
+ alike.
676
721
  - Error mapping for a Stripe-shaped `400`, an unexpected `502` HTML body, a
677
722
  connection failure, and a token-endpoint `401` (which is never retried).
678
723
  - Webhook verification: valid/invalid signatures, timestamp tolerance,
@@ -697,8 +742,8 @@ none of them touches the network:
697
742
  both URLs and answers an invoice still `"open"` with a `payment_intent` and
698
743
  a `hosted_invoice_url`; the four `invoiceItems` methods, including that
699
744
  neither `currency` nor `amount` is ever in the body; and that a
700
- `unit_amount` outside the safe-integer range throws `TypeError` before any
701
- request. Every body string is the one `sdks/rust/tests/resources.rs` pins,
745
+ `unit_amount` outside the safe-integer range throws `VpayInvalidParamsError`
746
+ before any request. Every body string is the one `sdks/rust/tests/resources.rs` pins,
702
747
  asserted as a literal on both sides.
703
748
  - **Against a real `vpay-server`, not a stub** (added 2026-09-08):
704
749
  `src/invoices.live.test.ts` creates a customer, drafts an invoice, bills two
package/dist/errors.d.ts CHANGED
@@ -57,6 +57,32 @@ export declare class VpayUnexpectedResponseError extends VpayError {
57
57
  readonly bodyPrefix: string;
58
58
  constructor(status: number, bodyPrefix: string);
59
59
  }
60
+ /**
61
+ * A request parameter this SDK refuses to put on the wire, caught before
62
+ * anything is sent — no token is minted and no request is built. The twin
63
+ * of `sdks/rust`'s `Error::InvalidParams { param, message }`, and its
64
+ * `message` is that variant's `Display` text: `invalid request parameter
65
+ * {param}: {detail}`.
66
+ *
67
+ * Thrown for an amount outside `0..=2^53-1`, a value the form encoder cannot
68
+ * put on the wire exactly (a non-safe-integer number, a `Date`, a `Map`, a
69
+ * class instance), a path `id` that is empty or only dots, and a URL beside
70
+ * `outOfBand` on `invoices.pay`.
71
+ *
72
+ * **Not a `TypeError`, since 2026-10-21.** Every one of these was a bare
73
+ * `TypeError` until then, which the one narrowing this package documents —
74
+ * `instanceof VpayError` — missed. It cannot be both: a class has one
75
+ * superclass, and making `VpayError` extend `TypeError` would make every
76
+ * error this package throws a `TypeError`, a network failure included.
77
+ */
78
+ export declare class VpayInvalidParamsError extends VpayError {
79
+ /**
80
+ * The offending parameter's wire name — `amount`, `unit_amount`, `id`,
81
+ * `success_url`, or a nested form path such as `metadata[order_id]`.
82
+ */
83
+ readonly param: string;
84
+ constructor(param: string, detail: string);
85
+ }
60
86
  /** A transport-level failure — DNS, TLS, connection refused, timeout. */
61
87
  export declare class VpayTransportError extends VpayError {
62
88
  constructor(message: string, options?: ErrorOptions);
package/dist/errors.js CHANGED
@@ -69,6 +69,35 @@ export class VpayUnexpectedResponseError extends VpayError {
69
69
  this.bodyPrefix = bodyPrefix;
70
70
  }
71
71
  }
72
+ /**
73
+ * A request parameter this SDK refuses to put on the wire, caught before
74
+ * anything is sent — no token is minted and no request is built. The twin
75
+ * of `sdks/rust`'s `Error::InvalidParams { param, message }`, and its
76
+ * `message` is that variant's `Display` text: `invalid request parameter
77
+ * {param}: {detail}`.
78
+ *
79
+ * Thrown for an amount outside `0..=2^53-1`, a value the form encoder cannot
80
+ * put on the wire exactly (a non-safe-integer number, a `Date`, a `Map`, a
81
+ * class instance), a path `id` that is empty or only dots, and a URL beside
82
+ * `outOfBand` on `invoices.pay`.
83
+ *
84
+ * **Not a `TypeError`, since 2026-10-21.** Every one of these was a bare
85
+ * `TypeError` until then, which the one narrowing this package documents —
86
+ * `instanceof VpayError` — missed. It cannot be both: a class has one
87
+ * superclass, and making `VpayError` extend `TypeError` would make every
88
+ * error this package throws a `TypeError`, a network failure included.
89
+ */
90
+ export class VpayInvalidParamsError extends VpayError {
91
+ /**
92
+ * The offending parameter's wire name — `amount`, `unit_amount`, `id`,
93
+ * `success_url`, or a nested form path such as `metadata[order_id]`.
94
+ */
95
+ param;
96
+ constructor(param, detail) {
97
+ super(`invalid request parameter ${param}: ${detail}`);
98
+ this.param = param;
99
+ }
100
+ }
72
101
  /** A transport-level failure — DNS, TLS, connection refused, timeout. */
73
102
  export class VpayTransportError extends VpayError {
74
103
  constructor(message, options) {
package/dist/form.d.ts CHANGED
@@ -30,6 +30,9 @@
30
30
  * is decimal integers with no separators (docs/flows/money.md bans floating
31
31
  * point in the money path outright), so a float or a `1e21` is a bug in the
32
32
  * caller, not a value to transmit approximately.
33
+ *
34
+ * Both refusals are a {@link VpayInvalidParamsError} naming the value's form
35
+ * path (`amount`, `metadata[order_id]`), thrown before anything is sent.
33
36
  */
34
37
  /** A value the form encoder can flatten. `null` is treated like `undefined`: omitted. */
35
38
  export type FormValue = string | number | boolean | undefined | null | readonly FormValue[] | {
package/dist/form.js CHANGED
@@ -30,8 +30,12 @@
30
30
  * is decimal integers with no separators (docs/flows/money.md bans floating
31
31
  * point in the money path outright), so a float or a `1e21` is a bug in the
32
32
  * caller, not a value to transmit approximately.
33
+ *
34
+ * Both refusals are a {@link VpayInvalidParamsError} naming the value's form
35
+ * path (`amount`, `metadata[order_id]`), thrown before anything is sent.
33
36
  */
34
- /** Renders a path back into the bracket syntax, for error messages only. */
37
+ import { VpayInvalidParamsError } from "./errors.js";
38
+ /** Renders a path back into the bracket syntax, for an error's `param` only. */
35
39
  function describePath(path) {
36
40
  const [head, ...rest] = path;
37
41
  return `${head ?? ""}${rest.map((segment) => `[${segment}]`).join("")}`;
@@ -53,7 +57,7 @@ function isPlainObject(value) {
53
57
  /** Renders a number for the wire, refusing anything that is not an exact decimal integer. */
54
58
  function encodeNumber(value, path) {
55
59
  if (!Number.isSafeInteger(value)) {
56
- throw new TypeError(`${describePath(path)} must be a safe integer, got ${value}`);
60
+ throw new VpayInvalidParamsError(describePath(path), `must be a safe integer, got ${value}`);
57
61
  }
58
62
  const text = String(value);
59
63
  // Subsumed by the check above as the language stands today — JavaScript
@@ -63,7 +67,7 @@ function encodeNumber(value, path) {
63
67
  // format actually requires: no test can kill this branch on its own, and
64
68
  // the comment says so rather than implying it is covered.
65
69
  if (text.includes("e") || text.includes("E")) {
66
- throw new TypeError(`${describePath(path)} must encode as a plain decimal integer, got ${text}`);
70
+ throw new VpayInvalidParamsError(describePath(path), `must encode as a plain decimal integer, got ${text}`);
67
71
  }
68
72
  return text;
69
73
  }
@@ -77,7 +81,7 @@ function flattenValue(path, value, out) {
77
81
  }
78
82
  if (typeof value === "object") {
79
83
  if (!isPlainObject(value)) {
80
- throw new TypeError(`${describePath(path)} must be a plain object, array or scalar, got ${value.constructor?.name ?? "an object"}`);
84
+ throw new VpayInvalidParamsError(describePath(path), `must be a plain object, array or scalar, got ${value.constructor?.name ?? "an object"}`);
81
85
  }
82
86
  for (const [childKey, childValue] of Object.entries(value)) {
83
87
  flattenValue([...path, childKey], childValue, out);
package/dist/index.d.ts CHANGED
@@ -10,6 +10,6 @@ export { mintClientAssertion, type MintClientAssertionOptions, } from "./auth.js
10
10
  export { verifyWebhook, type VerifyWebhookOptions } from "./webhooks.js";
11
11
  /** This package's own version, as sent in the `vpay-sdk-node/<version>` User-Agent. */
12
12
  export { SDK_VERSION } from "./version.js";
13
- export { VpayError, VpayApiError, VpayAuthError, VpayUnexpectedResponseError, VpayTransportError, VpayConfigError, WebhookSignatureError, } from "./errors.js";
13
+ export { VpayError, VpayApiError, VpayAuthError, VpayUnexpectedResponseError, VpayTransportError, VpayConfigError, VpayInvalidParamsError, WebhookSignatureError, } from "./errors.js";
14
14
  export type { AccountHolder, RetrieveAccountHolderParams, CheckoutSession, CheckoutSessionStatus, CheckoutSessionUiMode, CheckoutPaymentStatus, CreateCheckoutSessionParams, ListCheckoutSessionsParams, Customer, DeletedCustomer, Address, AddressParams, CreateCustomerParams, UpdateCustomerParams, ListCustomersParams, Invoice, InvoiceLine, InvoiceStatus, InvoiceStatusTransitions, DeletedInvoice, DeletedInvoiceItem, CreateInvoiceParams, UpdateInvoiceParams, ListInvoicesParams, PayInvoiceParams, HostedPayInvoiceParams, OutOfBandPayInvoiceParams, OutOfBandParams, OutOfBandMethod, OutOfBandPayment, CreateInvoiceItemParams, UpdateInvoiceItemParams, PaymentIntent, PaymentIntentStatus, PaymentMethodType, NextAction, LastPaymentError, FailureCode, Refund, RefundStatus, Event, KnownEventType, Balance, BalanceAmount, List, ListParams, ListPaymentIntentsParams, ListEventsParams, CreatePaymentIntentParams, ConfirmPaymentIntentParams, MtnMomoPaymentMethodData, OrangeMoneyPaymentMethodData, CreateRefundParams, RefundDestination, UpdateRefundParams, ListRefundsParams, RequestOptions, } from "./types.js";
15
15
  export { isCheckoutSessionEvent, isInvoiceEvent, isPaymentIntentEvent, isRefundEvent, } from "./types.js";
package/dist/index.js CHANGED
@@ -10,5 +10,5 @@ export { mintClientAssertion, } from "./auth.js";
10
10
  export { verifyWebhook } from "./webhooks.js";
11
11
  /** This package's own version, as sent in the `vpay-sdk-node/<version>` User-Agent. */
12
12
  export { SDK_VERSION } from "./version.js";
13
- export { VpayError, VpayApiError, VpayAuthError, VpayUnexpectedResponseError, VpayTransportError, VpayConfigError, WebhookSignatureError, } from "./errors.js";
13
+ export { VpayError, VpayApiError, VpayAuthError, VpayUnexpectedResponseError, VpayTransportError, VpayConfigError, VpayInvalidParamsError, WebhookSignatureError, } from "./errors.js";
14
14
  export { isCheckoutSessionEvent, isInvoiceEvent, isPaymentIntentEvent, isRefundEvent, } from "./types.js";
@@ -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
  }
@@ -153,7 +153,8 @@ export declare class InvoicesResource {
153
153
  * `invoice.paid` emitted, no intent, no checkout, no URL, and nothing
154
154
  * verified or posted to any ledger. While an intent that is not canceled is
155
155
  * attached it is the same `409` as `void`; cancel the intent first. A URL
156
- * beside `outOfBand` is refused with a `TypeError` before any request.
156
+ * beside `outOfBand` is refused with a `VpayInvalidParamsError` naming it,
157
+ * before any request.
157
158
  */
158
159
  pay(id: string, params: PayInvoiceParams, options?: RequestOptions): Promise<Invoice>;
159
160
  }
@@ -177,7 +178,7 @@ export declare class InvoiceItemsResource {
177
178
  * `POST /v1/invoice_items` — adds a line to a draft, and the invoice's
178
179
  * `amount_due` moves with it.
179
180
  *
180
- * Throws `TypeError` before any request when `unit_amount` is not a
181
+ * Throws `VpayInvalidParamsError` before any request when `unit_amount` is not a
181
182
  * non-negative safe integer; a `400` naming `invoice` when it is not one of
182
183
  * your drafts.
183
184
  */
@@ -188,7 +189,7 @@ export declare class InvoiceItemsResource {
188
189
  * `POST /v1/invoice_items/{id}` — **draft parent only**, with `amount`
189
190
  * recomputed.
190
191
  *
191
- * Throws `TypeError` before any request for a `unit_amount` outside the
192
+ * Throws `VpayInvalidParamsError` before any request for a `unit_amount` outside the
192
193
  * bound; a `409` once the parent is no longer a draft.
193
194
  */
194
195
  update(id: string, params: UpdateInvoiceItemParams, options?: RequestOptions): Promise<InvoiceLine>;
@@ -1,4 +1,5 @@
1
- import { assertIntegerAmount } from "../validate.js";
1
+ import { VpayInvalidParamsError } from "../errors.js";
2
+ import { assertIntegerAmount, pathSegment } from "../validate.js";
2
3
  /**
3
4
  * One patch field, in the wire's own three-state encoding.
4
5
  *
@@ -61,7 +62,7 @@ export class InvoicesResource {
61
62
  * never existed.
62
63
  */
63
64
  async retrieve(id) {
64
- return this.#http.request("GET", `/invoices/${encodeURIComponent(id)}`);
65
+ return this.#http.request("GET", `/invoices/${pathSegment(id)}`);
65
66
  }
66
67
  /**
67
68
  * `POST /v1/invoices/{id}` — patches a **draft**.
@@ -88,7 +89,7 @@ export class InvoicesResource {
88
89
  if (params.metadata !== undefined) {
89
90
  body["metadata"] = params.metadata;
90
91
  }
91
- return this.#http.request("POST", `/invoices/${encodeURIComponent(id)}`, body, options);
92
+ return this.#http.request("POST", `/invoices/${pathSegment(id)}`, body, options);
92
93
  }
93
94
  /** `GET /v1/invoices` — newest first. */
94
95
  async list(params) {
@@ -107,7 +108,7 @@ export class InvoicesResource {
107
108
  * delete answer the original `{deleted: true}` rather than a `404`.
108
109
  */
109
110
  async del(id, options) {
110
- return this.#http.request("DELETE", `/invoices/${encodeURIComponent(id)}`, undefined, options);
111
+ return this.#http.request("DELETE", `/invoices/${pathSegment(id)}`, undefined, options);
111
112
  }
112
113
  /**
113
114
  * `POST /v1/invoices/{id}/finalize` — issues the document. **Draft only.**
@@ -189,7 +190,8 @@ export class InvoicesResource {
189
190
  * `invoice.paid` emitted, no intent, no checkout, no URL, and nothing
190
191
  * verified or posted to any ledger. While an intent that is not canceled is
191
192
  * attached it is the same `409` as `void`; cancel the intent first. A URL
192
- * beside `outOfBand` is refused with a `TypeError` before any request.
193
+ * beside `outOfBand` is refused with a `VpayInvalidParamsError` naming it,
194
+ * before any request.
193
195
  */
194
196
  async pay(id, params, options) {
195
197
  const outOfBand = params.outOfBand;
@@ -200,7 +202,7 @@ export class InvoicesResource {
200
202
  for (const param of ["success_url", "cancel_url"]) {
201
203
  const value = params[param];
202
204
  if (value !== undefined && value !== "") {
203
- throw new TypeError(`${param} has no meaning when recording a payment made outside vpay (outOfBand is set)`);
205
+ throw new VpayInvalidParamsError(param, "has no meaning when recording a payment made outside vpay (outOfBand is set)");
204
206
  }
205
207
  }
206
208
  }
@@ -216,7 +218,7 @@ export class InvoicesResource {
216
218
  },
217
219
  }
218
220
  : { success_url: params.success_url, cancel_url: params.cancel_url };
219
- return this.#http.request("POST", `/invoices/${encodeURIComponent(id)}/pay`, body, options);
221
+ return this.#http.request("POST", `/invoices/${pathSegment(id)}/pay`, body, options);
220
222
  }
221
223
  /**
222
224
  * The three transitions that take no parameters, which are one shape:
@@ -228,7 +230,7 @@ export class InvoicesResource {
228
230
  * that is easy to get wrong in only one of them.
229
231
  */
230
232
  async #transition(id, name, options) {
231
- return this.#http.request("POST", `/invoices/${encodeURIComponent(id)}/${name}`, {}, options);
233
+ return this.#http.request("POST", `/invoices/${pathSegment(id)}/${name}`, {}, options);
232
234
  }
233
235
  }
234
236
  /**
@@ -253,7 +255,7 @@ export class InvoiceItemsResource {
253
255
  * `POST /v1/invoice_items` — adds a line to a draft, and the invoice's
254
256
  * `amount_due` moves with it.
255
257
  *
256
- * Throws `TypeError` before any request when `unit_amount` is not a
258
+ * Throws `VpayInvalidParamsError` before any request when `unit_amount` is not a
257
259
  * non-negative safe integer; a `400` naming `invoice` when it is not one of
258
260
  * your drafts.
259
261
  */
@@ -271,13 +273,13 @@ export class InvoiceItemsResource {
271
273
  }
272
274
  /** `GET /v1/invoice_items/{id}` — readable whatever the parent's status. */
273
275
  async retrieve(id) {
274
- return this.#http.request("GET", `/invoice_items/${encodeURIComponent(id)}`);
276
+ return this.#http.request("GET", `/invoice_items/${pathSegment(id)}`);
275
277
  }
276
278
  /**
277
279
  * `POST /v1/invoice_items/{id}` — **draft parent only**, with `amount`
278
280
  * recomputed.
279
281
  *
280
- * Throws `TypeError` before any request for a `unit_amount` outside the
282
+ * Throws `VpayInvalidParamsError` before any request for a `unit_amount` outside the
281
283
  * bound; a `409` once the parent is no longer a draft.
282
284
  */
283
285
  async update(id, params, options) {
@@ -294,13 +296,13 @@ export class InvoiceItemsResource {
294
296
  if (params.unit_amount !== undefined) {
295
297
  body["unit_amount"] = params.unit_amount;
296
298
  }
297
- return this.#http.request("POST", `/invoice_items/${encodeURIComponent(id)}`, body, options);
299
+ return this.#http.request("POST", `/invoice_items/${pathSegment(id)}`, body, options);
298
300
  }
299
301
  /**
300
302
  * `DELETE /v1/invoice_items/{id}` — **draft parent only**, and the invoice
301
303
  * re-totals.
302
304
  */
303
305
  async del(id, options) {
304
- return this.#http.request("DELETE", `/invoice_items/${encodeURIComponent(id)}`, undefined, options);
306
+ return this.#http.request("DELETE", `/invoice_items/${pathSegment(id)}`, undefined, options);
305
307
  }
306
308
  }
@@ -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) {
@@ -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
@@ -1109,8 +1109,8 @@ export interface CreateInvoiceItemParams {
1109
1109
  quantity?: number | undefined;
1110
1110
  /**
1111
1111
  * The price of one, in integer minor units. A non-integer, a negative, or
1112
- * anything past `Number.MAX_SAFE_INTEGER` throws `TypeError` before any
1113
- * request — `sdks/rust` refuses the identical set.
1112
+ * anything past `Number.MAX_SAFE_INTEGER` throws `VpayInvalidParamsError`
1113
+ * before any request — `sdks/rust` refuses the identical set.
1114
1114
  */
1115
1115
  unit_amount: number;
1116
1116
  }
@@ -1134,7 +1134,8 @@ export interface UpdateInvoiceItemParams {
1134
1134
  export interface CreatePaymentIntentParams {
1135
1135
  /**
1136
1136
  * Integer minor units. A non-integer, a negative, or anything past
1137
- * `Number.MAX_SAFE_INTEGER` throws `TypeError` before any request.
1137
+ * `Number.MAX_SAFE_INTEGER` throws `VpayInvalidParamsError` before any
1138
+ * request.
1138
1139
  */
1139
1140
  amount: number;
1140
1141
  currency: string;
@@ -1164,7 +1165,8 @@ export type OrangeMoneyPaymentMethodData = {
1164
1165
  /**
1165
1166
  * Notch Pay (RFC-0012, 2026-10-20): a push rail, so the same instrument as
1166
1167
  * 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
+ * stub and took three test-mode payments through Notch Pay's real sandbox on
1169
+ * 2026-10-21 (RFC-0012 step 5); it has never called Notch Pay's production.
1168
1170
  */
1169
1171
  export type NotchPayPaymentMethodData = {
1170
1172
  type: "notchpay";
@@ -1,5 +1,8 @@
1
1
  /**
2
2
  * Shared request-shape validation, run before anything reaches the wire.
3
+ *
4
+ * Every refusal here is a {@link VpayInvalidParamsError}, so it is caught by
5
+ * the same `instanceof VpayError` as every other error this package throws.
3
6
  */
4
7
  /**
5
8
  * Mirrors `@vpay/api-client`'s `formatAmount` integer check
@@ -12,5 +15,25 @@
12
15
  * `Number.MAX_SAFE_INTEGER`, where the value the caller wrote and the value
13
16
  * JavaScript holds have already diverged. An amount must be a non-negative
14
17
  * **safe** integer or nothing goes to the wire.
18
+ *
19
+ * @throws {VpayInvalidParamsError} naming `field`.
15
20
  */
16
21
  export declare function assertIntegerAmount(amount: number, field?: string): void;
22
+ /**
23
+ * Percent-encodes one merchant-supplied id for a URL path, and refuses the
24
+ * ids escaping cannot keep in place.
25
+ *
26
+ * `encodeURIComponent` keeps a `/`, `?` or `#` inside its segment, but it
27
+ * leaves `.` alone, so `""`, `.` and `..` pass through, and as whole segments
28
+ * `fetch`'s URL parser resolves them before the request leaves:
29
+ * `customers.del("..")` was a `DELETE /v1/`. Escaping the dots would not
30
+ * help, because WHATWG parsing reads `%2E%2E` as `..` too. So an empty or
31
+ * all-dots id throws before anything is sent, the token request included —
32
+ * `sdks/rust`'s `path_segment` refuses the same ids with
33
+ * `Error::InvalidParams`. `...` is refused by decision rather than by the
34
+ * standard: `docs/reference/rails.md` § "A path segment the caller chose"
35
+ * says why.
36
+ *
37
+ * @throws {VpayInvalidParamsError} naming `id`.
38
+ */
39
+ export declare function pathSegment(id: string): string;
package/dist/validate.js CHANGED
@@ -1,6 +1,10 @@
1
1
  /**
2
2
  * Shared request-shape validation, run before anything reaches the wire.
3
+ *
4
+ * Every refusal here is a {@link VpayInvalidParamsError}, so it is caught by
5
+ * the same `instanceof VpayError` as every other error this package throws.
3
6
  */
7
+ import { VpayInvalidParamsError } from "./errors.js";
4
8
  /**
5
9
  * Mirrors `@vpay/api-client`'s `formatAmount` integer check
6
10
  * (frontends/packages/api-client/src/index.ts) and
@@ -12,9 +16,43 @@
12
16
  * `Number.MAX_SAFE_INTEGER`, where the value the caller wrote and the value
13
17
  * JavaScript holds have already diverged. An amount must be a non-negative
14
18
  * **safe** integer or nothing goes to the wire.
19
+ *
20
+ * @throws {VpayInvalidParamsError} naming `field`.
15
21
  */
16
22
  export function assertIntegerAmount(amount, field = "amount") {
17
23
  if (!Number.isSafeInteger(amount) || amount < 0) {
18
- throw new TypeError(`${field} must be a non-negative safe integer in minor units, got ${amount}`);
24
+ throw new VpayInvalidParamsError(field, `must be a non-negative safe integer in minor units, got ${amount}`);
25
+ }
26
+ }
27
+ /**
28
+ * The one message {@link pathSegment}'s refusal carries, whatever the id.
29
+ *
30
+ * Fixed so that it cannot quote the id, and byte-identical to `sdks/rust`'s
31
+ * `DOT_SEGMENT_REFUSAL`. {@link VpayInvalidParamsError} puts the same
32
+ * `invalid request parameter id: ` prefix in front of it that crate's
33
+ * `Error::InvalidParams` displays.
34
+ */
35
+ 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";
36
+ /**
37
+ * Percent-encodes one merchant-supplied id for a URL path, and refuses the
38
+ * ids escaping cannot keep in place.
39
+ *
40
+ * `encodeURIComponent` keeps a `/`, `?` or `#` inside its segment, but it
41
+ * leaves `.` alone, so `""`, `.` and `..` pass through, and as whole segments
42
+ * `fetch`'s URL parser resolves them before the request leaves:
43
+ * `customers.del("..")` was a `DELETE /v1/`. Escaping the dots would not
44
+ * help, because WHATWG parsing reads `%2E%2E` as `..` too. So an empty or
45
+ * all-dots id throws before anything is sent, the token request included —
46
+ * `sdks/rust`'s `path_segment` refuses the same ids with
47
+ * `Error::InvalidParams`. `...` is refused by decision rather than by the
48
+ * standard: `docs/reference/rails.md` § "A path segment the caller chose"
49
+ * says why.
50
+ *
51
+ * @throws {VpayInvalidParamsError} naming `id`.
52
+ */
53
+ export function pathSegment(id) {
54
+ if (/^\.*$/u.test(id)) {
55
+ throw new VpayInvalidParamsError("id", DOT_SEGMENT_REFUSAL);
19
56
  }
57
+ return encodeURIComponent(id);
20
58
  }
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.9.0";
12
+ export declare const SDK_VERSION = "0.10.0";
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.9.0"; // x-release-please-version
12
+ export const SDK_VERSION = "0.10.0"; // x-release-please-version
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vaam-apps/vpay-sdk",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
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": {