@sweepconsole/node 0.0.0-stage → 0.1.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 +190 -2
- package/dist/client.d.ts +147 -0
- package/dist/client.js +467 -0
- package/dist/errors.d.ts +79 -0
- package/dist/errors.js +104 -0
- package/dist/index.d.ts +53 -0
- package/dist/index.js +59 -0
- package/dist/money.d.ts +24 -0
- package/dist/money.js +42 -0
- package/dist/types.d.ts +375 -0
- package/dist/types.js +8 -0
- package/dist/webhooks.d.ts +79 -0
- package/dist/webhooks.js +110 -0
- package/package.json +49 -4
package/dist/client.js
ADDED
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
import { errorFrom, SweepError } from "./errors.js";
|
|
2
|
+
// The API is its own origin, not a path under the website. www.sweepconsole.xyz
|
|
3
|
+
// is the Vercel-hosted front end and answers every path with the app shell, so
|
|
4
|
+
// pointing a client there yields a 200 full of HTML — see `parse` below, which
|
|
5
|
+
// exists because that mistake is otherwise diagnosed by JSON.parse.
|
|
6
|
+
const DEFAULT_BASE_URL = "https://api.sweepconsole.xyz";
|
|
7
|
+
/**
|
|
8
|
+
* Refuse to carry a secret key over plaintext.
|
|
9
|
+
*
|
|
10
|
+
* Every request sends `Authorization: Bearer <your key>`. An http baseUrl —
|
|
11
|
+
* from a typo, a copied tunnel URL, or an environment variable someone else
|
|
12
|
+
* can set — puts that key on the wire in clear, where it buys the ability to
|
|
13
|
+
* charge this merchant's payers. Loopback is exempt because that is where
|
|
14
|
+
* people legitimately run the API while developing.
|
|
15
|
+
*/
|
|
16
|
+
function checkedBaseUrl(raw) {
|
|
17
|
+
let url;
|
|
18
|
+
try {
|
|
19
|
+
url = new URL(raw);
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
throw new Error(`Sweep: baseUrl is not a URL: ${JSON.stringify(raw)}`);
|
|
23
|
+
}
|
|
24
|
+
const host = url.hostname.toLowerCase();
|
|
25
|
+
const loopback = host === "localhost" || host === "127.0.0.1" || host === "::1" || host === "[::1]" || host.endsWith(".localhost");
|
|
26
|
+
if (url.protocol !== "https:" && !loopback) {
|
|
27
|
+
throw new Error(`Sweep: baseUrl must be https — ${url.protocol}//${url.host} would send your API key in clear. ` +
|
|
28
|
+
`Only localhost may use http.`);
|
|
29
|
+
}
|
|
30
|
+
return raw.replace(/\/+$/, "");
|
|
31
|
+
}
|
|
32
|
+
export class Sweep {
|
|
33
|
+
mandates;
|
|
34
|
+
charges;
|
|
35
|
+
/** Hosted plans: start a checkout for a plan you made in the portal. */
|
|
36
|
+
checkout;
|
|
37
|
+
/** Hosted plans: read and cancel what checkout created. */
|
|
38
|
+
subscriptions;
|
|
39
|
+
apiKey;
|
|
40
|
+
baseUrl;
|
|
41
|
+
timeoutMs;
|
|
42
|
+
maxRetries;
|
|
43
|
+
constructor(apiKey, options = {}) {
|
|
44
|
+
if (!apiKey) {
|
|
45
|
+
throw new Error("Sweep: an API key is required. Create one in the portal under API Keys, " +
|
|
46
|
+
"and read it from the environment — never commit it.");
|
|
47
|
+
}
|
|
48
|
+
if (apiKey.startsWith("live_")) {
|
|
49
|
+
// Better here than as a 403 three calls later.
|
|
50
|
+
throw new Error("Sweep: live API keys are not issued yet. Use your test_ key.");
|
|
51
|
+
}
|
|
52
|
+
this.apiKey = apiKey;
|
|
53
|
+
this.baseUrl = checkedBaseUrl(options.baseUrl ?? DEFAULT_BASE_URL);
|
|
54
|
+
this.timeoutMs = options.timeoutMs ?? 30_000;
|
|
55
|
+
this.maxRetries = options.maxRetries ?? 2;
|
|
56
|
+
this.mandates = new Mandates(this);
|
|
57
|
+
this.charges = new Charges(this);
|
|
58
|
+
this.checkout = { sessions: new Sessions(this) };
|
|
59
|
+
this.subscriptions = new Subscriptions(this);
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* A 200 that is not JSON almost always means `baseUrl` is pointing at the web
|
|
63
|
+
* app rather than the API — a single-page app answers every path with its
|
|
64
|
+
* index.html, so the request "succeeds" and then dies in JSON.parse with
|
|
65
|
+
* `Unexpected token '<'`. Say that, rather than letting the parser say it.
|
|
66
|
+
*/
|
|
67
|
+
async parse(res) {
|
|
68
|
+
const text = await res.text();
|
|
69
|
+
try {
|
|
70
|
+
return JSON.parse(text);
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
const looksLikeHtml = /^\s*<(!doctype|html)/i.test(text);
|
|
74
|
+
throw new SweepError(looksLikeHtml
|
|
75
|
+
? `Expected JSON from ${res.url} but got an HTML page. Either baseUrl points at the website ` +
|
|
76
|
+
`rather than the API — api.sweepconsole.xyz, not www — or something between you and the API ` +
|
|
77
|
+
`answered instead of it.`
|
|
78
|
+
: `Expected JSON from ${res.url} but got ${res.headers.get("content-type") ?? "an unknown type"}.`, { code: "invalid_response", status: res.status });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/** @internal */
|
|
82
|
+
async request(method, path, opts = {}) {
|
|
83
|
+
const url = new URL(this.baseUrl + path);
|
|
84
|
+
for (const [k, v] of Object.entries(opts.query ?? {})) {
|
|
85
|
+
if (v !== undefined)
|
|
86
|
+
url.searchParams.set(k, String(v));
|
|
87
|
+
}
|
|
88
|
+
const headers = {
|
|
89
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
90
|
+
"User-Agent": "sweepconsole-node/0.1.0",
|
|
91
|
+
};
|
|
92
|
+
if (opts.body !== undefined)
|
|
93
|
+
headers["Content-Type"] = "application/json";
|
|
94
|
+
if (opts.idempotencyKey)
|
|
95
|
+
headers["Idempotency-Key"] = opts.idempotencyKey;
|
|
96
|
+
let lastError;
|
|
97
|
+
for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
|
|
98
|
+
if (attempt > 0)
|
|
99
|
+
await sleep(250 * 2 ** (attempt - 1));
|
|
100
|
+
try {
|
|
101
|
+
const res = await fetch(url, {
|
|
102
|
+
method,
|
|
103
|
+
headers,
|
|
104
|
+
body: opts.body === undefined ? undefined : JSON.stringify(opts.body),
|
|
105
|
+
signal: AbortSignal.timeout(this.timeoutMs),
|
|
106
|
+
});
|
|
107
|
+
if (res.ok)
|
|
108
|
+
return (await this.parse(res));
|
|
109
|
+
const blocked = edgeBlock(res);
|
|
110
|
+
if (blocked) {
|
|
111
|
+
throw new SweepError(`Blocked before reaching the Sweep API: ${res.status} from a proxy in front of ` +
|
|
112
|
+
`${res.url}, answering HTML rather than JSON. Server-to-server requests trip bot ` +
|
|
113
|
+
`protection tuned for browsers, so this is infrastructure rather than your code. ` +
|
|
114
|
+
`Quote this trace id to support: ${blocked.trace}`, { code: "blocked_upstream", status: res.status, reference: blocked.trace });
|
|
115
|
+
}
|
|
116
|
+
// 5xx and 429 may succeed on a retry; a 4xx is a decision, not a blip.
|
|
117
|
+
if ((res.status >= 500 || res.status === 429) && attempt < this.maxRetries) {
|
|
118
|
+
lastError = errorFrom(res.status, await safeJson(res));
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
throw errorFrom(res.status, await safeJson(res));
|
|
122
|
+
}
|
|
123
|
+
catch (e) {
|
|
124
|
+
if (e instanceof SweepError)
|
|
125
|
+
throw e;
|
|
126
|
+
// Network-level: DNS, connection reset, timeout.
|
|
127
|
+
lastError = e;
|
|
128
|
+
if (attempt >= this.maxRetries) {
|
|
129
|
+
throw new SweepError(`Could not reach the Sweep API: ${e instanceof Error ? e.message : String(e)}`, { code: "network_error", status: 0 });
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
throw lastError instanceof Error ? lastError : new Error(String(lastError));
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
class Mandates {
|
|
137
|
+
sweep;
|
|
138
|
+
constructor(sweep) {
|
|
139
|
+
this.sweep = sweep;
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Create a pending mandate and get back the URL to send the payer to.
|
|
143
|
+
*
|
|
144
|
+
* Nothing is authorized yet. The mandate becomes chargeable when the payer
|
|
145
|
+
* signs and `mandate.authorized` fires — start your billing clock on that
|
|
146
|
+
* webhook, not on the redirect, which they can close.
|
|
147
|
+
*/
|
|
148
|
+
async create(params) {
|
|
149
|
+
return toMandate(await this.sweep.request("POST", "/v1/mandates", {
|
|
150
|
+
body: {
|
|
151
|
+
external_ref: params.externalRef,
|
|
152
|
+
email: params.email,
|
|
153
|
+
max_amount: params.maxAmount,
|
|
154
|
+
interval: params.interval,
|
|
155
|
+
chains: params.chains,
|
|
156
|
+
expires_at: params.expiresAt.toISOString(),
|
|
157
|
+
return_url: params.returnUrl,
|
|
158
|
+
metadata: params.metadata,
|
|
159
|
+
},
|
|
160
|
+
}));
|
|
161
|
+
}
|
|
162
|
+
async retrieve(id) {
|
|
163
|
+
return toMandate(await this.sweep.request("GET", `/v1/mandates/${encodeURIComponent(id)}`));
|
|
164
|
+
}
|
|
165
|
+
async list(params = {}) {
|
|
166
|
+
const body = await this.sweep.request("GET", "/v1/mandates", {
|
|
167
|
+
query: { status: params.status, limit: params.limit },
|
|
168
|
+
});
|
|
169
|
+
return rows(body).map(toMandate);
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Stop honouring a mandate. Idempotent, and fires `mandate.revoked`.
|
|
173
|
+
*
|
|
174
|
+
* This is a decision recorded on our side, not a cryptographic one: the
|
|
175
|
+
* grants stay signed on chain, because only the payer's wallet can disable
|
|
176
|
+
* them. We stop redeeming them.
|
|
177
|
+
*/
|
|
178
|
+
async revoke(id) {
|
|
179
|
+
return toMandate(await this.sweep.request("DELETE", `/v1/mandates/${encodeURIComponent(id)}`));
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
class Charges {
|
|
183
|
+
sweep;
|
|
184
|
+
constructor(sweep) {
|
|
185
|
+
this.sweep = sweep;
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Collect one pull against a mandate.
|
|
189
|
+
*
|
|
190
|
+
* Answers 202, never 200: the charge row exists immediately, the money
|
|
191
|
+
* arrives seconds to minutes later. Learn the outcome from
|
|
192
|
+
* `charge.succeeded` / `charge.failed`, or by polling `retrieve`.
|
|
193
|
+
*
|
|
194
|
+
* `idempotencyKey` is required by this signature rather than by a runtime
|
|
195
|
+
* 400. Use a natural key — an invoice id, or `${userId}:${period}` — so that
|
|
196
|
+
* a retry after a timeout cannot collect twice.
|
|
197
|
+
*/
|
|
198
|
+
async create(params, opts) {
|
|
199
|
+
if (!opts?.idempotencyKey?.trim()) {
|
|
200
|
+
throw new Error("Sweep: charges.create needs an idempotencyKey. Use a natural one — an invoice id, " +
|
|
201
|
+
"or `${userId}:${period}` — so a retry collects once rather than twice.");
|
|
202
|
+
}
|
|
203
|
+
return toCharge(await this.sweep.request("POST", "/v1/charges", {
|
|
204
|
+
idempotencyKey: opts.idempotencyKey.trim(),
|
|
205
|
+
body: {
|
|
206
|
+
mandate: typeof params.mandate === "string" ? params.mandate : params.mandate.id,
|
|
207
|
+
amount: params.amount,
|
|
208
|
+
description: params.description,
|
|
209
|
+
metadata: params.metadata,
|
|
210
|
+
},
|
|
211
|
+
}));
|
|
212
|
+
}
|
|
213
|
+
async retrieve(id) {
|
|
214
|
+
return toCharge(await this.sweep.request("GET", `/v1/charges/${encodeURIComponent(id)}`));
|
|
215
|
+
}
|
|
216
|
+
async list(params = {}) {
|
|
217
|
+
const body = await this.sweep.request("GET", "/v1/charges", {
|
|
218
|
+
query: { mandate: params.mandate, status: params.status, limit: params.limit },
|
|
219
|
+
});
|
|
220
|
+
return rows(body).map(toCharge);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
// ─── wire → typed ─────────────────────────────────────────────────────────────
|
|
224
|
+
function rows(body) {
|
|
225
|
+
if (Array.isArray(body))
|
|
226
|
+
return body;
|
|
227
|
+
const d = body?.data;
|
|
228
|
+
return Array.isArray(d) ? d : [];
|
|
229
|
+
}
|
|
230
|
+
function date(v) {
|
|
231
|
+
return new Date(String(v));
|
|
232
|
+
}
|
|
233
|
+
function maybeDate(v) {
|
|
234
|
+
return v == null ? null : new Date(String(v));
|
|
235
|
+
}
|
|
236
|
+
function toMandate(raw) {
|
|
237
|
+
return {
|
|
238
|
+
id: raw.id,
|
|
239
|
+
mode: "external",
|
|
240
|
+
status: raw.status,
|
|
241
|
+
externalRef: raw.external_ref,
|
|
242
|
+
email: raw.email ?? null,
|
|
243
|
+
verifiedEmail: raw.verified_email ?? null,
|
|
244
|
+
walletAddress: raw.wallet_address ?? null,
|
|
245
|
+
maxAmount: raw.max_amount,
|
|
246
|
+
currency: "USDC",
|
|
247
|
+
interval: raw.interval,
|
|
248
|
+
periodDuration: raw.period_duration,
|
|
249
|
+
chains: raw.chains ?? [],
|
|
250
|
+
testMode: !!raw.test_mode,
|
|
251
|
+
expiresAt: date(raw.expires_at),
|
|
252
|
+
authorizationUrl: raw.authorization_url ?? null,
|
|
253
|
+
authorizationUrlExpiresAt: date(raw.authorization_url_expires_at),
|
|
254
|
+
authorizedAt: maybeDate(raw.authorized_at),
|
|
255
|
+
revokedAt: maybeDate(raw.revoked_at),
|
|
256
|
+
metadata: raw.metadata ?? null,
|
|
257
|
+
createdAt: date(raw.created_at),
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
function toCharge(raw) {
|
|
261
|
+
return {
|
|
262
|
+
id: raw.id,
|
|
263
|
+
mandate: raw.mandate,
|
|
264
|
+
status: raw.status,
|
|
265
|
+
amount: raw.amount,
|
|
266
|
+
currency: raw.currency ?? "USDC",
|
|
267
|
+
description: raw.description ?? null,
|
|
268
|
+
externalRef: raw.external_ref,
|
|
269
|
+
metadata: raw.metadata ?? null,
|
|
270
|
+
testMode: !!raw.test_mode,
|
|
271
|
+
sourceChain: raw.source_chain ?? null,
|
|
272
|
+
settlementChain: raw.settlement_chain ?? null,
|
|
273
|
+
txHash: raw.tx_hash ?? null,
|
|
274
|
+
failureReason: raw.failure_reason ?? null,
|
|
275
|
+
createdAt: date(raw.created_at),
|
|
276
|
+
settledAt: maybeDate(raw.settled_at),
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
function safeJson(res) {
|
|
280
|
+
return res.json().catch(() => null);
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* A proxy in front of the API refused the request before it got there.
|
|
284
|
+
*
|
|
285
|
+
* Server-to-server calls are what this catches. A request from a datacenter
|
|
286
|
+
* carries no browser fingerprint, so bot protection tuned for browsers treats
|
|
287
|
+
* it as exactly the thing it is looking for and answers a challenge page —
|
|
288
|
+
* HTML, with a 403, and a trace id of its own.
|
|
289
|
+
*
|
|
290
|
+
* Worth separating from every other 403, because nothing about the caller's
|
|
291
|
+
* code is wrong and no amount of re-reading their API key will help. The trace
|
|
292
|
+
* id is the one piece of information support actually needs.
|
|
293
|
+
*/
|
|
294
|
+
function edgeBlock(res) {
|
|
295
|
+
if (res.status !== 403 && res.status !== 503)
|
|
296
|
+
return null;
|
|
297
|
+
const trace = res.headers.get("cf-ray") ??
|
|
298
|
+
res.headers.get("x-amz-cf-id") ??
|
|
299
|
+
res.headers.get("x-akamai-request-id");
|
|
300
|
+
if (!trace)
|
|
301
|
+
return null;
|
|
302
|
+
// Our own 403s are JSON. An HTML body means something else answered.
|
|
303
|
+
const type = res.headers.get("content-type") ?? "";
|
|
304
|
+
if (type.includes("json"))
|
|
305
|
+
return null;
|
|
306
|
+
return { trace };
|
|
307
|
+
}
|
|
308
|
+
function sleep(ms) {
|
|
309
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
310
|
+
}
|
|
311
|
+
/* ── Hosted plans ──────────────────────────────────────────────────────────── */
|
|
312
|
+
function toSession(raw) {
|
|
313
|
+
const plan = raw["plan"];
|
|
314
|
+
return {
|
|
315
|
+
id: raw["session_id"],
|
|
316
|
+
url: raw["checkout_url"],
|
|
317
|
+
status: raw["status"],
|
|
318
|
+
...(raw["session_token"] ? { sessionToken: raw["session_token"] } : {}),
|
|
319
|
+
plan: {
|
|
320
|
+
name: plan["name"],
|
|
321
|
+
amount: plan["amount"],
|
|
322
|
+
currency: "USDC",
|
|
323
|
+
interval: plan["interval"],
|
|
324
|
+
},
|
|
325
|
+
expiresAt: new Date(raw["expires_at"]),
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
function toRetrievedSession(raw) {
|
|
329
|
+
const plan = raw["plan"];
|
|
330
|
+
return {
|
|
331
|
+
id: raw["id"],
|
|
332
|
+
status: raw["status"],
|
|
333
|
+
externalRef: raw["external_ref"],
|
|
334
|
+
successUrl: raw["success_url"],
|
|
335
|
+
cancelUrl: raw["cancel_url"],
|
|
336
|
+
metadata: raw["metadata"] ?? null,
|
|
337
|
+
testMode: !!raw["test_mode"],
|
|
338
|
+
plan: {
|
|
339
|
+
name: plan["name"],
|
|
340
|
+
amount: plan["amount"],
|
|
341
|
+
currency: "USDC",
|
|
342
|
+
interval: plan["interval"],
|
|
343
|
+
},
|
|
344
|
+
subscriptionId: raw["subscription_id"] ?? null,
|
|
345
|
+
expiresAt: new Date(raw["expires_at"]),
|
|
346
|
+
createdAt: new Date(raw["created_at"]),
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
function toSubscription(raw) {
|
|
350
|
+
const plan = raw["plan"];
|
|
351
|
+
const date = (v) => (v ? new Date(v) : null);
|
|
352
|
+
return {
|
|
353
|
+
id: raw["id"],
|
|
354
|
+
externalRef: raw["external_ref"],
|
|
355
|
+
status: raw["status"],
|
|
356
|
+
walletAddress: raw["wallet_address"] ?? null,
|
|
357
|
+
activationMethod: raw["activation_method"],
|
|
358
|
+
testMode: !!raw["test_mode"],
|
|
359
|
+
plan: {
|
|
360
|
+
id: plan["id"],
|
|
361
|
+
name: plan["name"],
|
|
362
|
+
amount: plan["amount"],
|
|
363
|
+
currency: "USDC",
|
|
364
|
+
interval: plan["interval"],
|
|
365
|
+
},
|
|
366
|
+
txHash: raw["tx_hash"] ?? null,
|
|
367
|
+
currentPeriodStart: new Date(raw["current_period_start"]),
|
|
368
|
+
currentPeriodEnd: new Date(raw["current_period_end"]),
|
|
369
|
+
trialStart: date(raw["trial_start"]),
|
|
370
|
+
trialEnd: date(raw["trial_end"]),
|
|
371
|
+
cancelledAt: date(raw["cancelled_at"]),
|
|
372
|
+
createdAt: new Date(raw["created_at"]),
|
|
373
|
+
updatedAt: new Date(raw["updated_at"]),
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
class Sessions {
|
|
377
|
+
sweep;
|
|
378
|
+
constructor(sweep) {
|
|
379
|
+
this.sweep = sweep;
|
|
380
|
+
}
|
|
381
|
+
/**
|
|
382
|
+
* Start a hosted checkout and get the URL to send the payer to.
|
|
383
|
+
*
|
|
384
|
+
* This is the server half of a "Subscribe with USDC" button: call it from
|
|
385
|
+
* your own endpoint, where `externalRef` comes from your session rather than
|
|
386
|
+
* from the page, and return `url` for the browser to follow.
|
|
387
|
+
*/
|
|
388
|
+
async create(params) {
|
|
389
|
+
return toSession(await this.sweep.request("POST", "/v1/checkout/sessions", {
|
|
390
|
+
body: {
|
|
391
|
+
plan_id: params.plan,
|
|
392
|
+
external_ref: params.externalRef,
|
|
393
|
+
success_url: params.successUrl,
|
|
394
|
+
cancel_url: params.cancelUrl,
|
|
395
|
+
...(params.metadata && { metadata: params.metadata }),
|
|
396
|
+
},
|
|
397
|
+
}));
|
|
398
|
+
}
|
|
399
|
+
/**
|
|
400
|
+
* Read a session back. `subscriptionId` is set once checkout completed.
|
|
401
|
+
*
|
|
402
|
+
* Do not treat this as the signal to grant access: a payer who closed the tab
|
|
403
|
+
* leaves a session you will poll forever. Act on subscription.created.
|
|
404
|
+
*/
|
|
405
|
+
async retrieve(id) {
|
|
406
|
+
return toRetrievedSession(await this.sweep.request("GET", `/v1/checkout/sessions/${encodeURIComponent(id)}`));
|
|
407
|
+
}
|
|
408
|
+
/** Close an open session early, so its link stops working. */
|
|
409
|
+
async expire(id) {
|
|
410
|
+
return this.sweep.request("POST", `/v1/checkout/sessions/${encodeURIComponent(id)}/expire`);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
class Subscriptions {
|
|
414
|
+
sweep;
|
|
415
|
+
constructor(sweep) {
|
|
416
|
+
this.sweep = sweep;
|
|
417
|
+
}
|
|
418
|
+
async retrieve(id) {
|
|
419
|
+
return toSubscription(await this.sweep.request("GET", `/v1/subscriptions/${encodeURIComponent(id)}`));
|
|
420
|
+
}
|
|
421
|
+
/**
|
|
422
|
+
* The live subscription for one of YOUR user ids, or null.
|
|
423
|
+
*
|
|
424
|
+
* Only active, trialing and past_due count as live. Use it to answer "is
|
|
425
|
+
* this person a subscriber right now" on a page load, not as a substitute
|
|
426
|
+
* for handling the webhooks — polling this per request is a round trip you
|
|
427
|
+
* do not need when subscription.created already told you.
|
|
428
|
+
*/
|
|
429
|
+
async status(externalRef) {
|
|
430
|
+
try {
|
|
431
|
+
return toSubscription(await this.sweep.request("GET", "/v1/subscriptions/status", {
|
|
432
|
+
query: { external_ref: externalRef },
|
|
433
|
+
}));
|
|
434
|
+
}
|
|
435
|
+
catch (e) {
|
|
436
|
+
// The endpoint 404s when nothing is live, which is an answer rather than
|
|
437
|
+
// a fault — every caller would otherwise write this same try/catch.
|
|
438
|
+
if (e instanceof SweepError && e.status === 404)
|
|
439
|
+
return null;
|
|
440
|
+
throw e;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
async list(params = {}) {
|
|
444
|
+
const body = await this.sweep.request("GET", "/v1/subscriptions", {
|
|
445
|
+
query: {
|
|
446
|
+
status: params.status,
|
|
447
|
+
plan_id: params.plan,
|
|
448
|
+
external_ref: params.externalRef,
|
|
449
|
+
limit: params.limit,
|
|
450
|
+
},
|
|
451
|
+
});
|
|
452
|
+
const rows = Array.isArray(body) ? body : body.data ?? [];
|
|
453
|
+
return rows.map((r) => toSubscription(r));
|
|
454
|
+
}
|
|
455
|
+
/**
|
|
456
|
+
* Stop a subscription. No further charges are attempted.
|
|
457
|
+
*
|
|
458
|
+
* It also revokes the renewal delegations behind it, so this is not merely a
|
|
459
|
+
* status flag. No money moves: nothing was ever held, so there is nothing to
|
|
460
|
+
* return — see the note on refunds in the README.
|
|
461
|
+
*/
|
|
462
|
+
async cancel(id, opts = {}) {
|
|
463
|
+
return toSubscription(await this.sweep.request("POST", `/v1/subscriptions/${encodeURIComponent(id)}/cancel`, {
|
|
464
|
+
body: opts.reason ? { cancel_reason: opts.reason } : {},
|
|
465
|
+
}));
|
|
466
|
+
}
|
|
467
|
+
}
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
export declare class SweepError extends Error {
|
|
2
|
+
/** The API's machine-readable code, e.g. `period_cap_exceeded`. */
|
|
3
|
+
readonly code: string;
|
|
4
|
+
readonly status: number;
|
|
5
|
+
/** Present on a 5xx. Quote it to support; it locates the failure in our logs. */
|
|
6
|
+
readonly reference?: string;
|
|
7
|
+
/** Per-field messages from a 422. */
|
|
8
|
+
readonly details?: Record<string, string>;
|
|
9
|
+
/** The id of whatever conflicted. Set on `mandate_exists`. */
|
|
10
|
+
readonly mandateId?: string;
|
|
11
|
+
/** Set on `mandate_exists` when the existing mandate is still pending. */
|
|
12
|
+
readonly authorizationUrl?: string;
|
|
13
|
+
constructor(message: string, opts: {
|
|
14
|
+
code: string;
|
|
15
|
+
status: number;
|
|
16
|
+
reference?: string;
|
|
17
|
+
details?: Record<string, string>;
|
|
18
|
+
mandateId?: string;
|
|
19
|
+
authorizationUrl?: string;
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
/** 403 — the account has not been granted the rail. Ask in portal → Payment rail. */
|
|
23
|
+
export declare class RailNotEnabled extends SweepError {
|
|
24
|
+
}
|
|
25
|
+
/** 404 — no mandate with that id on this account. */
|
|
26
|
+
export declare class MandateNotFound extends SweepError {
|
|
27
|
+
}
|
|
28
|
+
/** 409 — the payer withdrew it. Stop charging; a new mandate needs a new signature. */
|
|
29
|
+
export declare class MandateRevoked extends SweepError {
|
|
30
|
+
}
|
|
31
|
+
/** 409 — never authorized, or no longer active. */
|
|
32
|
+
export declare class MandateNotActive extends SweepError {
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* 409 — this `externalRef` already has a live mandate at your merchant.
|
|
36
|
+
*
|
|
37
|
+
* Two live mandates for one payer are two independent delegations with
|
|
38
|
+
* independent period enforcers, so their ceilings add up and both can be
|
|
39
|
+
* charged. `mandateId` names the one already standing. If it is still pending,
|
|
40
|
+
* `authorizationUrl` is the link to resend — do that instead of creating a
|
|
41
|
+
* second. To replace an active one, revoke it first.
|
|
42
|
+
*/
|
|
43
|
+
export declare class MandateExists extends SweepError {
|
|
44
|
+
}
|
|
45
|
+
/** 422 — this single charge is larger than the mandate's `maxAmount`. */
|
|
46
|
+
export declare class AmountOverCap extends SweepError {
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* 422 — it fits under `maxAmount` but not under what is left this period.
|
|
50
|
+
* The message names what is committed, what remains, and when the period resets.
|
|
51
|
+
*/
|
|
52
|
+
export declare class PeriodCapExceeded extends SweepError {
|
|
53
|
+
}
|
|
54
|
+
/** 400 — no `Idempotency-Key`. The client always sends one, so this means a bug here. */
|
|
55
|
+
export declare class IdempotencyKeyRequired extends SweepError {
|
|
56
|
+
}
|
|
57
|
+
/** 422 — that key was already used with a different body. A new charge needs a new key. */
|
|
58
|
+
export declare class IdempotencyKeyReused extends SweepError {
|
|
59
|
+
}
|
|
60
|
+
/** 409 — a charge with this key is still in flight. Retry shortly, same key. */
|
|
61
|
+
export declare class IdempotencyKeyInFlight extends SweepError {
|
|
62
|
+
}
|
|
63
|
+
/** 409 — two charges against one mandate committed at the same instant. Retry, same key. */
|
|
64
|
+
export declare class ChargeConflict extends SweepError {
|
|
65
|
+
}
|
|
66
|
+
/** The signature on an incoming webhook did not verify. Do not trust the body. */
|
|
67
|
+
export declare class WebhookSignatureError extends Error {
|
|
68
|
+
constructor(message: string);
|
|
69
|
+
}
|
|
70
|
+
export declare function errorFrom(status: number, body: {
|
|
71
|
+
error?: {
|
|
72
|
+
message?: string;
|
|
73
|
+
code?: string;
|
|
74
|
+
reference?: string;
|
|
75
|
+
details?: Record<string, string>;
|
|
76
|
+
mandate_id?: string;
|
|
77
|
+
authorization_url?: string;
|
|
78
|
+
};
|
|
79
|
+
} | null): SweepError;
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// One class per refusal the rail can hand back, because `catch (e)` on a rail
|
|
2
|
+
// that moves money should be able to ask "which of these is it" without parsing
|
|
3
|
+
// a string. Every one of these is a documented, expected outcome — not a bug.
|
|
4
|
+
export class SweepError extends Error {
|
|
5
|
+
/** The API's machine-readable code, e.g. `period_cap_exceeded`. */
|
|
6
|
+
code;
|
|
7
|
+
status;
|
|
8
|
+
/** Present on a 5xx. Quote it to support; it locates the failure in our logs. */
|
|
9
|
+
reference;
|
|
10
|
+
/** Per-field messages from a 422. */
|
|
11
|
+
details;
|
|
12
|
+
/** The id of whatever conflicted. Set on `mandate_exists`. */
|
|
13
|
+
mandateId;
|
|
14
|
+
/** Set on `mandate_exists` when the existing mandate is still pending. */
|
|
15
|
+
authorizationUrl;
|
|
16
|
+
constructor(message, opts) {
|
|
17
|
+
super(message);
|
|
18
|
+
this.name = new.target.name;
|
|
19
|
+
this.code = opts.code;
|
|
20
|
+
this.status = opts.status;
|
|
21
|
+
this.reference = opts.reference;
|
|
22
|
+
this.details = opts.details;
|
|
23
|
+
this.mandateId = opts.mandateId;
|
|
24
|
+
this.authorizationUrl = opts.authorizationUrl;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/** 403 — the account has not been granted the rail. Ask in portal → Payment rail. */
|
|
28
|
+
export class RailNotEnabled extends SweepError {
|
|
29
|
+
}
|
|
30
|
+
/** 404 — no mandate with that id on this account. */
|
|
31
|
+
export class MandateNotFound extends SweepError {
|
|
32
|
+
}
|
|
33
|
+
/** 409 — the payer withdrew it. Stop charging; a new mandate needs a new signature. */
|
|
34
|
+
export class MandateRevoked extends SweepError {
|
|
35
|
+
}
|
|
36
|
+
/** 409 — never authorized, or no longer active. */
|
|
37
|
+
export class MandateNotActive extends SweepError {
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* 409 — this `externalRef` already has a live mandate at your merchant.
|
|
41
|
+
*
|
|
42
|
+
* Two live mandates for one payer are two independent delegations with
|
|
43
|
+
* independent period enforcers, so their ceilings add up and both can be
|
|
44
|
+
* charged. `mandateId` names the one already standing. If it is still pending,
|
|
45
|
+
* `authorizationUrl` is the link to resend — do that instead of creating a
|
|
46
|
+
* second. To replace an active one, revoke it first.
|
|
47
|
+
*/
|
|
48
|
+
export class MandateExists extends SweepError {
|
|
49
|
+
}
|
|
50
|
+
/** 422 — this single charge is larger than the mandate's `maxAmount`. */
|
|
51
|
+
export class AmountOverCap extends SweepError {
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* 422 — it fits under `maxAmount` but not under what is left this period.
|
|
55
|
+
* The message names what is committed, what remains, and when the period resets.
|
|
56
|
+
*/
|
|
57
|
+
export class PeriodCapExceeded extends SweepError {
|
|
58
|
+
}
|
|
59
|
+
/** 400 — no `Idempotency-Key`. The client always sends one, so this means a bug here. */
|
|
60
|
+
export class IdempotencyKeyRequired extends SweepError {
|
|
61
|
+
}
|
|
62
|
+
/** 422 — that key was already used with a different body. A new charge needs a new key. */
|
|
63
|
+
export class IdempotencyKeyReused extends SweepError {
|
|
64
|
+
}
|
|
65
|
+
/** 409 — a charge with this key is still in flight. Retry shortly, same key. */
|
|
66
|
+
export class IdempotencyKeyInFlight extends SweepError {
|
|
67
|
+
}
|
|
68
|
+
/** 409 — two charges against one mandate committed at the same instant. Retry, same key. */
|
|
69
|
+
export class ChargeConflict extends SweepError {
|
|
70
|
+
}
|
|
71
|
+
/** The signature on an incoming webhook did not verify. Do not trust the body. */
|
|
72
|
+
export class WebhookSignatureError extends Error {
|
|
73
|
+
constructor(message) {
|
|
74
|
+
super(message);
|
|
75
|
+
this.name = "WebhookSignatureError";
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
const BY_CODE = {
|
|
79
|
+
rail_not_enabled: RailNotEnabled,
|
|
80
|
+
not_found: MandateNotFound,
|
|
81
|
+
mandate_revoked: MandateRevoked,
|
|
82
|
+
mandate_not_active: MandateNotActive,
|
|
83
|
+
mandate_exists: MandateExists,
|
|
84
|
+
amount_over_cap: AmountOverCap,
|
|
85
|
+
period_cap_exceeded: PeriodCapExceeded,
|
|
86
|
+
idempotency_key_required: IdempotencyKeyRequired,
|
|
87
|
+
idempotency_key_reused: IdempotencyKeyReused,
|
|
88
|
+
idempotency_key_in_flight: IdempotencyKeyInFlight,
|
|
89
|
+
charge_conflict: ChargeConflict,
|
|
90
|
+
};
|
|
91
|
+
export function errorFrom(status, body) {
|
|
92
|
+
const e = body?.error;
|
|
93
|
+
const code = e?.code ?? "error";
|
|
94
|
+
const Cls = BY_CODE[code] ?? SweepError;
|
|
95
|
+
const message = e?.message ?? `Sweep API error (${status})`;
|
|
96
|
+
return new Cls(message, {
|
|
97
|
+
code,
|
|
98
|
+
status,
|
|
99
|
+
reference: e?.reference,
|
|
100
|
+
details: e?.details,
|
|
101
|
+
mandateId: e?.mandate_id,
|
|
102
|
+
authorizationUrl: e?.authorization_url,
|
|
103
|
+
});
|
|
104
|
+
}
|