@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/README.md
ADDED
|
@@ -0,0 +1,732 @@
|
|
|
1
|
+
# @vaam-apps/vpay-sdk
|
|
2
|
+
|
|
3
|
+
The Node.js merchant SDK for vpay's `/v1` API. Implements the wire contract in
|
|
4
|
+
[`docs/flows/merchant-auth.md`](../../docs/flows/merchant-auth.md) exactly —
|
|
5
|
+
`private_key_jwt` client assertions, `client_credentials` token exchange and
|
|
6
|
+
caching, the form-encoded resource calls, and outbound-webhook verification.
|
|
7
|
+
|
|
8
|
+
**Not yet on the registry.** `npm view @vaam-apps/vpay-sdk` answered `E404` on
|
|
9
|
+
2026-09-05 and no workflow in this repository publishes anything. What changed
|
|
10
|
+
on that date is only that the manifest stopped saying `"private": true`, so a
|
|
11
|
+
release can happen without editing it; the release workflow that would do the
|
|
12
|
+
publishing does not exist yet. See "Status" below for what the server it talks
|
|
13
|
+
to actually serves.
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
Inside this workspace:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
pnpm --filter @vaam-apps/vpay-sdk build
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
From a registry, once a release workflow exists and has run:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pnpm add @vaam-apps/vpay-sdk # not yet published — see above
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Requires Node.js `>=22.11.0`.
|
|
30
|
+
|
|
31
|
+
## The handshake, in prose
|
|
32
|
+
|
|
33
|
+
`/v1` never accepts an API key ([ADR-0010](../../docs/adr/0010-merchant-auth-private-key-jwt.md)).
|
|
34
|
+
Every merchant is a statically registered OAuth2 client authenticating with
|
|
35
|
+
`client_credentials` (RFC 6749 §4.4) via a signed `private_key_jwt` assertion
|
|
36
|
+
(RFC 7523). Concretely:
|
|
37
|
+
|
|
38
|
+
1. **Mint an assertion.** The SDK signs a short-lived RS256 JWT with your
|
|
39
|
+
private key: `iss`/`sub` are your `client_id`, `aud` is the OP's own token
|
|
40
|
+
endpoint (see `assertionAudience`), `jti` is a fresh UUIDv4, and `exp` is
|
|
41
|
+
`now + assertionLifetimeSeconds` (default 60s, capped at 300s — the OP
|
|
42
|
+
refuses anything further out).
|
|
43
|
+
2. **Exchange it for an access token.** The SDK `POST`s the assertion to the
|
|
44
|
+
token endpoint alongside `grant_type=client_credentials` and
|
|
45
|
+
`audience=vpay:v1`. No `client_secret` is ever sent — there is nothing to
|
|
46
|
+
send; vpay stores only your **public** key.
|
|
47
|
+
3. **Call `/v1` with the token.** Every resource call carries
|
|
48
|
+
`Authorization: Bearer <access_token>`. The SDK caches the token until
|
|
49
|
+
shortly before it expires and reuses it across calls; concurrent calls
|
|
50
|
+
share one in-flight token request.
|
|
51
|
+
4. **Re-authenticate on expiry or a 401.** There is no refresh token by
|
|
52
|
+
design. On a `401` from a resource route, the SDK discards the cached
|
|
53
|
+
token, repeats steps 1–2 once, and retries the original request exactly
|
|
54
|
+
once. A second `401` is returned to you as a `VpayApiError`.
|
|
55
|
+
|
|
56
|
+
## Usage
|
|
57
|
+
|
|
58
|
+
```ts
|
|
59
|
+
import { readFileSync } from "node:fs";
|
|
60
|
+
import { VpayClient } from "@vaam-apps/vpay-sdk";
|
|
61
|
+
|
|
62
|
+
const vpay = new VpayClient({
|
|
63
|
+
baseUrl: "https://api.vpay.example",
|
|
64
|
+
clientId: "merchant_a",
|
|
65
|
+
privateKey: readFileSync("./merchant_a.key.pem", "utf8"),
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const intent = await vpay.paymentIntents.create(
|
|
69
|
+
{
|
|
70
|
+
amount: 5000, // 5,000 FCFA — XAF is zero-decimal, see docs/flows/money.md
|
|
71
|
+
currency: "xaf",
|
|
72
|
+
payment_method_types: ["mtn_momo"],
|
|
73
|
+
metadata: { order_id: "1234" },
|
|
74
|
+
},
|
|
75
|
+
{ idempotencyKey: "order_1234_attempt_1" },
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
console.log(intent.id, intent.status); // pi_..., "requires_payment_method"
|
|
79
|
+
|
|
80
|
+
const confirmed = await vpay.paymentIntents.confirm(intent.id, {
|
|
81
|
+
payment_method_data: {
|
|
82
|
+
type: "mtn_momo",
|
|
83
|
+
mtn_momo: { msisdn: "237670000000" },
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// `processing` means NOT YET. Wait for a payment_intent.succeeded webhook,
|
|
88
|
+
// or poll retrieve(). There is no `failed` status — see payment-lifecycle.md.
|
|
89
|
+
console.log(confirmed.status);
|
|
90
|
+
|
|
91
|
+
// Orange Money is a redirect rail:
|
|
92
|
+
const redirectConfirm = await vpay.paymentIntents.confirm(intent.id, {
|
|
93
|
+
payment_method_data: { type: "orange_money" },
|
|
94
|
+
return_url: "https://shop.example/return",
|
|
95
|
+
});
|
|
96
|
+
if (redirectConfirm.next_action?.type === "redirect_to_url") {
|
|
97
|
+
// redirect the payer's browser to redirectConfirm.next_action.redirect_to_url.url
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
await vpay.paymentIntents.retrieve(intent.id);
|
|
101
|
+
await vpay.paymentIntents.cancel(intent.id);
|
|
102
|
+
await vpay.paymentIntents.list({ limit: 10 });
|
|
103
|
+
|
|
104
|
+
await vpay.refunds.create({
|
|
105
|
+
payment_intent: intent.id,
|
|
106
|
+
reason: "requested_by_customer",
|
|
107
|
+
});
|
|
108
|
+
await vpay.events.list({ type: "payment_intent.succeeded", limit: 20 });
|
|
109
|
+
await vpay.balance.retrieve();
|
|
110
|
+
|
|
111
|
+
// Checkout Sessions (Step 9). A session references an intent you already
|
|
112
|
+
// created; it never creates one, and it carries where the payer goes next.
|
|
113
|
+
const hosted = await vpay.checkout.sessions.create({
|
|
114
|
+
payment_intent: intent.id,
|
|
115
|
+
ui_mode: "hosted",
|
|
116
|
+
// vpay substitutes {CHECKOUT_SESSION_ID} when it forwards the payer, and
|
|
117
|
+
// appends nothing else. Omit it and the return has no correlation.
|
|
118
|
+
success_url: "https://shop.example/ok?sid={CHECKOUT_SESSION_ID}",
|
|
119
|
+
cancel_url: "https://shop.example/cancel",
|
|
120
|
+
});
|
|
121
|
+
// Send the payer to hosted.url — the session secret is in its fragment.
|
|
122
|
+
|
|
123
|
+
const embedded = await vpay.checkout.sessions.create({
|
|
124
|
+
payment_intent: intent.id,
|
|
125
|
+
ui_mode: "embedded",
|
|
126
|
+
return_url: "https://shop.example/order/42",
|
|
127
|
+
});
|
|
128
|
+
// Hand embedded.client_secret to @vaam-apps/vpay-stripe-js's initEmbeddedCheckout.
|
|
129
|
+
|
|
130
|
+
await vpay.checkout.sessions.retrieve(hosted.id);
|
|
131
|
+
await vpay.checkout.sessions.list({ payment_intent: intent.id, limit: 10 });
|
|
132
|
+
await vpay.checkout.sessions.expire(hosted.id); // open -> expired
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
`create()` and `retrieve()` responses carry a `client_secret` (`intent.client_secret`,
|
|
136
|
+
typed `string | undefined`) — the payer credential `/v1/browser` accepts for a
|
|
137
|
+
browser-side confirm. Hand it straight to `@vaam-apps/vpay-stripe-js` on the page you
|
|
138
|
+
render for the payer; never log it or send it anywhere but the browser. It is
|
|
139
|
+
the one field on `PaymentIntent` that is genuinely absent — not `null` — from
|
|
140
|
+
every other response shape: a `list()` item and `event.data.object` never
|
|
141
|
+
carry it, so a merchant's own listing view or a stored/forwarded webhook body
|
|
142
|
+
never receives a live payer credential for intents it did not just create.
|
|
143
|
+
|
|
144
|
+
A Checkout Session's own `client_secret` (`cs_…_secret_…`) is a **different**
|
|
145
|
+
credential from the intent's: it authorises reading that session, never
|
|
146
|
+
confirming the intent. Like the intent's, it is present on `create()` and
|
|
147
|
+
`retrieve()` only and absent from every `list()` item. Unlike the intent's, it
|
|
148
|
+
does not print: every `CheckoutSession` this SDK returns redacts it — and the
|
|
149
|
+
fragment of a hosted session's `url`, which holds the same value — from
|
|
150
|
+
`util.inspect`, so `console.log(session)` cannot leak it. `JSON.stringify` is
|
|
151
|
+
left faithful, because an embedded integration has to serialise the secret to
|
|
152
|
+
get it to the browser at all.
|
|
153
|
+
|
|
154
|
+
## Configuration
|
|
155
|
+
|
|
156
|
+
| Option | Default | Notes |
|
|
157
|
+
| -------------------------- | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
158
|
+
| `baseUrl` | — | Required, e.g. `https://api.vpay.example`. |
|
|
159
|
+
| `clientId` | — | Required. Your registered OAuth2 `client_id`. |
|
|
160
|
+
| `privateKey` | — | Required. PEM text or a `crypto.KeyObject`. Never logged, never serialized. |
|
|
161
|
+
| `kid` | — | Required only if you registered more than one JWK. |
|
|
162
|
+
| `issuer` | `${baseUrl}/v1/oauth` | This default is what the server does: `vpay_api::op::issuer_for` builds the issuer as `{public_base_url}/v1/oauth` from the deployment YAML, and `vpay_api::router` mounts the OP there. Override only for a deployment behind a path prefix. |
|
|
163
|
+
| `tokenEndpoint` | `${issuer}/token` | The URL this package POSTs the token request to. A **reachability** setting: it must resolve from wherever your server runs. It is also the default `assertionAudience`, but the two are separate settings — see the row below. |
|
|
164
|
+
| `assertionAudience` | `tokenEndpoint` | The client assertion's `aud` claim: the OP's own token endpoint (or issuer) **as vpay is configured publicly** — `{deployment.public_base_url}/v1/oauth/token`. Set it when your server reaches vpay by a different URL than payers do. See "Reaching vpay by an internal URL" below. |
|
|
165
|
+
| `audience` | `vpay:v1` | The OAuth2 `audience` request parameter. Load-bearing: without it the OP mints a token whose `aud` is the `client_id`, which the resource server rejects. Server-side the same string is `vpay_config::MERCHANT_AUDIENCE`; this package keeps its own copy, so the two must change together. |
|
|
166
|
+
| `scope` | — | Omitted from the token request unless set. |
|
|
167
|
+
| `assertionLifetimeSeconds` | `60` | Must be an integer in `1..=300`; anything else throws `VpayConfigError` at construction, not at request time. Keep the default — see the note below the table. |
|
|
168
|
+
| `timeoutMs` | `30000` | Applies to both the token exchange and every resource call. |
|
|
169
|
+
| `fetch` | global `fetch` | Injection point for tests or an outbound proxy. |
|
|
170
|
+
|
|
171
|
+
**Do not raise `assertionLifetimeSeconds` to 300.** 300 is not a comfortable
|
|
172
|
+
maximum, it is the OP's exact refusal boundary
|
|
173
|
+
(`MAX_CLIENT_ASSERTION_LIFETIME_SECS`), and the OP compares it against _its_
|
|
174
|
+
clock. A merchant clock running even one second fast mints an `exp` the OP
|
|
175
|
+
reads as 301 seconds out, and every assertion is refused — the failure looks
|
|
176
|
+
like `invalid_client`, arrives all at once, and clears up on its own when the
|
|
177
|
+
clocks drift back. The default of 60 leaves 240 seconds of headroom for a
|
|
178
|
+
value that only has to outlive one HTTP round trip.
|
|
179
|
+
|
|
180
|
+
### Reaching vpay by an internal URL
|
|
181
|
+
|
|
182
|
+
Two things look like one and are not:
|
|
183
|
+
|
|
184
|
+
- **`tokenEndpoint`** — where this process sends the HTTP request. It has to
|
|
185
|
+
be reachable from _your_ server: a compose service name, a private DNS
|
|
186
|
+
name, a service-mesh address.
|
|
187
|
+
- **`assertionAudience`** — what the OP calls _itself_. The OP's
|
|
188
|
+
`authenticate_client` compares the assertion's `aud` claim against its own
|
|
189
|
+
two names, `{deployment.public_base_url}/v1/oauth/token` and
|
|
190
|
+
`{deployment.public_base_url}/v1/oauth`, and against nothing else. The URL
|
|
191
|
+
you happened to POST to is never consulted.
|
|
192
|
+
|
|
193
|
+
They coincide for a merchant on the public internet, which is why
|
|
194
|
+
`assertionAudience` defaults to `tokenEndpoint`. They do not coincide the
|
|
195
|
+
moment your server reaches vpay by an internal name, and then every token
|
|
196
|
+
request answers `invalid_client` (`InvalidAudience`) with nothing in the
|
|
197
|
+
response pointing at the cause — the signature, the `client_id`, the `kid`
|
|
198
|
+
and the lifetime are all correct.
|
|
199
|
+
|
|
200
|
+
```ts
|
|
201
|
+
// The demo stack: the shop's container reaches vpay at the compose service
|
|
202
|
+
// name, but vpay's `deployment.public_base_url` is the published host port.
|
|
203
|
+
const vpay = new VpayClient({
|
|
204
|
+
baseUrl: "http://vpay-server:8080",
|
|
205
|
+
assertionAudience: "http://localhost:8080/v1/oauth/token",
|
|
206
|
+
clientId: "shop-merchant",
|
|
207
|
+
privateKey,
|
|
208
|
+
});
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
The issuer works too — `http://localhost:8080/v1/oauth` — because the OP
|
|
212
|
+
accepts either.
|
|
213
|
+
|
|
214
|
+
## Error handling
|
|
215
|
+
|
|
216
|
+
Every error extends `VpayError`, so `catch (err) { if (err instanceof VpayError) ... }`
|
|
217
|
+
works as a catch-all; narrow further with `instanceof` on a specific subclass:
|
|
218
|
+
|
|
219
|
+
| Class | When | Fields |
|
|
220
|
+
| ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------- |
|
|
221
|
+
| `VpayApiError` | A non-2xx response from a `/v1` resource route. | `status`, `type`, `code`, `message`, `param` |
|
|
222
|
+
| `VpayAuthError` | A `400`/`401` from the token endpoint (`invalid_client`, etc). Never retried. | `error`, `errorDescription` |
|
|
223
|
+
| `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) |
|
|
224
|
+
| `VpayTransportError` | DNS, TLS, connection refused, timeout. | `cause` (the underlying error) |
|
|
225
|
+
| `VpayConfigError` | The SDK was misconfigured — caught at construction where possible. | — |
|
|
226
|
+
| `WebhookSignatureError` | `verifyWebhook` rejected a signature. | — |
|
|
227
|
+
|
|
228
|
+
```ts
|
|
229
|
+
import { VpayApiError, VpayAuthError, VpayError } from "@vaam-apps/vpay-sdk";
|
|
230
|
+
|
|
231
|
+
try {
|
|
232
|
+
await vpay.paymentIntents.create({
|
|
233
|
+
amount: 5000,
|
|
234
|
+
currency: "xaf",
|
|
235
|
+
payment_method_types: ["mtn_momo"],
|
|
236
|
+
});
|
|
237
|
+
} catch (err) {
|
|
238
|
+
if (err instanceof VpayApiError) {
|
|
239
|
+
console.error(err.status, err.type, err.code, err.message, err.param);
|
|
240
|
+
} else if (err instanceof VpayAuthError) {
|
|
241
|
+
console.error("authentication failed:", err.error, err.errorDescription);
|
|
242
|
+
} else if (err instanceof VpayError) {
|
|
243
|
+
console.error(err.name, err.message);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
`VpayApiError` is one class for every error envelope: the envelope's `type`
|
|
249
|
+
is a _field_, not a subclass. Branch on `code`, which is what the server
|
|
250
|
+
treats as the machine-readable answer — two idempotency errors share a status
|
|
251
|
+
(`400`) and a `type` (`idempotency_error`) and mean opposite things:
|
|
252
|
+
|
|
253
|
+
```ts
|
|
254
|
+
if (err instanceof VpayApiError) {
|
|
255
|
+
if (err.code === "idempotency_key_in_flight") {
|
|
256
|
+
// The first request under this key has not finished. Wait, then send
|
|
257
|
+
// the same call again — do not mint a new key.
|
|
258
|
+
} else if (err.code === "idempotency_key_in_use") {
|
|
259
|
+
// The key was already used with a different body. Retrying cannot help.
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
## Webhook verification
|
|
265
|
+
|
|
266
|
+
```ts
|
|
267
|
+
import { createServer } from "node:http";
|
|
268
|
+
import { verifyWebhook, WebhookSignatureError } from "@vaam-apps/vpay-sdk";
|
|
269
|
+
|
|
270
|
+
// Read the secret once, at startup, so a misconfigured deployment fails
|
|
271
|
+
// loudly instead of rejecting every delivery as a bad signature. Under
|
|
272
|
+
// `noUncheckedIndexedAccess` this is `string | undefined` until you narrow it.
|
|
273
|
+
const webhookSecret = process.env["VPAY_WEBHOOK_SECRET"];
|
|
274
|
+
if (webhookSecret === undefined) {
|
|
275
|
+
throw new Error("VPAY_WEBHOOK_SECRET is not set");
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
createServer((req, res) => {
|
|
279
|
+
let raw = "";
|
|
280
|
+
req.on("data", (chunk: Buffer) => (raw += chunk.toString("utf8")));
|
|
281
|
+
req.on("end", () => {
|
|
282
|
+
// `IncomingHttpHeaders` types every header as `string | string[]`, because
|
|
283
|
+
// a header may legitimately be repeated. A repeated `Vpay-Signature` is
|
|
284
|
+
// not something vpay sends, and joining or picking one arbitrarily would
|
|
285
|
+
// verify against bytes nobody promised — so reject it.
|
|
286
|
+
const header = req.headers["vpay-signature"];
|
|
287
|
+
if (typeof header !== "string") {
|
|
288
|
+
res.writeHead(400).end("missing or repeated Vpay-Signature");
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
let event;
|
|
293
|
+
try {
|
|
294
|
+
// The RAW request body must be used — parsing and re-stringifying it
|
|
295
|
+
// breaks the HMAC. Do not run a body-parsing middleware before this.
|
|
296
|
+
event = verifyWebhook({
|
|
297
|
+
rawBody: raw,
|
|
298
|
+
signatureHeader: header,
|
|
299
|
+
secret: webhookSecret,
|
|
300
|
+
});
|
|
301
|
+
} catch (err) {
|
|
302
|
+
if (err instanceof WebhookSignatureError) {
|
|
303
|
+
res.writeHead(400).end("bad signature");
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
throw err;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Delivery is at-least-once. verifyWebhook does not dedupe by event.id —
|
|
310
|
+
// that is your job. Keep a set (or a unique DB constraint) of processed
|
|
311
|
+
// event.id values and skip anything you've already handled.
|
|
312
|
+
console.log("event", event.id, event.type);
|
|
313
|
+
res.writeHead(200).end("ok");
|
|
314
|
+
});
|
|
315
|
+
}).listen(4242);
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
## Using the official Stripe SDK
|
|
319
|
+
|
|
320
|
+
vpay's `/v1` surface is Stripe-shaped, and the official
|
|
321
|
+
[`stripe`](https://www.npmjs.com/package/stripe) Node SDK can talk to it —
|
|
322
|
+
with an **empty API key** and a `config.authenticator` that performs the
|
|
323
|
+
`private_key_jwt` handshake above. `@vaam-apps/vpay-sdk/stripe` is that authenticator:
|
|
324
|
+
|
|
325
|
+
```js
|
|
326
|
+
import { readFileSync } from "node:fs";
|
|
327
|
+
import Stripe from "stripe";
|
|
328
|
+
import { createStripeAuthenticator } from "@vaam-apps/vpay-sdk/stripe";
|
|
329
|
+
|
|
330
|
+
const authenticator = createStripeAuthenticator({
|
|
331
|
+
baseUrl: "http://localhost:8080",
|
|
332
|
+
clientId: "acme-cameroon",
|
|
333
|
+
privateKey: readFileSync("./merchant-key.pem", "utf8"),
|
|
334
|
+
kid: "acme-cameroon-2026-08",
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
const stripe = new Stripe("", {
|
|
338
|
+
authenticator,
|
|
339
|
+
host: "localhost",
|
|
340
|
+
port: "8080",
|
|
341
|
+
protocol: "http",
|
|
342
|
+
maxNetworkRetries: 2,
|
|
343
|
+
timeout: 30_000,
|
|
344
|
+
telemetry: false,
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
const intent = await stripe.paymentIntents.create({
|
|
348
|
+
amount: 5000,
|
|
349
|
+
currency: "xaf",
|
|
350
|
+
payment_method_types: ["mtn_momo"],
|
|
351
|
+
});
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
`host`, `port` and `protocol` are not optional. The authenticator is bound to
|
|
355
|
+
`baseUrl`'s origin: a request addressed anywhere else throws `VpayConfigError`
|
|
356
|
+
before a token is minted. Omit them and stripe-node addresses
|
|
357
|
+
`api.stripe.com:443`, which is exactly the case this refusal exists to stop.
|
|
358
|
+
Behind a reverse proxy, or on split-horizon DNS, set `baseUrl` to the origin
|
|
359
|
+
this client actually connects to rather than the public one — both the token
|
|
360
|
+
exchange and the binding follow `baseUrl`, so a mismatch either mints the
|
|
361
|
+
token against an unreachable endpoint or refuses every request as addressed
|
|
362
|
+
elsewhere.
|
|
363
|
+
|
|
364
|
+
`stripe` is an **optional peer dependency** of this package: install it
|
|
365
|
+
yourself if you want this entry point, and `@vaam-apps/vpay-sdk`'s core entry never
|
|
366
|
+
imports it. This package itself still has **zero runtime dependencies**.
|
|
367
|
+
|
|
368
|
+
The empty first argument is not a trick — stripe-node's `_setAuthenticator`
|
|
369
|
+
refuses only _both_ an API key and an authenticator, or _neither_. The token
|
|
370
|
+
cache, its single-flight refresh and its `expires_in` safety margin are the
|
|
371
|
+
same ones `VpayClient` uses, not a second implementation.
|
|
372
|
+
|
|
373
|
+
`authenticator.invalidate()` discards the cached token. You need it because an
|
|
374
|
+
authenticator runs _before_ a request and its promise settles before any
|
|
375
|
+
status code exists — it cannot see a `401` and re-auth the way `VpayClient`
|
|
376
|
+
does:
|
|
377
|
+
|
|
378
|
+
```js
|
|
379
|
+
try {
|
|
380
|
+
await stripe.paymentIntents.retrieve(id);
|
|
381
|
+
} catch (err) {
|
|
382
|
+
if (err instanceof Stripe.errors.StripeAuthenticationError) {
|
|
383
|
+
authenticator.invalidate();
|
|
384
|
+
}
|
|
385
|
+
throw err;
|
|
386
|
+
}
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
### Where vpay diverges from Stripe
|
|
390
|
+
|
|
391
|
+
- **No API keys, ever.** `sk_live_…` means nothing here; authentication is the
|
|
392
|
+
OAuth2 `client_credentials` + `private_key_jwt` handshake above
|
|
393
|
+
([ADR-0010](../../docs/adr/0010-merchant-auth-private-key-jwt.md)). There is
|
|
394
|
+
no publishable key and no Connect equivalent.
|
|
395
|
+
- **`Idempotency-Key` is mandatory on every `/v1` POST**, not optional as at
|
|
396
|
+
Stripe. This costs you nothing: stripe-node puts a
|
|
397
|
+
`stripe-node-retry-<uuid>` key on every v1 POST unconditionally — _including_
|
|
398
|
+
when `maxNetworkRetries` is `0`. Pass your own with
|
|
399
|
+
`{ idempotencyKey: "order_1234_attempt_1" }` when you want the retry window
|
|
400
|
+
to span more than one process.
|
|
401
|
+
- **XAF and EUR only.** XAF is zero-decimal: `amount: 5000` is 5,000 FCFA, not
|
|
402
|
+
50.00 (see [docs/flows/money.md](../../docs/flows/money.md)).
|
|
403
|
+
- **`payment_method_types` is required and non-empty**, and must name an
|
|
404
|
+
enabled vpay rail. A copy-pasted `automatic_payment_methods: { enabled: true }`
|
|
405
|
+
gets a `400` naming `payment_method_types`.
|
|
406
|
+
- **No `confirm: true` on create, no `search`, no Connect.** Confirm with a
|
|
407
|
+
second call to `stripe.paymentIntents.confirm(id, …)`: `confirm: true` on
|
|
408
|
+
create is **refused** with `param: "confirm"` and a message naming the
|
|
409
|
+
confirm route. `stripe.paymentIntents.search(…)` and every `stripeAccount` /
|
|
410
|
+
Connect parameter have no vpay counterpart.
|
|
411
|
+
- **The fields that decide where or when money moves are refused, not
|
|
412
|
+
ignored.** `capture_method` with any value other than `automatic`,
|
|
413
|
+
`application_fee_amount`, `transfer_data` and `on_behalf_of` come back as a
|
|
414
|
+
`400` naming the field in `error.param`. vpay has no authorise-now /
|
|
415
|
+
capture-later split — confirming _is_ the charge — and no Connect, so
|
|
416
|
+
ignoring any of them would move a merchant's money somewhere, or at a time,
|
|
417
|
+
they did not ask for and could not see in the response.
|
|
418
|
+
|
|
419
|
+
Everything else Stripe sends and vpay does not implement is **accepted and
|
|
420
|
+
ignored**, because none of it changes that: `setup_future_usage`,
|
|
421
|
+
`confirmation_method`, `receipt_email`, `statement_descriptor`, `customer`,
|
|
422
|
+
`expand` and `metadata` (`metadata` is actually stored) all leave the
|
|
423
|
+
payment exactly as requested. Nothing is expandable, so an `expand` request
|
|
424
|
+
simply produces no expanded field — visible in the response itself.
|
|
425
|
+
|
|
426
|
+
- **Rail codes are vpay's, not Stripe's.** `payment_method_types` entries and
|
|
427
|
+
`payment_method_data.type` are `mtn_momo` / `orange_money`; stripe-node's
|
|
428
|
+
TypeScript types enumerate Stripe's own rails and reject them, so a cast is
|
|
429
|
+
needed at those call sites — the values are right, the _type_ is Stripe's:
|
|
430
|
+
|
|
431
|
+
```ts
|
|
432
|
+
await stripe.paymentIntents.confirm(id, {
|
|
433
|
+
payment_method_data: {
|
|
434
|
+
type: "mtn_momo",
|
|
435
|
+
mtn_momo: { msisdn: "237670000000" },
|
|
436
|
+
},
|
|
437
|
+
} as unknown as Stripe.PaymentIntentConfirmParams);
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
- **`429` is never emitted.** Nothing in vpay constructs a rate-limit
|
|
441
|
+
category, so `Stripe.errors.StripeRateLimitError` cannot occur. Do not build
|
|
442
|
+
a backoff path around it.
|
|
443
|
+
- **`client_secret` is present, but only on `create`/`retrieve`.** vpay's
|
|
444
|
+
`create()` and `retrieve()` responses carry it — see "Usage" above — for
|
|
445
|
+
`@vaam-apps/vpay-stripe-js`'s browser-side confirm flow. `amount_received`,
|
|
446
|
+
`capture_method` and `confirmation_method` stay genuinely absent, though:
|
|
447
|
+
stripe-node's TypeScript types declare all three as present on every
|
|
448
|
+
response shape — it casts responses, it never validates them — so these
|
|
449
|
+
read as `undefined` at runtime while the type says otherwise, on every
|
|
450
|
+
route including `create`/`retrieve`.
|
|
451
|
+
- **`Stripe-Version`, `Stripe-Account`, `Stripe-Context` and the
|
|
452
|
+
`X-Stripe-Client-*` headers are accepted and ignored.** vpay advertises no
|
|
453
|
+
dated API version, so `obj.lastResponse.apiVersion` is `undefined` and
|
|
454
|
+
pinning `apiVersion` has no effect.
|
|
455
|
+
|
|
456
|
+
### Webhooks
|
|
457
|
+
|
|
458
|
+
vpay signs outbound deliveries with the same construction Stripe uses —
|
|
459
|
+
`t=<unix>,v1=<lowercase hex HMAC-SHA256 of "<t>.<raw body>">` — so
|
|
460
|
+
`stripe.webhooks.constructEvent(rawBody, header, secret)` verifies a vpay
|
|
461
|
+
delivery byte for byte. Read the header value, not the request object, and
|
|
462
|
+
give it the **raw** body:
|
|
463
|
+
|
|
464
|
+
```js
|
|
465
|
+
const event = stripe.webhooks.constructEvent(
|
|
466
|
+
rawBody,
|
|
467
|
+
req.headers["stripe-signature"],
|
|
468
|
+
process.env.VPAY_WEBHOOK_SECRET,
|
|
469
|
+
);
|
|
470
|
+
```
|
|
471
|
+
|
|
472
|
+
**Either header works, and that snippet is observed rather than argued.**
|
|
473
|
+
`vpay_worker`'s deliverer sends `Vpay-Signature` and `Stripe-Signature`
|
|
474
|
+
carrying the same string, byte for byte, and
|
|
475
|
+
`sdks/stripe-compat`'s `webhooks.compat.test.ts` takes a delivery out of a
|
|
476
|
+
WireMock receiver's request journal and puts it through
|
|
477
|
+
`stripe.webhooks.constructEvent` exactly as written above — then requires the
|
|
478
|
+
same call to throw `StripeSignatureVerificationError` for a body with one byte
|
|
479
|
+
flipped and for the right body with the wrong secret.
|
|
480
|
+
|
|
481
|
+
`Stripe-Signature` exists only so that a copy-pasted Stripe recipe needs no
|
|
482
|
+
edit at all. Code written from scratch should read the authoritative name
|
|
483
|
+
instead — the same call, one word changed, verifying the same bytes:
|
|
484
|
+
|
|
485
|
+
```js
|
|
486
|
+
const event = stripe.webhooks.constructEvent(
|
|
487
|
+
rawBody,
|
|
488
|
+
req.headers["vpay-signature"],
|
|
489
|
+
process.env.VPAY_WEBHOOK_SECRET,
|
|
490
|
+
);
|
|
491
|
+
```
|
|
492
|
+
|
|
493
|
+
**`Vpay-Signature` is the authoritative name** whatever else is added beside
|
|
494
|
+
it — prefer it in code you write from scratch, and use this package's own
|
|
495
|
+
`verifyWebhook` if you are not otherwise using stripe-node.
|
|
496
|
+
|
|
497
|
+
### A stripe-node defect you will hit if your key is wrong
|
|
498
|
+
|
|
499
|
+
**Measured against `stripe@22.6.1`, not inferred.** When the vpay handshake
|
|
500
|
+
fails — a bad key, an unregistered `client_id`, vpay unreachable — the
|
|
501
|
+
authenticator rejects, as it must. stripe-node builds the right error from it
|
|
502
|
+
(`StripeError: Unable to authenticate the request`, with the underlying
|
|
503
|
+
`VpayAuthError` at `err.raw.exception`) but **throws it inside a detached
|
|
504
|
+
promise chain that never calls its own callback**. The result: the error
|
|
505
|
+
arrives as a process-level `unhandledRejection`, and the promise you awaited
|
|
506
|
+
**never settles at all** — not even after `timeout`, because no HTTP request
|
|
507
|
+
was ever started for a timeout to fire against.
|
|
508
|
+
|
|
509
|
+
Two of the ways a key can be wrong no longer reach that path at all. A
|
|
510
|
+
`privateKey` that `node:crypto` cannot read, and a `baseUrl` that is not an
|
|
511
|
+
absolute URL, are both `VpayConfigError` thrown by `createStripeAuthenticator`
|
|
512
|
+
**at construction** — before a `Stripe` instance exists, on the line the
|
|
513
|
+
merchant wrote. What is left for the warning below is the set of failures only
|
|
514
|
+
the OP can report: an unregistered `client_id`, a key whose public half vpay
|
|
515
|
+
does not hold, a clock too far out, vpay unreachable.
|
|
516
|
+
|
|
517
|
+
Until stripe-node fixes this, do not rely on `try`/`catch` around a call to
|
|
518
|
+
surface a handshake failure. Two things that do work:
|
|
519
|
+
|
|
520
|
+
- **Verify the handshake at startup**, where a rejection is yours to catch.
|
|
521
|
+
The authenticator is a plain function, so call it once against a throwaway
|
|
522
|
+
request object and let it throw before you serve traffic — a `VpayAuthError`
|
|
523
|
+
there names the OAuth2 reason (`invalid_client`, …) directly:
|
|
524
|
+
|
|
525
|
+
```js
|
|
526
|
+
await authenticator({ headers: {} });
|
|
527
|
+
```
|
|
528
|
+
|
|
529
|
+
- **Install a process-level guard** so a mid-flight handshake failure is at
|
|
530
|
+
least logged and alerted on rather than silently hanging a request:
|
|
531
|
+
`process.on("unhandledRejection", …)`.
|
|
532
|
+
|
|
533
|
+
`src/stripe-auth.test.ts` pins this behaviour, so if a future `stripe` release
|
|
534
|
+
routes the failure to the caller the test fails and this warning gets deleted.
|
|
535
|
+
|
|
536
|
+
### Status
|
|
537
|
+
|
|
538
|
+
`createStripeAuthenticator` is **built and tested**. Its tests run against a
|
|
539
|
+
real `node:http` server and, for the end-to-end case, drive the **real
|
|
540
|
+
`stripe` package**: `new Stripe("", { authenticator, host, port, protocol })`
|
|
541
|
+
creating a PaymentIntent, with the stub asserting the `Authorization` header
|
|
542
|
+
the authenticator minted, the form-encoded body, and stripe-node's
|
|
543
|
+
auto-generated `Idempotency-Key`. The type-level assertion that the returned
|
|
544
|
+
function is assignable to `Stripe.StripeConfig["authenticator"]` is checked by
|
|
545
|
+
`pnpm --filter @vaam-apps/vpay-sdk typecheck`.
|
|
546
|
+
|
|
547
|
+
What is **not** proven here:
|
|
548
|
+
|
|
549
|
+
- **Nothing in this package has run against a real `vpay-server`.** The
|
|
550
|
+
end-to-end test's server is a stub in this repository. What _has_ run
|
|
551
|
+
against a real one is
|
|
552
|
+
[`sdks/stripe-compat`](../stripe-compat/) — the official `stripe` package
|
|
553
|
+
driven through this authenticator against a live compose stack, 25 cases,
|
|
554
|
+
run by CI's `e2e (compose)` job. It is a separate package on purpose: it
|
|
555
|
+
needs a stack, and a suite that needs a stack must not be able to report
|
|
556
|
+
green in a job that has none.
|
|
557
|
+
- **Every webhook verified so far was delivered to a WireMock receiver on a
|
|
558
|
+
compose network.** The _signature_ is now proven three ways — against this
|
|
559
|
+
package's own `verifyWebhook`, against `vpay_sdk::webhooks::verify_at`, and
|
|
560
|
+
against the official `stripe` package's `constructEvent` — but **no merchant
|
|
561
|
+
endpoint has ever been POSTed to**, and there is no SSRF protection on the
|
|
562
|
+
destination ([`docs/flows/webhooks.md`](../../docs/flows/webhooks.md)).
|
|
563
|
+
- **The `stripe` peer range (`^22.6.1`) is asserted, not derived.** It is the
|
|
564
|
+
current major at the time of writing, tested against exactly `22.6.1`.
|
|
565
|
+
Nothing pins vpay against a future `stripe` release; a dependency bump is
|
|
566
|
+
what will surface a break.
|
|
567
|
+
- **The server-side compatibility work is not in this package**, though it
|
|
568
|
+
has now landed beside it: the `request-id` response header stripe-node
|
|
569
|
+
reads, `stripe-should-retry` derived from `Classify::retry`, and refusing
|
|
570
|
+
`confirm: true` on create rather than ignoring it are all in `vpay-api`,
|
|
571
|
+
with their own unit and integration tests, and are exercised end to end by
|
|
572
|
+
`sdks/stripe-compat`. See
|
|
573
|
+
[`docs/flows/stripe-sdk-compat.md`](../../docs/flows/stripe-sdk-compat.md).
|
|
574
|
+
|
|
575
|
+
## `scripts/mint-assertion.mjs`
|
|
576
|
+
|
|
577
|
+
Mints one assertion and prints it, alongside the public JWK derived from the
|
|
578
|
+
same private key, as JSON on stdout — used to feed a real OP verifier (e.g. a
|
|
579
|
+
Rust example driving `authkestra_op::client_assertion::verify_client_assertion`
|
|
580
|
+
directly) without standing up a server:
|
|
581
|
+
|
|
582
|
+
```bash
|
|
583
|
+
pnpm --filter @vaam-apps/vpay-sdk build
|
|
584
|
+
VPAY_CLIENT_ID=merchant_a \
|
|
585
|
+
VPAY_PRIVATE_KEY_FILE=./merchant_a.key.pem \
|
|
586
|
+
VPAY_AUDIENCE=https://api.vpay.example/v1/oauth/token \
|
|
587
|
+
node sdks/nodejs/scripts/mint-assertion.mjs
|
|
588
|
+
```
|
|
589
|
+
|
|
590
|
+
## Status
|
|
591
|
+
|
|
592
|
+
**Corrected 2026-09-03.** This section used to say "what it does **not**
|
|
593
|
+
have is a single `/v1` resource route" — true when it was written on
|
|
594
|
+
2026-09-02, and false since Step 2 landed the next day.
|
|
595
|
+
|
|
596
|
+
`vpay-server` mounts the merchant OP — `POST /v1/oauth/token`,
|
|
597
|
+
`GET /v1/oauth/jwks.json`,
|
|
598
|
+
`GET /v1/oauth/.well-known/openid-configuration` — puts a bearer-token
|
|
599
|
+
boundary in front of every other `/v1` path, and **serves four
|
|
600
|
+
payment-intent routes past it**: create, retrieve and list on
|
|
601
|
+
`/v1/payment_intents`, plus `/{id}`, `/{id}/confirm` and `/{id}/cancel`. A
|
|
602
|
+
confirm reaches a real rail adapter over HTTP and moves the intent to
|
|
603
|
+
`processing`, and `vpay-worker` then polls the rail and settles it, so an
|
|
604
|
+
intent does reach **`succeeded`** — `sdks/stripe-compat` watches one do so
|
|
605
|
+
through `paymentIntents.retrieve` and nothing else. `/v1/events`,
|
|
606
|
+
`/v1/events/{id}` (Step 5) and `GET /v1/refunds/{id}` (issue #45,
|
|
607
|
+
2026-09-05) are served, and so are `/v1/customers` (S4a) and — since
|
|
608
|
+
2026-09-07 — `/v1/invoices` and `/v1/invoice_items` (S4b), which this
|
|
609
|
+
package has wrapped since 2026-09-08. `POST /v1/refunds` and `/v1/balance`
|
|
610
|
+
are still the honest `404 unknown_route`. ~~**This package has no client method for any of
|
|
611
|
+
that beyond the payment-intent routes it already wraps.**~~ **— corrected
|
|
612
|
+
2026-09-05: it has `refunds.retrieve(id)`,** added in the same change that
|
|
613
|
+
served the route, so this package can now make the refund read a
|
|
614
|
+
reconciliation job needs when a `charge.refund.updated` was missed or
|
|
615
|
+
arrived out of order. It still has no `events.retrieve` — a dated gap in
|
|
616
|
+
[`../../docs/sdks/parity.md`](../../docs/sdks/parity.md) — and
|
|
617
|
+
`refunds.create` and `balance.retrieve` still reach the 404.
|
|
618
|
+
|
|
619
|
+
**`checkout.sessions` is newer than this paragraph and less proven than the
|
|
620
|
+
rest.** The four methods are implemented and tested against this package's
|
|
621
|
+
`node:http` stub, in the shape of Step 9's wire contract. The routes they
|
|
622
|
+
call — `/v1/checkout/sessions` and its three siblings — are built by lane 1
|
|
623
|
+
of the same step; until that lands and
|
|
624
|
+
`backends/tests/integration/tests/checkout_sessions.rs` is green, "the stub
|
|
625
|
+
answers the way this SDK expects" is the whole of the evidence, and a
|
|
626
|
+
mismatch would show up as a `VpayApiError` rather than as anything this
|
|
627
|
+
package could catch earlier.
|
|
628
|
+
|
|
629
|
+
**No test in _this package_ has ever talked to a vpay** — every server in
|
|
630
|
+
these tests is a `node:http` stub started by the test. What has talked to a
|
|
631
|
+
real one is `sdks/stripe-compat`, which drives the official `stripe` package
|
|
632
|
+
through this package's own `createStripeAuthenticator` against a live
|
|
633
|
+
compose stack (25 cases, CI's `e2e (compose)` job), and the Rust SDK's
|
|
634
|
+
`backends/tests/integration/tests/merchant_token_flow.rs`. See
|
|
635
|
+
[`docs/status.md`](../../docs/status.md),
|
|
636
|
+
[`docs/flows/merchant-auth.md`](../../docs/flows/merchant-auth.md) and
|
|
637
|
+
[`docs/flows/stripe-sdk-compat.md`](../../docs/flows/stripe-sdk-compat.md).
|
|
638
|
+
|
|
639
|
+
What the tests in this package **do** prove. Everything that touches HTTP
|
|
640
|
+
runs against a real `node:http` server started by the test (never a mocked
|
|
641
|
+
`fetch`) and asserts the actual bytes sent on the wire; the form encoder,
|
|
642
|
+
the webhook verifier and the error-body bounding are pure unit tests, since
|
|
643
|
+
none of them touches the network:
|
|
644
|
+
|
|
645
|
+
- The client assertion this SDK mints has every claim and header field the
|
|
646
|
+
wire contract specifies, is a fresh `jti` per mint, and its RS256 signature
|
|
647
|
+
verifies with `node:crypto` against the matching public key — and fails
|
|
648
|
+
against a different one.
|
|
649
|
+
- The full `client_credentials` + `private_key_jwt` token exchange: exact
|
|
650
|
+
form fields, content type, and the resulting `Authorization: Bearer`
|
|
651
|
+
header on the following resource call.
|
|
652
|
+
- Token caching (`expires_in` minus the safety margin), single-flight
|
|
653
|
+
refresh under concurrent callers, and the single 401-triggered re-auth
|
|
654
|
+
retry (including that a second consecutive 401 surfaces as `VpayApiError`
|
|
655
|
+
without a third token request).
|
|
656
|
+
- Every resource method's exact HTTP method, path, headers, and
|
|
657
|
+
bracket-encoded body — including that a caller-supplied `Idempotency-Key`
|
|
658
|
+
is honoured and one is generated when omitted.
|
|
659
|
+
- That `create()` and `retrieve()` surface `client_secret` typed (`string`)
|
|
660
|
+
when the server sends it, and that a `list()` item without one reads back
|
|
661
|
+
`undefined` rather than a cast or a runtime crash.
|
|
662
|
+
- All four `checkout.sessions` operations: the exact path, method,
|
|
663
|
+
`Idempotency-Key` and body of `create` (including that an unset `ui_mode`
|
|
664
|
+
or URL is omitted rather than sent empty), the `GET` paths of `retrieve`
|
|
665
|
+
and `list` with its `payment_intent` filter, `expire`'s empty-bodied
|
|
666
|
+
`POST`, percent-encoding of a hostile id on both, and the `404`/`409`
|
|
667
|
+
envelopes. The body strings are the ones `sdks/rust/tests/resources.rs`
|
|
668
|
+
pins byte for byte, so the two SDKs cannot drift.
|
|
669
|
+
- That a `CheckoutSession`'s `client_secret` — and the fragment of a hosted
|
|
670
|
+
session's `url`, which carries the same value — are redacted from
|
|
671
|
+
`util.inspect` while `JSON.stringify` stays faithful.
|
|
672
|
+
- The form encoder's nested-object/array/boolean/percent-encoding rules, and
|
|
673
|
+
that a non-integer `amount` throws `TypeError` before any request is sent.
|
|
674
|
+
- Error mapping for a Stripe-shaped `400`, an unexpected `502` HTML body, a
|
|
675
|
+
connection failure, and a token-endpoint `401` (which is never retried).
|
|
676
|
+
- Webhook verification: valid/invalid signatures, timestamp tolerance,
|
|
677
|
+
multi-signature secret rotation, malformed headers, a one-byte body change,
|
|
678
|
+
and that a `Buffer` body and its string form verify identically.
|
|
679
|
+
- That `util.inspect(client)` and `JSON.stringify(client)` never contain the
|
|
680
|
+
private key, and that neither the client nor any error this SDK throws
|
|
681
|
+
carries an access token into `util.inspect` output.
|
|
682
|
+
- All thirteen invoice operations (S4b, added 2026-09-08): the exact body of
|
|
683
|
+
`invoices.create` and of a create that supplies nothing but the two required
|
|
684
|
+
fields (`customer` and `currency` — the second was optional for one day, and
|
|
685
|
+
a running vpay answers `400` without it); the `GET`
|
|
686
|
+
whose response carries the lines; the update's three states (`due_date=` is
|
|
687
|
+
_clear it_, an unmentioned field is absent, and a patch that mentions
|
|
688
|
+
nothing sends nothing); the list's two cursors and two filters; the
|
|
689
|
+
`DELETE` with its key and no body; that `finalize`, `void` and
|
|
690
|
+
`markUncollectible` each `POST` an **empty** body to its own path while
|
|
691
|
+
still sending a content type (the third method is camelCase and its route,
|
|
692
|
+
`/v1/invoices/{id}/mark_uncollectible`, is not — the case asserts the path
|
|
693
|
+
by name); that a refused transition arrives as a
|
|
694
|
+
`VpayApiError` carrying the server's `409` whole; that `invoices.pay` sends
|
|
695
|
+
both URLs and answers an invoice still `"open"` with a `payment_intent` and
|
|
696
|
+
a `hosted_invoice_url`; the four `invoiceItems` methods, including that
|
|
697
|
+
neither `currency` nor `amount` is ever in the body; and that a
|
|
698
|
+
`unit_amount` outside the safe-integer range throws `TypeError` before any
|
|
699
|
+
request. Every body string is the one `sdks/rust/tests/resources.rs` pins,
|
|
700
|
+
asserted as a literal on both sides.
|
|
701
|
+
- **Against a real `vpay-server`, not a stub** (added 2026-09-08):
|
|
702
|
+
`src/invoices.live.test.ts` creates a customer, drafts an invoice, bills two
|
|
703
|
+
lines, finalizes it, reads it back, checks the stored lines add up to the
|
|
704
|
+
stored total, lists it, voids it and finds it keeps its number; then
|
|
705
|
+
finalizes a second and has `pay` mint a payment intent and a hosted URL
|
|
706
|
+
without charging anybody; and confirms `currency` is required. It is a
|
|
707
|
+
**separate vitest project** (`vitest.live.config.ts`, `pnpm test:live`) so
|
|
708
|
+
`pnpm test` stays runnable with no stack, and it **fails rather than
|
|
709
|
+
skipping** when there is none — `just sdk-live` is what brings one up.
|
|
710
|
+
- That the four `invoice.*` types are in `KnownEventType` and narrow through
|
|
711
|
+
`isInvoiceEvent`, that `isPaymentIntentEvent` and `isCheckoutSessionEvent`
|
|
712
|
+
do **not** claim them, and that an event body's empty `lines` decodes
|
|
713
|
+
through the same `Invoice` type a `/v1` response's populated `lines` does.
|
|
714
|
+
|
|
715
|
+
**Separately — and this is _not_ one of the tests above — the assertion has
|
|
716
|
+
been checked against the real OP verifier by hand.** An assertion minted by
|
|
717
|
+
this SDK was fed directly to
|
|
718
|
+
`authkestra_op::client_assertion::verify_client_assertion` at the pinned
|
|
719
|
+
`authkestra-op = "=0.7.1"`, against a `ClientRegistration` holding the
|
|
720
|
+
corresponding public JWK, and was accepted. `just sdk-conformance-node`
|
|
721
|
+
reproduces it: the recipe mints an assertion via `scripts/mint-assertion.mjs`
|
|
722
|
+
and pipes it into `cargo run -p vpay-sdk --example verify_assertion`. It is a
|
|
723
|
+
manual check, run on demand — it needs a Rust toolchain, it is not part of
|
|
724
|
+
`pnpm test`, and no CI job gates on it. Nothing in the list above depends on
|
|
725
|
+
it having been run.
|
|
726
|
+
|
|
727
|
+
What this does **not** prove: that any of this works against a real vpay
|
|
728
|
+
deployment. Nothing in this package has been run against `vpay-server`; the
|
|
729
|
+
`issuer`/`tokenEndpoint` defaults agree with `vpay_api::op::issuer_for` by
|
|
730
|
+
inspection, not by a test in this repository that exercises them together,
|
|
731
|
+
and the resource routes every method here calls do not exist server-side at
|
|
732
|
+
all — see [`docs/status.md`](../../docs/status.md).
|