@secureprocessing/node 0.0.0-stage → 0.2.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Secure Processing
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,249 @@
1
- # Temporary Holding Version
1
+ # @secureprocessing/node
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Card payments for Belize. Zero dependencies; runs on Node 20+, Deno, Bun and Cloudflare Workers.
4
+
5
+ ## Install
6
+
7
+ From our own domain — this file is the npm package itself:
8
+
9
+ ```bash
10
+ npm install https://secureprocessing.app/sdk/secureprocessing-node-0.2.0.tgz
11
+ ```
12
+
13
+ It installs as `@secureprocessing/node`, so every `import ... from "@secureprocessing/node"` below
14
+ works unchanged, and your lockfile pins its hash. `pnpm add` takes the same URL. Once the package is
15
+ on the npm registry, `npm install @secureprocessing/node@0.2.0` gives you these same bytes;
16
+ [the docs](https://docs.secureprocessing.app/sdk) say which of the two to use today.
17
+
18
+ Deno, including Supabase Edge Functions:
19
+
20
+ ```ts
21
+ import { SecureProcessing } from "https://secureprocessing.app/sdk/0.2.0/index.js";
22
+
23
+ // Deno 2 imports only from its built-in allow-list unless told otherwise:
24
+ // deno run --allow-net --allow-env --allow-import=secureprocessing.app your-script.ts
25
+ ```
26
+
27
+ The types come with it: every hosted `.js` names its own `.d.ts`, so `deno check` and the Deno
28
+ language server see them with no `@deno-types` line. (0.1.0 needed one and still lost every type
29
+ behind its first import; 0.2.0 fixes that.)
30
+
31
+ Both URLs are pinned to a version and that version's bytes never change, so the code you reviewed is
32
+ the code that runs.
33
+
34
+ ## What is in it
35
+
36
+ `paymentIntents`, `checkoutSessions`, `refunds`, `customers`, `paymentMethods`, `products`, `prices`,
37
+ `paymentLinks`, `taxRates`, `invoices`, `invoiceItems`, `balance`, `balanceTransactions`, `payouts`,
38
+ `reports`, `account`, `events`, `webhookEndpoints`, `riskRules`, `riskLists`, `reviews`, `disputes`,
39
+ `webhooks.constructEvent`, and `autoPaginate` for walking a list — every call the docs site shows,
40
+ which a test in this package checks by reading the docs.
41
+
42
+ ## Take a payment
43
+
44
+ Amounts are integers in the currency's minor unit. `6000` is $60.00 — there are no decimals in
45
+ this API, in either direction.
46
+
47
+ ```ts
48
+ import { SecureProcessing } from "@secureprocessing/node";
49
+
50
+ const sp = new SecureProcessing(process.env.SECURE_PROCESSING_KEY!);
51
+
52
+ const intent = await sp.paymentIntents.create({
53
+ amount: 6000,
54
+ currency: "usd",
55
+ // Reaches the bank's own record of the payment, and is what you will see in their portal.
56
+ // Lead with your own reference so you can find it again.
57
+ description: "SCU-B0161 — Blue Hole Two-Tank",
58
+ metadata: { shop_id: "scuba-sensation", booking_id: "SCU-B0161" },
59
+ });
60
+
61
+ const session = await sp.checkoutSessions.create({
62
+ payment_intent: intent.id,
63
+ success_url: "https://example.com/thanks?session={CHECKOUT_SESSION_ID}",
64
+ cancel_url: "https://example.com/cart",
65
+ });
66
+
67
+ redirect(session.url!); // the card is entered on the bank's page, never on yours
68
+ ```
69
+
70
+ ## Hold now, charge later
71
+
72
+ ```ts
73
+ const intent = await sp.paymentIntents.create({
74
+ amount: 20000,
75
+ currency: "usd",
76
+ capture_method: "manual", // places a hold
77
+ });
78
+ // ...once you know the final amount:
79
+ await sp.paymentIntents.capture(intent.id, { amount_to_capture: 17500 });
80
+ // or release it entirely:
81
+ await sp.paymentIntents.cancel(intent.id);
82
+ ```
83
+
84
+ ## Refunds
85
+
86
+ Full or partial. A full refund of a payment that has not settled yet is sent as a reversal, which
87
+ is faster and often never reaches the cardholder's statement. You do not choose: we use whichever
88
+ the bank accepts.
89
+
90
+ ```ts
91
+ await sp.refunds.create({ payment_intent: intent.id }); // all of it
92
+ await sp.refunds.create({ payment_intent: intent.id, amount: 2000 }); // part of it
93
+ ```
94
+
95
+ ## Webhooks
96
+
97
+ ```ts
98
+ const event = await sp.webhooks.constructEvent(
99
+ rawBody, // the RAW string, not a parsed object
100
+ request.headers.get("Secure-Processing-Signature")!,
101
+ process.env.SECURE_PROCESSING_WEBHOOK_SECRET!,
102
+ );
103
+
104
+ if (event.type === "payment_intent.succeeded") {
105
+ const payment = event.data.object;
106
+ // fulfil the order
107
+ }
108
+ ```
109
+
110
+ > **Pass the raw body.** `JSON.stringify(JSON.parse(body))` produces different bytes, so the
111
+ > signature will not match. This is the most common reason a first webhook integration fails, and
112
+ > the error message will tell you so.
113
+
114
+ Deliveries are retried for about 35 hours. Handle events idempotently — key on `event.id` — because
115
+ at-least-once delivery means you will occasionally see one twice.
116
+
117
+ ## Idempotency
118
+
119
+ Every `POST` carries an `Idempotency-Key` automatically, and it is kept across the client's own
120
+ retries, so a timeout cannot take a second payment. Supply your own when the retry might come from
121
+ elsewhere — a job runner, a queue, a customer pressing the button again:
122
+
123
+ ```ts
124
+ await sp.paymentIntents.create(params, { idempotencyKey: `order-${orderId}` });
125
+ ```
126
+
127
+ Reusing a key with a *different* body is an error rather than a second payment.
128
+
129
+ If the first request with a key is still running, the API answers `409 idempotency_in_progress`.
130
+ The client retries that with the same key, up to `maxRetries` times and never past 30 seconds from
131
+ the first attempt — it cannot run twice; the API answers with the first request's result once it
132
+ has one — and then hands you the error, whose `isRetryable` is `true`. The same 30-second limit
133
+ applies to every retry of a POST, including after a timeout or a dropped connection: past 60
134
+ seconds the API may treat the first request as dead and run a retry again.
135
+
136
+ 🔴 **Retrying yourself? Use the same key.** Every error from a POST carries the key it was sent
137
+ with — the one you gave, or the one generated for you — as `err.idempotencyKey`, on
138
+ `SecureProcessingError` and `SecureProcessingConnectionError` alike. Calling the method again
139
+ without it sends a NEW key, which the API treats as a new request: a second charge or refund if the
140
+ first one reached the bank.
141
+
142
+ ```ts
143
+ try {
144
+ await sp.refunds.create({ payment_intent: id });
145
+ } catch (err) {
146
+ if ((err instanceof SecureProcessingError && err.isRetryable) || err instanceof SecureProcessingConnectionError) {
147
+ await sp.refunds.create({ payment_intent: id }, { idempotencyKey: err.idempotencyKey }); // never a new key
148
+ }
149
+ }
150
+ ```
151
+
152
+ `409 payment_in_progress` is different: cancelling a payment the customer may be paying right now
153
+ (on the bank's page, or in 3-D Secure). Nothing was changed, the message says when to try again,
154
+ and `err.isPaymentInProgress` is `true`. The client never retries it — it is minutes, not a backoff.
155
+
156
+ 🔴 `500 outcome_unknown` (`err.isOutcomeUnknown`) means the request reached the bank and the result
157
+ could not be confirmed: **the money may have moved.** Never repeat it under a new idempotency key —
158
+ that is a second charge or refund. Retrying under the same key (`err.idempotencyKey`, which the
159
+ message also names) is safe but gains nothing now: usually it replays this answer, and when the
160
+ message says the original "has not recorded its result yet", a retry later returns that result. So
161
+ the client does not retry it, and `isRetryable` is `false`. Retrieve the payment (or list its refunds) and check
162
+ its `status`; it settles on its own and its webhook follows.
163
+
164
+ A `202` is the same situation answered with an object: `invoices.pay` (charge sent, outcome not
165
+ back) and `reviews.refund` (refund pending, review still open). Tell it from a `200` with
166
+ `result.lastResponse.statusCode === 202`.
167
+
168
+ ## Errors
169
+
170
+ ```ts
171
+ import { SecureProcessingError } from "@secureprocessing/node";
172
+
173
+ try {
174
+ await sp.refunds.create({ payment_intent: id });
175
+ } catch (err) {
176
+ if (err instanceof SecureProcessingError) {
177
+ console.error(err.type, err.code, err.declineCode, err.requestId);
178
+ if (err.isRetryable) { /* try again later, with { idempotencyKey: err.idempotencyKey } */ }
179
+ }
180
+ }
181
+ ```
182
+
183
+ Quote `err.requestId` to support and they can find the exact request.
184
+
185
+ A `403 insufficient_permissions` names what your API key or role lacks in `err.requiredCapability`
186
+ (for example `payments:refund`); ask the account's owner for it — retrying will not help. A
187
+ `404 resource_missing` caused by an id in the request body names that field in `err.param`. A
188
+ request body may be at most 1 MB (`413 body_too_large`); none of the API's own objects come close.
189
+ None of these three is retried.
190
+
191
+ A successful result carries its request id too, on a non-enumerable `lastResponse` (it does not
192
+ appear in `JSON.stringify`, spreads or `Object.keys` — so a copy such as `{ ...intent }` does not
193
+ have it at runtime, whatever TypeScript says; read it from the object the call returned):
194
+
195
+ ```ts
196
+ const intent = await sp.paymentIntents.create({ amount: 6000, currency: "usd" });
197
+ intent.lastResponse.requestId; // the X-Request-Id header
198
+ intent.lastResponse.idempotencyKey; // what this POST was sent with
199
+ intent.lastResponse.idempotentReplayed; // true if the API replayed an earlier answer
200
+ ```
201
+
202
+ ## Every page of a list
203
+
204
+ List calls return one page. `autoPaginate` walks all of them, fetching each only when you reach it:
205
+
206
+ ```ts
207
+ import { autoPaginate } from "@secureprocessing/node";
208
+
209
+ let total = 0;
210
+ for await (const line of autoPaginate((page) => sp.payouts.listTransactions(payoutId, page))) {
211
+ total += line.net;
212
+ }
213
+ ```
214
+
215
+ ## Currencies
216
+
217
+ Price in `usd` or `bzd`. Settlement is in BZD, and the payment object records what the card was
218
+ actually charged and at what rate:
219
+
220
+ ```ts
221
+ intent.amount; // 6000 — what you asked for, in USD cents
222
+ intent.settlement_amount; // 12000 — what the card was charged, in BZD cents
223
+ intent.settlement_currency; // "BZD"
224
+ intent.fx_rate; // 2
225
+ ```
226
+
227
+ The rate is frozen when the payment is taken, so a later capture or refund uses that rate and not
228
+ whatever today's is.
229
+
230
+ ## Test mode
231
+
232
+ Keys beginning `sk_test_` never move real money. Test cards:
233
+
234
+ | Number | Result |
235
+ |---|---|
236
+ | `4000001111111118` | Succeeds |
237
+ | `4444444411111111` | Declined |
238
+ | `5555555555555599` | 3-D Secure challenge |
239
+
240
+ ## Options
241
+
242
+ ```ts
243
+ new SecureProcessing(apiKey, {
244
+ baseUrl: "https://api.secureprocessing.app",
245
+ timeout: 30_000,
246
+ maxRetries: 2,
247
+ fetch: myFetch, // e.g. a Worker's fetch, or a test double
248
+ });
249
+ ```
@@ -0,0 +1,94 @@
1
+ export interface ClientOptions {
2
+ /** Defaults to the production API. */
3
+ baseUrl?: string;
4
+ /** `test` or `live`. Only needed when authenticating with a dashboard session. */
5
+ mode?: "test" | "live";
6
+ /** Supply your own fetch — a Worker's, a test double, or one with a proxy. */
7
+ fetch?: typeof fetch;
8
+ /** Milliseconds before giving up on a request. */
9
+ timeout?: number;
10
+ /** How many times to retry a request that failed in a way that might succeed. */
11
+ maxRetries?: number;
12
+ }
13
+ export interface RequestOptions {
14
+ /**
15
+ * Make this request safe to retry.
16
+ *
17
+ * One is generated for every POST if you do not supply one, so a retry after a timeout cannot
18
+ * take a second payment. Supply your own when the retry might come from somewhere else — a job
19
+ * runner, a queue, a user pressing the button again.
20
+ */
21
+ idempotencyKey?: string;
22
+ signal?: AbortSignal;
23
+ }
24
+ /**
25
+ * What came back with a result, besides the object itself. Read it as `result.lastResponse`.
26
+ *
27
+ * ```ts
28
+ * const intent = await sp.paymentIntents.create({ amount: 6000, currency: "usd" });
29
+ * log.info("created", intent.id, intent.lastResponse.requestId); // quote this to support
30
+ * ```
31
+ */
32
+ export interface ResponseMetadata {
33
+ /** The API's `X-Request-Id` for this call: what support needs to find it in our logs. */
34
+ requestId: string | undefined;
35
+ statusCode: number;
36
+ headers: Headers;
37
+ /** The `Idempotency-Key` this POST was sent with — yours, or the one generated for you. */
38
+ idempotencyKey: string | undefined;
39
+ /** True when the API replayed the stored answer to an earlier request with the same key. */
40
+ idempotentReplayed: boolean;
41
+ }
42
+ /**
43
+ * An API object with its response metadata attached, non-enumerably, as `lastResponse`.
44
+ *
45
+ * ⚠️ Non-enumerable means a copy loses it: `{ ...intent }` or `structuredClone(intent)` has no
46
+ * `lastResponse` at runtime, although TypeScript's spread type still claims one. Read it from the
47
+ * object the call returned.
48
+ */
49
+ export type ApiResponse<T> = T & {
50
+ readonly lastResponse: ResponseMetadata;
51
+ };
52
+ export declare class HttpClient {
53
+ private readonly apiKey;
54
+ private readonly baseUrl;
55
+ private readonly mode;
56
+ private readonly fetchImpl;
57
+ private readonly timeout;
58
+ private readonly maxRetries;
59
+ constructor(apiKey: string, options?: ClientOptions);
60
+ request<T>(method: "GET" | "POST" | "DELETE", path: string, body?: unknown, options?: RequestOptions): Promise<ApiResponse<T>>;
61
+ /**
62
+ * 🔴 Every retry of a keyed request (a POST) goes out within RETRY_WINDOW_MS of the FIRST
63
+ * attempt, or not at all — a 409 `idempotency_in_progress`, a 5xx and a connection failure alike.
64
+ *
65
+ * The API's idempotency store (packages/db, IN_PROGRESS_GRACE_SECONDS = 60) presumes a request
66
+ * whose key has been locked for 60 seconds has died, and lets the next caller with that key run
67
+ * the operation again. Inside the window a retry can only be answered with another 409 or with
68
+ * the first request's stored response; past it, a retry is a second execution if the first was
69
+ * merely slow. Before this applied to connection failures too, the defaults (30s timeout, two
70
+ * retries, backoff) could send a third attempt at about 63 seconds. So the client gives up at
71
+ * half the server's grace and hands the error back, carrying `idempotencyKey` so the caller can
72
+ * retry under the same key when they choose. A first attempt that timed out has already spent the
73
+ * window. A GET has no key and nothing to run twice, so only `maxRetries` limits it.
74
+ */
75
+ private mayRetry;
76
+ get<T>(path: string, query?: Record<string, unknown>): Promise<ApiResponse<T>>;
77
+ /**
78
+ * A GET whose body you want as a STREAM, not as parsed JSON.
79
+ *
80
+ * 🔴 Deliberately outside `request()`, and not retried. `request()` calls `response.json()`,
81
+ * which reads the whole body into memory — on a year of payments that is the point of the export
82
+ * defeated, and on a Worker it is the memory limit. Retrying is wrong here for a second reason:
83
+ * a retry can only happen after the first attempt's body has been abandoned, and a caller who
84
+ * has already started piping bytes to a file would have them silently interleaved with a second
85
+ * attempt's.
86
+ *
87
+ * The caller gets the `Response`: pipe `response.body`, or `await response.text()` for a small
88
+ * one. A non-2xx still throws a `SecureProcessingError` with the API's envelope, because the
89
+ * error path IS JSON and is small.
90
+ */
91
+ raw(path: string, query?: Record<string, unknown>, accept?: string): Promise<Response>;
92
+ post<T>(path: string, body?: unknown, options?: RequestOptions): Promise<ApiResponse<T>>;
93
+ delete<T>(path: string): Promise<ApiResponse<T>>;
94
+ }
package/dist/client.js ADDED
@@ -0,0 +1,196 @@
1
+ // @ts-self-types="./client.d.ts"
2
+ import { SecureProcessingConnectionError, SecureProcessingError } from "./errors.js";
3
+ import { VERSION } from "./version.js";
4
+ const REQUEST_ID_HEADER = "X-Request-Id";
5
+ const DEFAULT_BASE_URL = "https://api.secureprocessing.app";
6
+ const DEFAULT_TIMEOUT_MS = 30_000;
7
+ const DEFAULT_MAX_RETRIES = 2;
8
+ /** Half of the API's IN_PROGRESS_GRACE_SECONDS (60). See `mayRetry`. */
9
+ const RETRY_WINDOW_MS = 30_000;
10
+ export class HttpClient {
11
+ apiKey;
12
+ baseUrl;
13
+ mode;
14
+ fetchImpl;
15
+ timeout;
16
+ maxRetries;
17
+ constructor(apiKey, options = {}) {
18
+ if (!apiKey)
19
+ throw new Error("An API key is required");
20
+ this.apiKey = apiKey;
21
+ this.baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
22
+ this.mode = options.mode;
23
+ this.fetchImpl = options.fetch ?? globalThis.fetch;
24
+ this.timeout = options.timeout ?? DEFAULT_TIMEOUT_MS;
25
+ this.maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES;
26
+ }
27
+ async request(method, path, body, options = {}) {
28
+ const headers = {
29
+ Authorization: `Bearer ${this.apiKey}`,
30
+ Accept: "application/json",
31
+ "User-Agent": `secureprocessing-node/${VERSION}`,
32
+ };
33
+ if (this.mode)
34
+ headers["Secure-Processing-Mode"] = this.mode;
35
+ // Exactly this string, lower-case: the API answers `415 unsupported_media_type` to a body whose
36
+ // type is not `application/json` (or a lower-case `+json` type). A test pins it.
37
+ if (body !== undefined)
38
+ headers["Content-Type"] = "application/json";
39
+ // 🔴 Generated once, OUTSIDE the retry loop. A key regenerated per attempt would defeat the
40
+ // entire purpose: the retry would look like a new request and could take a second payment.
41
+ if (method === "POST") {
42
+ headers["Idempotency-Key"] = options.idempotencyKey ?? crypto.randomUUID();
43
+ }
44
+ const idempotencyKey = headers["Idempotency-Key"];
45
+ const startedAt = Date.now();
46
+ let lastError;
47
+ for (let attempt = 0; attempt <= this.maxRetries; attempt += 1) {
48
+ if (attempt > 0)
49
+ await sleep(backoffMs(attempt));
50
+ try {
51
+ const response = await this.fetchImpl(`${this.baseUrl}${path}`, {
52
+ method,
53
+ headers,
54
+ body: body === undefined ? undefined : JSON.stringify(body),
55
+ signal: options.signal ?? AbortSignal.timeout(this.timeout),
56
+ });
57
+ if (response.ok) {
58
+ return withLastResponse((await response.json()), response, idempotencyKey);
59
+ }
60
+ throw await errorFrom(response, idempotencyKey);
61
+ }
62
+ catch (err) {
63
+ if (err instanceof SecureProcessingError) {
64
+ if (!err.isRetryable || !this.mayRetry(attempt, startedAt, idempotencyKey))
65
+ throw err;
66
+ lastError = err;
67
+ continue;
68
+ }
69
+ // A connection failure is safe to retry precisely BECAUSE the idempotency key is fixed:
70
+ // if the first attempt reached us, the retry replays its answer rather than repeating it.
71
+ lastError = new SecureProcessingConnectionError(`Could not reach the Secure Processing API: ${err.message}`, err, idempotencyKey);
72
+ if (!this.mayRetry(attempt, startedAt, idempotencyKey))
73
+ throw lastError;
74
+ }
75
+ }
76
+ throw lastError;
77
+ }
78
+ /**
79
+ * 🔴 Every retry of a keyed request (a POST) goes out within RETRY_WINDOW_MS of the FIRST
80
+ * attempt, or not at all — a 409 `idempotency_in_progress`, a 5xx and a connection failure alike.
81
+ *
82
+ * The API's idempotency store (packages/db, IN_PROGRESS_GRACE_SECONDS = 60) presumes a request
83
+ * whose key has been locked for 60 seconds has died, and lets the next caller with that key run
84
+ * the operation again. Inside the window a retry can only be answered with another 409 or with
85
+ * the first request's stored response; past it, a retry is a second execution if the first was
86
+ * merely slow. Before this applied to connection failures too, the defaults (30s timeout, two
87
+ * retries, backoff) could send a third attempt at about 63 seconds. So the client gives up at
88
+ * half the server's grace and hands the error back, carrying `idempotencyKey` so the caller can
89
+ * retry under the same key when they choose. A first attempt that timed out has already spent the
90
+ * window. A GET has no key and nothing to run twice, so only `maxRetries` limits it.
91
+ */
92
+ mayRetry(attempt, startedAt, idempotencyKey) {
93
+ if (attempt >= this.maxRetries)
94
+ return false;
95
+ if (idempotencyKey === undefined)
96
+ return true;
97
+ return Date.now() - startedAt + backoffMs(attempt + 1) < RETRY_WINDOW_MS;
98
+ }
99
+ get(path, query) {
100
+ return this.request("GET", path + encodeQuery(query));
101
+ }
102
+ /**
103
+ * A GET whose body you want as a STREAM, not as parsed JSON.
104
+ *
105
+ * 🔴 Deliberately outside `request()`, and not retried. `request()` calls `response.json()`,
106
+ * which reads the whole body into memory — on a year of payments that is the point of the export
107
+ * defeated, and on a Worker it is the memory limit. Retrying is wrong here for a second reason:
108
+ * a retry can only happen after the first attempt's body has been abandoned, and a caller who
109
+ * has already started piping bytes to a file would have them silently interleaved with a second
110
+ * attempt's.
111
+ *
112
+ * The caller gets the `Response`: pipe `response.body`, or `await response.text()` for a small
113
+ * one. A non-2xx still throws a `SecureProcessingError` with the API's envelope, because the
114
+ * error path IS JSON and is small.
115
+ */
116
+ async raw(path, query, accept = "text/csv") {
117
+ const headers = {
118
+ Authorization: `Bearer ${this.apiKey}`,
119
+ Accept: accept,
120
+ "User-Agent": `secureprocessing-node/${VERSION}`,
121
+ };
122
+ if (this.mode)
123
+ headers["Secure-Processing-Mode"] = this.mode;
124
+ let response;
125
+ try {
126
+ response = await this.fetchImpl(`${this.baseUrl}${path}${encodeQuery(query)}`, {
127
+ method: "GET",
128
+ headers,
129
+ signal: AbortSignal.timeout(this.timeout),
130
+ });
131
+ }
132
+ catch (err) {
133
+ throw new SecureProcessingConnectionError(`Could not reach the Secure Processing API: ${err.message}`, err);
134
+ }
135
+ if (response.ok)
136
+ return response;
137
+ throw await errorFrom(response);
138
+ }
139
+ post(path, body, options) {
140
+ return this.request("POST", path, body ?? {}, options);
141
+ }
142
+ delete(path) {
143
+ return this.request("DELETE", path);
144
+ }
145
+ }
146
+ /** The API's envelope when there is one, and an honest stand-in when a proxy answered instead. */
147
+ async function errorFrom(response, idempotencyKey) {
148
+ const payload = (await response.json().catch(() => null));
149
+ const requestId = response.headers.get(REQUEST_ID_HEADER);
150
+ return payload?.error
151
+ ? new SecureProcessingError(response.status, payload, requestId, idempotencyKey)
152
+ : new SecureProcessingError(response.status, { error: { type: "api_error", code: "unexpected_response", message: `HTTP ${response.status}` } }, requestId, idempotencyKey);
153
+ }
154
+ /**
155
+ * Attach the response's metadata as a NON-ENUMERABLE `lastResponse`, the convention Stripe's Node
156
+ * SDK uses for the same problem: the request id of a SUCCESSFUL call lives only in a header, and
157
+ * returning `{ data, response }` instead would break every caller. Non-enumerable means
158
+ * `JSON.stringify`, spreading, `Object.keys` and deep-equality checks see exactly the API's object,
159
+ * so storing or comparing a result behaves as it did in 0.1.0.
160
+ */
161
+ function withLastResponse(body, response, idempotencyKey) {
162
+ if (body === null || typeof body !== "object")
163
+ return body;
164
+ const lastResponse = {
165
+ requestId: response.headers.get(REQUEST_ID_HEADER) ?? undefined,
166
+ statusCode: response.status,
167
+ headers: response.headers,
168
+ idempotencyKey,
169
+ idempotentReplayed: response.headers.get("Idempotent-Replayed") === "true",
170
+ };
171
+ Object.defineProperty(body, "lastResponse", { value: lastResponse, enumerable: false, writable: false });
172
+ return body;
173
+ }
174
+ /** Nested keys become `metadata[shop_id]=…`, which is what the list endpoints expect. */
175
+ function encodeQuery(query) {
176
+ if (!query)
177
+ return "";
178
+ const params = new URLSearchParams();
179
+ for (const [key, value] of Object.entries(query)) {
180
+ if (value === undefined || value === null)
181
+ continue;
182
+ if (typeof value === "object") {
183
+ for (const [inner, innerValue] of Object.entries(value)) {
184
+ if (innerValue !== undefined && innerValue !== null)
185
+ params.append(`${key}[${inner}]`, String(innerValue));
186
+ }
187
+ }
188
+ else {
189
+ params.append(key, String(value));
190
+ }
191
+ }
192
+ const encoded = params.toString();
193
+ return encoded ? `?${encoded}` : "";
194
+ }
195
+ const backoffMs = (attempt) => Math.min(1000 * 2 ** (attempt - 1), 8000) + Math.floor(Math.random() * 250);
196
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
@@ -0,0 +1,99 @@
1
+ /** The shape every non-2xx response from the API has. */
2
+ export interface ApiErrorBody {
3
+ error: {
4
+ type: string;
5
+ code: string;
6
+ message: string;
7
+ param?: string;
8
+ decline_code?: string;
9
+ request_id?: string;
10
+ /** On `403 insufficient_permissions`: the permission the request needed, e.g. `payments:refund`. */
11
+ required_capability?: string;
12
+ };
13
+ }
14
+ /**
15
+ * Something the API refused.
16
+ *
17
+ * Branch on `type` for the category and `code` for the specific reason. For a declined card,
18
+ * `declineCode` says why in terms you can show a customer. `requestId` is what to quote to support.
19
+ * `param` names the field at fault — including a `404 resource_missing` for an id in the body that
20
+ * is not yours (`param: "customer"`). A `403` names the missing permission in `requiredCapability`.
21
+ */
22
+ export declare class SecureProcessingError extends Error {
23
+ readonly type: string;
24
+ readonly code: string;
25
+ readonly param: string | undefined;
26
+ readonly declineCode: string | undefined;
27
+ readonly requestId: string | undefined;
28
+ readonly status: number;
29
+ /**
30
+ * The `Idempotency-Key` the failed request was sent with — yours, or the one the client generated
31
+ * for a POST you did not give a key. `undefined` for a GET, which has none.
32
+ *
33
+ * 🔴 To retry a failed POST, pass THIS back: `{ idempotencyKey: err.idempotencyKey }`. Calling the
34
+ * method again without it sends a new key, and a new key is a new request to the API: if the
35
+ * first one did reach the bank, that is a second charge or refund.
36
+ */
37
+ readonly idempotencyKey: string | undefined;
38
+ /**
39
+ * On `403 permission_error` / `insufficient_permissions`: the permission this request needed and
40
+ * your API key — or, from a dashboard session, your role — does not have, such as
41
+ * `payments:refund`. Ask the account's owner for it; retrying will not help (`isRetryable` is
42
+ * false). `undefined` on every other error.
43
+ */
44
+ readonly requiredCapability: string | undefined;
45
+ /**
46
+ * `requestId` falls back to the `X-Request-Id` response header: a response that never reached
47
+ * the API's error handler (a proxy's 502, say) has no envelope to carry it, and the header is
48
+ * the one thing support can still look up.
49
+ */
50
+ constructor(status: number, body: ApiErrorBody, requestIdHeader?: string | null, idempotencyKey?: string);
51
+ /**
52
+ * True when retrying the identical request — with the SAME idempotency key — could reasonably
53
+ * succeed soon. For a POST that means `{ idempotencyKey: err.idempotencyKey }`, never a new key.
54
+ *
55
+ * `idempotency_in_progress` is included: the first request with that key is still running, and
56
+ * the API answers a retry either with another 409 or, once it has finished, with its stored
57
+ * answer, so a retry under that key cannot run the operation twice. The client already retries
58
+ * it for you, up to `maxRetries` times and never past 30 seconds from the first attempt.
59
+ * `idempotency_key_in_use` (a key reused with a different body) is not: that is a bug in the
60
+ * caller and will fail identically for ever.
61
+ *
62
+ * `payment_in_progress` is deliberately NOT retryable. See `isPaymentInProgress`. Nor is
63
+ * `outcome_unknown`, although it is a 500. See `isOutcomeUnknown`.
64
+ */
65
+ get isRetryable(): boolean;
66
+ /**
67
+ * `409 payment_in_progress`: the customer may be on the bank's page, or in 3-D Secure, for this
68
+ * payment right now, so it cannot be cancelled yet. Nothing was changed. The message says when to
69
+ * try again — minutes, not a backoff — and an unpaid payment is cancelled automatically anyway.
70
+ * The client never retries it: a loop here would only poll the bank on the customer's behalf.
71
+ */
72
+ get isPaymentInProgress(): boolean;
73
+ /**
74
+ * 🔴 `500 outcome_unknown`: the request asked the bank to move money — a charge, a capture, a
75
+ * refund — and the API could not confirm what happened. The money MAY HAVE MOVED.
76
+ *
77
+ * - Do not send it again under a NEW idempotency key: that is a second charge or refund.
78
+ * - Retrying under the SAME key (`err.idempotencyKey`) is always safe — it never reaches the bank
79
+ * again. Usually the API has stored this answer and replays it. If the message says an earlier
80
+ * request with the key "has not recorded its result yet", the original is still in flight, and
81
+ * a LATER retry under the same key returns its result once it finishes. Either way an immediate
82
+ * retry gains nothing, so the client does not retry it, and `isRetryable` is false.
83
+ * - Find out by reading the object: retrieve the payment intent (or list its refunds, or the
84
+ * invoice's payments) and look at its `status`. It settles on its own within minutes and its
85
+ * webhook follows.
86
+ */
87
+ get isOutcomeUnknown(): boolean;
88
+ }
89
+ /** The request never got an answer: a timeout, a DNS failure, a dropped connection. */
90
+ export declare class SecureProcessingConnectionError extends Error {
91
+ readonly cause: unknown;
92
+ /**
93
+ * 🔴 The `Idempotency-Key` the request was sent with, generated or yours. A connection failure
94
+ * does not say whether the request reached us: retry with `{ idempotencyKey: err.idempotencyKey }`
95
+ * and the API replays its answer if it did; a new key would run it again.
96
+ */
97
+ readonly idempotencyKey: string | undefined;
98
+ constructor(message: string, cause?: unknown, idempotencyKey?: string);
99
+ }