@vaam-apps/vpay-sdk 0.2.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/LICENSE +21 -0
- package/README.md +732 -0
- package/dist/auth.d.ts +180 -0
- package/dist/auth.js +297 -0
- package/dist/client.d.ts +75 -0
- package/dist/client.js +79 -0
- package/dist/errors.d.ts +91 -0
- package/dist/errors.js +112 -0
- package/dist/form.d.ts +47 -0
- package/dist/form.js +127 -0
- package/dist/http.d.ts +33 -0
- package/dist/http.js +115 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +14 -0
- package/dist/resources/account-holders.d.ts +36 -0
- package/dist/resources/account-holders.js +46 -0
- package/dist/resources/balance.d.ts +8 -0
- package/dist/resources/balance.js +10 -0
- package/dist/resources/checkout-sessions.d.ts +55 -0
- package/dist/resources/checkout-sessions.js +127 -0
- package/dist/resources/customers.d.ts +97 -0
- package/dist/resources/customers.js +157 -0
- package/dist/resources/events.d.ts +8 -0
- package/dist/resources/events.js +10 -0
- package/dist/resources/invoices.d.ts +193 -0
- package/dist/resources/invoices.js +275 -0
- package/dist/resources/payment-intents.d.ts +12 -0
- package/dist/resources/payment-intents.js +44 -0
- package/dist/resources/refunds.d.ts +92 -0
- package/dist/resources/refunds.js +106 -0
- package/dist/stripe-auth.d.ts +177 -0
- package/dist/stripe-auth.js +192 -0
- package/dist/types.d.ts +1268 -0
- package/dist/types.js +48 -0
- package/dist/validate.d.ts +16 -0
- package/dist/validate.js +20 -0
- package/dist/version.d.ts +12 -0
- package/dist/version.js +12 -0
- package/dist/webhooks.d.ts +22 -0
- package/dist/webhooks.js +94 -0
- package/package.json +63 -0
- package/scripts/mint-assertion.mjs +73 -0
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Error taxonomy for `@vaam-apps/vpay-sdk`, per docs/flows/merchant-auth.md's
|
|
3
|
+
* "Errors" section and the token-endpoint error shape it documents.
|
|
4
|
+
*
|
|
5
|
+
* Every error extends {@link VpayError} so callers can `catch (err) { if
|
|
6
|
+
* (err instanceof VpayError) ... }` once and still narrow further by
|
|
7
|
+
* `err.name` or an `instanceof` check on a specific subclass.
|
|
8
|
+
*/
|
|
9
|
+
/** Base class for every error this SDK throws. */
|
|
10
|
+
export declare class VpayError extends Error {
|
|
11
|
+
constructor(message: string, options?: ErrorOptions);
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* A non-2xx response from a `/v1` resource route, shaped like
|
|
15
|
+
* `vpay_api::error_envelope`: `{ "error": { "type", "code", "message",
|
|
16
|
+
* "param" } }`.
|
|
17
|
+
*/
|
|
18
|
+
export declare class VpayApiError extends VpayError {
|
|
19
|
+
/** The HTTP status code of the response. */
|
|
20
|
+
readonly status: number;
|
|
21
|
+
/** Stripe-shaped error type, e.g. `invalid_request_error`. */
|
|
22
|
+
readonly type: string;
|
|
23
|
+
/** Machine-readable error code, when the server sent one. */
|
|
24
|
+
readonly code: string | undefined;
|
|
25
|
+
/** The offending request parameter, when the server named one. */
|
|
26
|
+
readonly param: string | undefined;
|
|
27
|
+
constructor(status: number, body: {
|
|
28
|
+
type: string;
|
|
29
|
+
code?: string | undefined;
|
|
30
|
+
message: string;
|
|
31
|
+
param?: string | undefined;
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* A `400`/`401` from the OAuth2 token endpoint:
|
|
36
|
+
* `{ "error": "invalid_client", "error_description": "…" }`
|
|
37
|
+
* (`TokenErrorResponse` in `authkestra-op`). Never retried automatically —
|
|
38
|
+
* see docs/flows/merchant-auth.md, "Re-authentication".
|
|
39
|
+
*/
|
|
40
|
+
export declare class VpayAuthError extends VpayError {
|
|
41
|
+
/** The OAuth2 `error` code, e.g. `invalid_client`. */
|
|
42
|
+
readonly error: string;
|
|
43
|
+
/** Human-readable detail, when the server sent one. */
|
|
44
|
+
readonly errorDescription: string | undefined;
|
|
45
|
+
constructor(error: string, errorDescription: string | undefined);
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* A response that is not the Stripe-shaped error envelope this SDK expects —
|
|
49
|
+
* a proxy's HTML 502, an empty body, or JSON with an unrecognised shape.
|
|
50
|
+
* Carries a bounded prefix of the body so the cause is diagnosable without
|
|
51
|
+
* risking an unbounded string in logs.
|
|
52
|
+
*/
|
|
53
|
+
export declare class VpayUnexpectedResponseError extends VpayError {
|
|
54
|
+
/** The HTTP status code of the response. */
|
|
55
|
+
readonly status: number;
|
|
56
|
+
/** At most the first 500 **bytes** of the response body, decoded as UTF-8. */
|
|
57
|
+
readonly bodyPrefix: string;
|
|
58
|
+
constructor(status: number, bodyPrefix: string);
|
|
59
|
+
}
|
|
60
|
+
/** A transport-level failure — DNS, TLS, connection refused, timeout. */
|
|
61
|
+
export declare class VpayTransportError extends VpayError {
|
|
62
|
+
constructor(message: string, options?: ErrorOptions);
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* The SDK was configured incorrectly — caught before any request is sent.
|
|
66
|
+
*
|
|
67
|
+
* Almost always at construction (a missing `clientId`, an unreadable
|
|
68
|
+
* `privateKey`, an `assertionLifetimeSeconds` outside the OP's `1..=300`).
|
|
69
|
+
* The one exception is `@vaam-apps/vpay-sdk/stripe`'s host binding: an authenticator
|
|
70
|
+
* asked to sign a request addressed somewhere other than its `baseUrl`
|
|
71
|
+
* throws this *per request*, because that is when the destination becomes
|
|
72
|
+
* knowable. Still before anything is sent — no token is minted and no
|
|
73
|
+
* `Authorization` header is written.
|
|
74
|
+
*/
|
|
75
|
+
export declare class VpayConfigError extends VpayError {
|
|
76
|
+
}
|
|
77
|
+
/** A webhook signature failed verification — see {@link verifyWebhook}. */
|
|
78
|
+
export declare class WebhookSignatureError extends VpayError {
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Truncates a response body to a bounded prefix for
|
|
82
|
+
* {@link VpayUnexpectedResponseError}.
|
|
83
|
+
*
|
|
84
|
+
* The bound is on **bytes**, not `String.prototype.length`: a body of
|
|
85
|
+
* multi-byte characters (a UTF-8 error page, a JSON body of CJK text) is up
|
|
86
|
+
* to four times its code-unit count on the wire, and the point of the bound
|
|
87
|
+
* is to keep an unbounded response out of a log line. `TextDecoder` is used
|
|
88
|
+
* in streaming mode and never flushed, so a multi-byte character straddling
|
|
89
|
+
* the cut is dropped rather than emitted as a replacement character.
|
|
90
|
+
*/
|
|
91
|
+
export declare function boundedBodyPrefix(body: string): string;
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Error taxonomy for `@vaam-apps/vpay-sdk`, per docs/flows/merchant-auth.md's
|
|
3
|
+
* "Errors" section and the token-endpoint error shape it documents.
|
|
4
|
+
*
|
|
5
|
+
* Every error extends {@link VpayError} so callers can `catch (err) { if
|
|
6
|
+
* (err instanceof VpayError) ... }` once and still narrow further by
|
|
7
|
+
* `err.name` or an `instanceof` check on a specific subclass.
|
|
8
|
+
*/
|
|
9
|
+
/** Base class for every error this SDK throws. */
|
|
10
|
+
export class VpayError extends Error {
|
|
11
|
+
constructor(message, options) {
|
|
12
|
+
super(message, options);
|
|
13
|
+
this.name = new.target.name;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* A non-2xx response from a `/v1` resource route, shaped like
|
|
18
|
+
* `vpay_api::error_envelope`: `{ "error": { "type", "code", "message",
|
|
19
|
+
* "param" } }`.
|
|
20
|
+
*/
|
|
21
|
+
export class VpayApiError extends VpayError {
|
|
22
|
+
/** The HTTP status code of the response. */
|
|
23
|
+
status;
|
|
24
|
+
/** Stripe-shaped error type, e.g. `invalid_request_error`. */
|
|
25
|
+
type;
|
|
26
|
+
/** Machine-readable error code, when the server sent one. */
|
|
27
|
+
code;
|
|
28
|
+
/** The offending request parameter, when the server named one. */
|
|
29
|
+
param;
|
|
30
|
+
constructor(status, body) {
|
|
31
|
+
super(body.message);
|
|
32
|
+
this.status = status;
|
|
33
|
+
this.type = body.type;
|
|
34
|
+
this.code = body.code;
|
|
35
|
+
this.param = body.param;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* A `400`/`401` from the OAuth2 token endpoint:
|
|
40
|
+
* `{ "error": "invalid_client", "error_description": "…" }`
|
|
41
|
+
* (`TokenErrorResponse` in `authkestra-op`). Never retried automatically —
|
|
42
|
+
* see docs/flows/merchant-auth.md, "Re-authentication".
|
|
43
|
+
*/
|
|
44
|
+
export class VpayAuthError extends VpayError {
|
|
45
|
+
/** The OAuth2 `error` code, e.g. `invalid_client`. */
|
|
46
|
+
error;
|
|
47
|
+
/** Human-readable detail, when the server sent one. */
|
|
48
|
+
errorDescription;
|
|
49
|
+
constructor(error, errorDescription) {
|
|
50
|
+
super(errorDescription ? `${error}: ${errorDescription}` : error);
|
|
51
|
+
this.error = error;
|
|
52
|
+
this.errorDescription = errorDescription;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* A response that is not the Stripe-shaped error envelope this SDK expects —
|
|
57
|
+
* a proxy's HTML 502, an empty body, or JSON with an unrecognised shape.
|
|
58
|
+
* Carries a bounded prefix of the body so the cause is diagnosable without
|
|
59
|
+
* risking an unbounded string in logs.
|
|
60
|
+
*/
|
|
61
|
+
export class VpayUnexpectedResponseError extends VpayError {
|
|
62
|
+
/** The HTTP status code of the response. */
|
|
63
|
+
status;
|
|
64
|
+
/** At most the first 500 **bytes** of the response body, decoded as UTF-8. */
|
|
65
|
+
bodyPrefix;
|
|
66
|
+
constructor(status, bodyPrefix) {
|
|
67
|
+
super(`unexpected response (status ${status}): ${bodyPrefix.slice(0, 200)}`);
|
|
68
|
+
this.status = status;
|
|
69
|
+
this.bodyPrefix = bodyPrefix;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/** A transport-level failure — DNS, TLS, connection refused, timeout. */
|
|
73
|
+
export class VpayTransportError extends VpayError {
|
|
74
|
+
constructor(message, options) {
|
|
75
|
+
super(message, options);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* The SDK was configured incorrectly — caught before any request is sent.
|
|
80
|
+
*
|
|
81
|
+
* Almost always at construction (a missing `clientId`, an unreadable
|
|
82
|
+
* `privateKey`, an `assertionLifetimeSeconds` outside the OP's `1..=300`).
|
|
83
|
+
* The one exception is `@vaam-apps/vpay-sdk/stripe`'s host binding: an authenticator
|
|
84
|
+
* asked to sign a request addressed somewhere other than its `baseUrl`
|
|
85
|
+
* throws this *per request*, because that is when the destination becomes
|
|
86
|
+
* knowable. Still before anything is sent — no token is minted and no
|
|
87
|
+
* `Authorization` header is written.
|
|
88
|
+
*/
|
|
89
|
+
export class VpayConfigError extends VpayError {
|
|
90
|
+
}
|
|
91
|
+
/** A webhook signature failed verification — see {@link verifyWebhook}. */
|
|
92
|
+
export class WebhookSignatureError extends VpayError {
|
|
93
|
+
}
|
|
94
|
+
const BODY_PREFIX_LIMIT_BYTES = 500;
|
|
95
|
+
/**
|
|
96
|
+
* Truncates a response body to a bounded prefix for
|
|
97
|
+
* {@link VpayUnexpectedResponseError}.
|
|
98
|
+
*
|
|
99
|
+
* The bound is on **bytes**, not `String.prototype.length`: a body of
|
|
100
|
+
* multi-byte characters (a UTF-8 error page, a JSON body of CJK text) is up
|
|
101
|
+
* to four times its code-unit count on the wire, and the point of the bound
|
|
102
|
+
* is to keep an unbounded response out of a log line. `TextDecoder` is used
|
|
103
|
+
* in streaming mode and never flushed, so a multi-byte character straddling
|
|
104
|
+
* the cut is dropped rather than emitted as a replacement character.
|
|
105
|
+
*/
|
|
106
|
+
export function boundedBodyPrefix(body) {
|
|
107
|
+
const bytes = Buffer.from(body, "utf8");
|
|
108
|
+
if (bytes.length <= BODY_PREFIX_LIMIT_BYTES) {
|
|
109
|
+
return body;
|
|
110
|
+
}
|
|
111
|
+
return new TextDecoder("utf-8").decode(bytes.subarray(0, BODY_PREFIX_LIMIT_BYTES), { stream: true });
|
|
112
|
+
}
|
package/dist/form.d.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stripe's bracket-nested `application/x-www-form-urlencoded` encoding
|
|
3
|
+
* (docs/flows/merchant-auth.md, "Encoding" table), used for every request
|
|
4
|
+
* body and — identically — every `GET` query string.
|
|
5
|
+
*
|
|
6
|
+
* | Shape | Wire form |
|
|
7
|
+
* |---|---|
|
|
8
|
+
* | scalar | `amount=5000` |
|
|
9
|
+
* | nested object | `metadata[order_id]=1234` |
|
|
10
|
+
* | array | `payment_method_types[0]=mtn_momo&payment_method_types[1]=…` (indexed) |
|
|
11
|
+
* | boolean | `true` / `false` |
|
|
12
|
+
* | `undefined` | omitted entirely |
|
|
13
|
+
*
|
|
14
|
+
* A key is carried through the flattener as an **array of path segments**, and
|
|
15
|
+
* only assembled into wire syntax at the very end. The brackets a nested key
|
|
16
|
+
* is written with are therefore structural — produced by this encoder — while
|
|
17
|
+
* every segment, including one a merchant supplied containing `[` or `]`, is
|
|
18
|
+
* percent-encoded via `encodeURIComponent`. (An earlier version re-parsed the
|
|
19
|
+
* assembled key with a bracket regex, which silently corrupted a merchant key
|
|
20
|
+
* such as `metadata: { "a[b]": "x" }` into `metadata[b]=x`.)
|
|
21
|
+
*
|
|
22
|
+
* Two things are refused rather than silently mangled, because both would
|
|
23
|
+
* otherwise reach a payment API as something other than what the caller wrote:
|
|
24
|
+
*
|
|
25
|
+
* - a value that is an object but not a plain one (a `Date`, a `Map`, a class
|
|
26
|
+
* instance) — `Object.entries` on it yields no own enumerable keys, so it
|
|
27
|
+
* would vanish from the request entirely;
|
|
28
|
+
* - a number that is not a non-negative-or-negative *safe* integer, or whose
|
|
29
|
+
* `String()` form carries an exponent (`1e21` → `1e+21`). vpay's wire format
|
|
30
|
+
* is decimal integers with no separators (docs/flows/money.md bans floating
|
|
31
|
+
* point in the money path outright), so a float or a `1e21` is a bug in the
|
|
32
|
+
* caller, not a value to transmit approximately.
|
|
33
|
+
*/
|
|
34
|
+
/** A value the form encoder can flatten. `null` is treated like `undefined`: omitted. */
|
|
35
|
+
export type FormValue = string | number | boolean | undefined | null | readonly FormValue[] | {
|
|
36
|
+
[key: string]: FormValue;
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* Flattens a params object into ordered `[path segments, value]` pairs.
|
|
40
|
+
*
|
|
41
|
+
* The key is still segments at this point, deliberately: assembling it into
|
|
42
|
+
* `a[b][c]` before encoding is what made merchant-supplied brackets
|
|
43
|
+
* ambiguous.
|
|
44
|
+
*/
|
|
45
|
+
export declare function flattenForm(params: Record<string, FormValue>): Array<[readonly string[], string]>;
|
|
46
|
+
/** Encodes `params` as an `application/x-www-form-urlencoded` body (or query string). */
|
|
47
|
+
export declare function encodeForm(params: Record<string, FormValue>): string;
|
package/dist/form.js
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stripe's bracket-nested `application/x-www-form-urlencoded` encoding
|
|
3
|
+
* (docs/flows/merchant-auth.md, "Encoding" table), used for every request
|
|
4
|
+
* body and — identically — every `GET` query string.
|
|
5
|
+
*
|
|
6
|
+
* | Shape | Wire form |
|
|
7
|
+
* |---|---|
|
|
8
|
+
* | scalar | `amount=5000` |
|
|
9
|
+
* | nested object | `metadata[order_id]=1234` |
|
|
10
|
+
* | array | `payment_method_types[0]=mtn_momo&payment_method_types[1]=…` (indexed) |
|
|
11
|
+
* | boolean | `true` / `false` |
|
|
12
|
+
* | `undefined` | omitted entirely |
|
|
13
|
+
*
|
|
14
|
+
* A key is carried through the flattener as an **array of path segments**, and
|
|
15
|
+
* only assembled into wire syntax at the very end. The brackets a nested key
|
|
16
|
+
* is written with are therefore structural — produced by this encoder — while
|
|
17
|
+
* every segment, including one a merchant supplied containing `[` or `]`, is
|
|
18
|
+
* percent-encoded via `encodeURIComponent`. (An earlier version re-parsed the
|
|
19
|
+
* assembled key with a bracket regex, which silently corrupted a merchant key
|
|
20
|
+
* such as `metadata: { "a[b]": "x" }` into `metadata[b]=x`.)
|
|
21
|
+
*
|
|
22
|
+
* Two things are refused rather than silently mangled, because both would
|
|
23
|
+
* otherwise reach a payment API as something other than what the caller wrote:
|
|
24
|
+
*
|
|
25
|
+
* - a value that is an object but not a plain one (a `Date`, a `Map`, a class
|
|
26
|
+
* instance) — `Object.entries` on it yields no own enumerable keys, so it
|
|
27
|
+
* would vanish from the request entirely;
|
|
28
|
+
* - a number that is not a non-negative-or-negative *safe* integer, or whose
|
|
29
|
+
* `String()` form carries an exponent (`1e21` → `1e+21`). vpay's wire format
|
|
30
|
+
* is decimal integers with no separators (docs/flows/money.md bans floating
|
|
31
|
+
* point in the money path outright), so a float or a `1e21` is a bug in the
|
|
32
|
+
* caller, not a value to transmit approximately.
|
|
33
|
+
*/
|
|
34
|
+
/** Renders a path back into the bracket syntax, for error messages only. */
|
|
35
|
+
function describePath(path) {
|
|
36
|
+
const [head, ...rest] = path;
|
|
37
|
+
return `${head ?? ""}${rest.map((segment) => `[${segment}]`).join("")}`;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* True only for `{}`-shaped values: an object literal, or one created with a
|
|
41
|
+
* null prototype. A `Date`, `Map`, `URL` or class instance is not one. Nor,
|
|
42
|
+
* deliberately, is a plain object from another realm (`node:vm`,
|
|
43
|
+
* `worker_threads`) — its prototype is that realm's `Object.prototype`, not
|
|
44
|
+
* this one's — so structured data crossing a realm boundary must be
|
|
45
|
+
* re-created (e.g. via `JSON.parse(JSON.stringify(..))`) before it is
|
|
46
|
+
* encoded, rather than silently accepted on one side and refused on the
|
|
47
|
+
* other.
|
|
48
|
+
*/
|
|
49
|
+
function isPlainObject(value) {
|
|
50
|
+
const prototype = Object.getPrototypeOf(value);
|
|
51
|
+
return prototype === Object.prototype || prototype === null;
|
|
52
|
+
}
|
|
53
|
+
/** Renders a number for the wire, refusing anything that is not an exact decimal integer. */
|
|
54
|
+
function encodeNumber(value, path) {
|
|
55
|
+
if (!Number.isSafeInteger(value)) {
|
|
56
|
+
throw new TypeError(`${describePath(path)} must be a safe integer, got ${value}`);
|
|
57
|
+
}
|
|
58
|
+
const text = String(value);
|
|
59
|
+
// Subsumed by the check above as the language stands today — JavaScript
|
|
60
|
+
// renders every integer below 1e21 without an exponent, and 1e21 is far
|
|
61
|
+
// past `Number.MAX_SAFE_INTEGER`, so no safe integer reaches here with an
|
|
62
|
+
// `e` in it. Kept as an independent guard on the one property the wire
|
|
63
|
+
// format actually requires: no test can kill this branch on its own, and
|
|
64
|
+
// the comment says so rather than implying it is covered.
|
|
65
|
+
if (text.includes("e") || text.includes("E")) {
|
|
66
|
+
throw new TypeError(`${describePath(path)} must encode as a plain decimal integer, got ${text}`);
|
|
67
|
+
}
|
|
68
|
+
return text;
|
|
69
|
+
}
|
|
70
|
+
function flattenValue(path, value, out) {
|
|
71
|
+
if (value === undefined || value === null) {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (Array.isArray(value)) {
|
|
75
|
+
value.forEach((item, index) => flattenValue([...path, String(index)], item, out));
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
if (typeof value === "object") {
|
|
79
|
+
if (!isPlainObject(value)) {
|
|
80
|
+
throw new TypeError(`${describePath(path)} must be a plain object, array or scalar, got ${value.constructor?.name ?? "an object"}`);
|
|
81
|
+
}
|
|
82
|
+
for (const [childKey, childValue] of Object.entries(value)) {
|
|
83
|
+
flattenValue([...path, childKey], childValue, out);
|
|
84
|
+
}
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if (typeof value === "boolean") {
|
|
88
|
+
out.push([path, value ? "true" : "false"]);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
if (typeof value === "number") {
|
|
92
|
+
out.push([path, encodeNumber(value, path)]);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
out.push([path, value]);
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Flattens a params object into ordered `[path segments, value]` pairs.
|
|
99
|
+
*
|
|
100
|
+
* The key is still segments at this point, deliberately: assembling it into
|
|
101
|
+
* `a[b][c]` before encoding is what made merchant-supplied brackets
|
|
102
|
+
* ambiguous.
|
|
103
|
+
*/
|
|
104
|
+
export function flattenForm(params) {
|
|
105
|
+
const out = [];
|
|
106
|
+
for (const [key, value] of Object.entries(params)) {
|
|
107
|
+
flattenValue([key], value, out);
|
|
108
|
+
}
|
|
109
|
+
return out;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Assembles one flattened path into wire syntax: every segment percent-encoded,
|
|
113
|
+
* the brackets between them literal.
|
|
114
|
+
*/
|
|
115
|
+
function encodePath(path) {
|
|
116
|
+
const [head, ...rest] = path;
|
|
117
|
+
const encodedRest = rest
|
|
118
|
+
.map((segment) => `[${encodeURIComponent(segment)}]`)
|
|
119
|
+
.join("");
|
|
120
|
+
return `${encodeURIComponent(head ?? "")}${encodedRest}`;
|
|
121
|
+
}
|
|
122
|
+
/** Encodes `params` as an `application/x-www-form-urlencoded` body (or query string). */
|
|
123
|
+
export function encodeForm(params) {
|
|
124
|
+
return flattenForm(params)
|
|
125
|
+
.map(([path, value]) => `${encodePath(path)}=${encodeURIComponent(value)}`)
|
|
126
|
+
.join("&");
|
|
127
|
+
}
|
package/dist/http.d.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { TokenManager } from "./auth.js";
|
|
2
|
+
import { type FormValue } from "./form.js";
|
|
3
|
+
import type { RequestOptions } from "./types.js";
|
|
4
|
+
export interface HttpClientOptions {
|
|
5
|
+
baseUrl: string;
|
|
6
|
+
tokenManager: TokenManager;
|
|
7
|
+
fetchImpl: typeof fetch;
|
|
8
|
+
timeoutMs: number;
|
|
9
|
+
userAgent: string;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* `DELETE` since 2026-09-06 (S4a) — `DELETE /v1/customers/{id}` is the one
|
|
13
|
+
* this API has. It is handled as a write below, not as a read: it carries an
|
|
14
|
+
* `Idempotency-Key` and, deliberately, **no body**.
|
|
15
|
+
*
|
|
16
|
+
* The header is the server's contract (`docs/flows/merchant-auth.md`: every
|
|
17
|
+
* write under `/v1` carries one) and it is the verb where a replay is most
|
|
18
|
+
* confusing without it — the second call would otherwise answer `404` for a
|
|
19
|
+
* deletion that succeeded, which a merchant retrying a timed-out request
|
|
20
|
+
* cannot tell from "somebody else deleted it".
|
|
21
|
+
*
|
|
22
|
+
* No body, because an empty `application/x-www-form-urlencoded` body and no
|
|
23
|
+
* body are different requests to some proxies, and the request hash the
|
|
24
|
+
* server stores against the idempotency key is taken over the bytes.
|
|
25
|
+
*/
|
|
26
|
+
type HttpMethod = "GET" | "POST" | "DELETE";
|
|
27
|
+
/** Thin wrapper over `fetch` implementing the merchant-auth wire contract for `/v1`. */
|
|
28
|
+
export declare class HttpClient {
|
|
29
|
+
#private;
|
|
30
|
+
constructor(options: HttpClientOptions);
|
|
31
|
+
request<T>(method: HttpMethod, path: string, params?: Record<string, FormValue>, requestOptions?: RequestOptions): Promise<T>;
|
|
32
|
+
}
|
|
33
|
+
export {};
|
package/dist/http.js
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `/v1` resource HTTP layer: request encoding, headers, the single
|
|
3
|
+
* 401-triggered re-auth retry, and error-envelope mapping
|
|
4
|
+
* (docs/flows/merchant-auth.md, "Headers"/"Errors"/"Re-authentication").
|
|
5
|
+
*/
|
|
6
|
+
import { randomUUID } from "node:crypto";
|
|
7
|
+
import { VpayApiError, VpayTransportError, VpayUnexpectedResponseError, boundedBodyPrefix, } from "./errors.js";
|
|
8
|
+
import { encodeForm } from "./form.js";
|
|
9
|
+
function isErrorEnvelope(value) {
|
|
10
|
+
if (typeof value !== "object" || value === null || !("error" in value)) {
|
|
11
|
+
return false;
|
|
12
|
+
}
|
|
13
|
+
const err = value.error;
|
|
14
|
+
return (typeof err === "object" &&
|
|
15
|
+
err !== null &&
|
|
16
|
+
typeof err["type"] === "string" &&
|
|
17
|
+
typeof err["message"] === "string");
|
|
18
|
+
}
|
|
19
|
+
/** Thin wrapper over `fetch` implementing the merchant-auth wire contract for `/v1`. */
|
|
20
|
+
export class HttpClient {
|
|
21
|
+
#options;
|
|
22
|
+
constructor(options) {
|
|
23
|
+
this.#options = options;
|
|
24
|
+
}
|
|
25
|
+
async request(method, path, params, requestOptions) {
|
|
26
|
+
let url = `${this.#options.baseUrl}${path}`;
|
|
27
|
+
let body;
|
|
28
|
+
const headers = {
|
|
29
|
+
Accept: "application/json",
|
|
30
|
+
"User-Agent": this.#options.userAgent,
|
|
31
|
+
};
|
|
32
|
+
if (method === "GET") {
|
|
33
|
+
if (params) {
|
|
34
|
+
const query = encodeForm(params);
|
|
35
|
+
if (query.length > 0) {
|
|
36
|
+
url += `?${query}`;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
else if (method === "DELETE") {
|
|
41
|
+
// Keyed like every other write, and `body` is left `undefined` — see
|
|
42
|
+
// the `HttpMethod` comment for why both halves are deliberate.
|
|
43
|
+
headers["Idempotency-Key"] =
|
|
44
|
+
requestOptions?.idempotencyKey ?? randomUUID();
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
body = params ? encodeForm(params) : "";
|
|
48
|
+
headers["Content-Type"] = "application/x-www-form-urlencoded";
|
|
49
|
+
headers["Idempotency-Key"] =
|
|
50
|
+
requestOptions?.idempotencyKey ?? randomUUID();
|
|
51
|
+
}
|
|
52
|
+
// `headers` and `body` are built once, above the retry loop, and the
|
|
53
|
+
// 401 re-auth below reuses them untouched — only `Authorization` is
|
|
54
|
+
// rewritten. The second attempt therefore carries a byte-identical
|
|
55
|
+
// `Idempotency-Key` and body, so a re-auth can never double-create
|
|
56
|
+
// (docs/flows/merchant-auth.md, "Headers"/"Re-authentication").
|
|
57
|
+
let attempt = 0;
|
|
58
|
+
for (;;) {
|
|
59
|
+
attempt += 1;
|
|
60
|
+
const token = await this.#options.tokenManager.getToken();
|
|
61
|
+
headers["Authorization"] = `Bearer ${token}`;
|
|
62
|
+
let status;
|
|
63
|
+
let ok;
|
|
64
|
+
let text;
|
|
65
|
+
try {
|
|
66
|
+
const init = {
|
|
67
|
+
method,
|
|
68
|
+
headers,
|
|
69
|
+
signal: AbortSignal.timeout(this.#options.timeoutMs),
|
|
70
|
+
};
|
|
71
|
+
if (body !== undefined) {
|
|
72
|
+
init.body = body;
|
|
73
|
+
}
|
|
74
|
+
const response = await this.#options.fetchImpl(url, init);
|
|
75
|
+
status = response.status;
|
|
76
|
+
ok = response.ok;
|
|
77
|
+
// Read the body inside the same `try`. `fetch` resolves as soon as the
|
|
78
|
+
// response headers arrive, so a stalled or truncated body — or the
|
|
79
|
+
// timeout signal firing mid-stream — rejects *here*, not at the call
|
|
80
|
+
// above. Outside this block that rejection escapes as a raw
|
|
81
|
+
// `DOMException: TimeoutError` instead of a `VpayTransportError`.
|
|
82
|
+
text = await response.text();
|
|
83
|
+
}
|
|
84
|
+
catch (err) {
|
|
85
|
+
throw new VpayTransportError("request failed", { cause: err });
|
|
86
|
+
}
|
|
87
|
+
if (status === 401 && attempt === 1) {
|
|
88
|
+
this.#options.tokenManager.invalidate();
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
return this.#mapResponse(status, ok, text);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
#mapResponse(status, ok, text) {
|
|
95
|
+
if (ok) {
|
|
96
|
+
try {
|
|
97
|
+
return (text.length > 0 ? JSON.parse(text) : undefined);
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
throw new VpayUnexpectedResponseError(status, boundedBodyPrefix(text));
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
let parsed;
|
|
104
|
+
try {
|
|
105
|
+
parsed = text.length > 0 ? JSON.parse(text) : undefined;
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
parsed = undefined;
|
|
109
|
+
}
|
|
110
|
+
if (isErrorEnvelope(parsed)) {
|
|
111
|
+
throw new VpayApiError(status, parsed.error);
|
|
112
|
+
}
|
|
113
|
+
throw new VpayUnexpectedResponseError(status, boundedBodyPrefix(text));
|
|
114
|
+
}
|
|
115
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@vaam-apps/vpay-sdk` — the Node.js merchant SDK for vpay.
|
|
3
|
+
*
|
|
4
|
+
* See README.md for the handshake, a usage example, and this package's own
|
|
5
|
+
* Status section (the server serves `/v1/oauth` and the `/v1` auth boundary;
|
|
6
|
+
* no `/v1` resource route exists yet).
|
|
7
|
+
*/
|
|
8
|
+
export { VpayClient, type VpayClientOptions } from "./client.js";
|
|
9
|
+
export { mintClientAssertion, type MintClientAssertionOptions, } from "./auth.js";
|
|
10
|
+
export { verifyWebhook, type VerifyWebhookOptions } from "./webhooks.js";
|
|
11
|
+
/** This package's own version, as sent in the `vpay-sdk-node/<version>` User-Agent. */
|
|
12
|
+
export { SDK_VERSION } from "./version.js";
|
|
13
|
+
export { VpayError, VpayApiError, VpayAuthError, VpayUnexpectedResponseError, VpayTransportError, VpayConfigError, WebhookSignatureError, } from "./errors.js";
|
|
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, CreateInvoiceItemParams, UpdateInvoiceItemParams, PaymentIntent, PaymentIntentStatus, PaymentMethodType, NextAction, LastPaymentError, FailureCode, Refund, RefundStatus, Event, KnownEventType, Balance, BalanceAmount, List, ListParams, ListEventsParams, CreatePaymentIntentParams, ConfirmPaymentIntentParams, MtnMomoPaymentMethodData, OrangeMoneyPaymentMethodData, CreateRefundParams, RefundDestination, UpdateRefundParams, ListRefundsParams, RequestOptions, } from "./types.js";
|
|
15
|
+
export { isCheckoutSessionEvent, isInvoiceEvent, isPaymentIntentEvent, isRefundEvent, } from "./types.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@vaam-apps/vpay-sdk` — the Node.js merchant SDK for vpay.
|
|
3
|
+
*
|
|
4
|
+
* See README.md for the handshake, a usage example, and this package's own
|
|
5
|
+
* Status section (the server serves `/v1/oauth` and the `/v1` auth boundary;
|
|
6
|
+
* no `/v1` resource route exists yet).
|
|
7
|
+
*/
|
|
8
|
+
export { VpayClient } from "./client.js";
|
|
9
|
+
export { mintClientAssertion, } from "./auth.js";
|
|
10
|
+
export { verifyWebhook } from "./webhooks.js";
|
|
11
|
+
/** This package's own version, as sent in the `vpay-sdk-node/<version>` User-Agent. */
|
|
12
|
+
export { SDK_VERSION } from "./version.js";
|
|
13
|
+
export { VpayError, VpayApiError, VpayAuthError, VpayUnexpectedResponseError, VpayTransportError, VpayConfigError, WebhookSignatureError, } from "./errors.js";
|
|
14
|
+
export { isCheckoutSessionEvent, isInvoiceEvent, isPaymentIntentEvent, isRefundEvent, } from "./types.js";
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { HttpClient } from "../http.js";
|
|
2
|
+
import type { AccountHolder, RetrieveAccountHolderParams } from "../types.js";
|
|
3
|
+
/**
|
|
4
|
+
* `/v1/account_holders` — whose mobile-money account a number is
|
|
5
|
+
* (issue #47).
|
|
6
|
+
*
|
|
7
|
+
* The one resource on this surface that returns a **third party's** name.
|
|
8
|
+
* vpay projects the rail's answer down to a name, logs neither the name nor
|
|
9
|
+
* the unmasked number, and stores nothing;
|
|
10
|
+
* `docs/flows/account-holder-lookup.md` is the policy, and an integrator
|
|
11
|
+
* holding the returned name inherits the same obligation.
|
|
12
|
+
*/
|
|
13
|
+
export declare class AccountHoldersResource {
|
|
14
|
+
#private;
|
|
15
|
+
constructor(http: HttpClient);
|
|
16
|
+
/**
|
|
17
|
+
* `GET /v1/account_holders`.
|
|
18
|
+
*
|
|
19
|
+
* `name` is `null` when **the rail has no record** of the number. It is
|
|
20
|
+
* never `null` because vpay could not ask: that case throws
|
|
21
|
+
* (`VpayApiError`, `502` for a rail that could not be reached, `400` for a
|
|
22
|
+
* rail with no such API), so a caller matching a name can tell "not
|
|
23
|
+
* registered" from "not checked". Both are refusals; only one of them is
|
|
24
|
+
* the payer's to fix.
|
|
25
|
+
*
|
|
26
|
+
* Nothing is validated locally — not the number's shape, not whether the
|
|
27
|
+
* rail can answer. The first is a *market* rule vpay owns and may widen,
|
|
28
|
+
* and an SDK copy would refuse offline a number a later server version
|
|
29
|
+
* accepts; the second is a property of the deployment. Both come back as a
|
|
30
|
+
* `400` naming the parameter, which is the same information one round trip
|
|
31
|
+
* later. `sdks/rust`'s `RetrieveAccountHolderParams` takes the identical
|
|
32
|
+
* line, which is what keeps the two at parity on the refusals as well as
|
|
33
|
+
* the acceptances.
|
|
34
|
+
*/
|
|
35
|
+
retrieve(params: RetrieveAccountHolderParams): Promise<AccountHolder>;
|
|
36
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/v1/account_holders` — whose mobile-money account a number is
|
|
3
|
+
* (issue #47).
|
|
4
|
+
*
|
|
5
|
+
* The one resource on this surface that returns a **third party's** name.
|
|
6
|
+
* vpay projects the rail's answer down to a name, logs neither the name nor
|
|
7
|
+
* the unmasked number, and stores nothing;
|
|
8
|
+
* `docs/flows/account-holder-lookup.md` is the policy, and an integrator
|
|
9
|
+
* holding the returned name inherits the same obligation.
|
|
10
|
+
*/
|
|
11
|
+
export class AccountHoldersResource {
|
|
12
|
+
#http;
|
|
13
|
+
constructor(http) {
|
|
14
|
+
this.#http = http;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* `GET /v1/account_holders`.
|
|
18
|
+
*
|
|
19
|
+
* `name` is `null` when **the rail has no record** of the number. It is
|
|
20
|
+
* never `null` because vpay could not ask: that case throws
|
|
21
|
+
* (`VpayApiError`, `502` for a rail that could not be reached, `400` for a
|
|
22
|
+
* rail with no such API), so a caller matching a name can tell "not
|
|
23
|
+
* registered" from "not checked". Both are refusals; only one of them is
|
|
24
|
+
* the payer's to fix.
|
|
25
|
+
*
|
|
26
|
+
* Nothing is validated locally — not the number's shape, not whether the
|
|
27
|
+
* rail can answer. The first is a *market* rule vpay owns and may widen,
|
|
28
|
+
* and an SDK copy would refuse offline a number a later server version
|
|
29
|
+
* accepts; the second is a property of the deployment. Both come back as a
|
|
30
|
+
* `400` naming the parameter, which is the same information one round trip
|
|
31
|
+
* later. `sdks/rust`'s `RetrieveAccountHolderParams` takes the identical
|
|
32
|
+
* line, which is what keeps the two at parity on the refusals as well as
|
|
33
|
+
* the acceptances.
|
|
34
|
+
*/
|
|
35
|
+
async retrieve(params) {
|
|
36
|
+
// Built explicitly rather than spread from `params`, and in this order:
|
|
37
|
+
// the encoder walks insertion order, and `sdks/rust/tests/resources.rs`
|
|
38
|
+
// pins the identical query string byte for byte. A spread would make the
|
|
39
|
+
// wire depend on how a caller happened to write their object literal.
|
|
40
|
+
const query = {
|
|
41
|
+
msisdn: params.msisdn,
|
|
42
|
+
payment_method_type: params.payment_method_type,
|
|
43
|
+
};
|
|
44
|
+
return this.#http.request("GET", "/account_holders", query);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { HttpClient } from "../http.js";
|
|
2
|
+
import type { Balance } from "../types.js";
|
|
3
|
+
/** `/v1/balance` — docs/flows/merchant-auth.md, "Resources". */
|
|
4
|
+
export declare class BalanceResource {
|
|
5
|
+
#private;
|
|
6
|
+
constructor(http: HttpClient);
|
|
7
|
+
retrieve(): Promise<Balance>;
|
|
8
|
+
}
|