@sweepconsole/node 0.0.0-stage → 0.1.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/dist/client.js ADDED
@@ -0,0 +1,433 @@
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. This usually means baseUrl points at the ` +
76
+ `website rather than the API — check the API base URL in your Sweep portal.`
77
+ : `Expected JSON from ${res.url} but got ${res.headers.get("content-type") ?? "an unknown type"}.`, { code: "invalid_response", status: res.status });
78
+ }
79
+ }
80
+ /** @internal */
81
+ async request(method, path, opts = {}) {
82
+ const url = new URL(this.baseUrl + path);
83
+ for (const [k, v] of Object.entries(opts.query ?? {})) {
84
+ if (v !== undefined)
85
+ url.searchParams.set(k, String(v));
86
+ }
87
+ const headers = {
88
+ Authorization: `Bearer ${this.apiKey}`,
89
+ "User-Agent": "sweepconsole-node/0.1.0",
90
+ };
91
+ if (opts.body !== undefined)
92
+ headers["Content-Type"] = "application/json";
93
+ if (opts.idempotencyKey)
94
+ headers["Idempotency-Key"] = opts.idempotencyKey;
95
+ let lastError;
96
+ for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
97
+ if (attempt > 0)
98
+ await sleep(250 * 2 ** (attempt - 1));
99
+ try {
100
+ const res = await fetch(url, {
101
+ method,
102
+ headers,
103
+ body: opts.body === undefined ? undefined : JSON.stringify(opts.body),
104
+ signal: AbortSignal.timeout(this.timeoutMs),
105
+ });
106
+ if (res.ok)
107
+ return (await this.parse(res));
108
+ // 5xx and 429 may succeed on a retry; a 4xx is a decision, not a blip.
109
+ if ((res.status >= 500 || res.status === 429) && attempt < this.maxRetries) {
110
+ lastError = errorFrom(res.status, await safeJson(res));
111
+ continue;
112
+ }
113
+ throw errorFrom(res.status, await safeJson(res));
114
+ }
115
+ catch (e) {
116
+ if (e instanceof SweepError)
117
+ throw e;
118
+ // Network-level: DNS, connection reset, timeout.
119
+ lastError = e;
120
+ if (attempt >= this.maxRetries) {
121
+ throw new SweepError(`Could not reach the Sweep API: ${e instanceof Error ? e.message : String(e)}`, { code: "network_error", status: 0 });
122
+ }
123
+ }
124
+ }
125
+ throw lastError instanceof Error ? lastError : new Error(String(lastError));
126
+ }
127
+ }
128
+ class Mandates {
129
+ sweep;
130
+ constructor(sweep) {
131
+ this.sweep = sweep;
132
+ }
133
+ /**
134
+ * Create a pending mandate and get back the URL to send the payer to.
135
+ *
136
+ * Nothing is authorized yet. The mandate becomes chargeable when the payer
137
+ * signs and `mandate.authorized` fires — start your billing clock on that
138
+ * webhook, not on the redirect, which they can close.
139
+ */
140
+ async create(params) {
141
+ return toMandate(await this.sweep.request("POST", "/v1/mandates", {
142
+ body: {
143
+ external_ref: params.externalRef,
144
+ email: params.email,
145
+ max_amount: params.maxAmount,
146
+ interval: params.interval,
147
+ chains: params.chains,
148
+ expires_at: params.expiresAt.toISOString(),
149
+ return_url: params.returnUrl,
150
+ metadata: params.metadata,
151
+ },
152
+ }));
153
+ }
154
+ async retrieve(id) {
155
+ return toMandate(await this.sweep.request("GET", `/v1/mandates/${encodeURIComponent(id)}`));
156
+ }
157
+ async list(params = {}) {
158
+ const body = await this.sweep.request("GET", "/v1/mandates", {
159
+ query: { status: params.status, limit: params.limit },
160
+ });
161
+ return rows(body).map(toMandate);
162
+ }
163
+ /**
164
+ * Stop honouring a mandate. Idempotent, and fires `mandate.revoked`.
165
+ *
166
+ * This is a decision recorded on our side, not a cryptographic one: the
167
+ * grants stay signed on chain, because only the payer's wallet can disable
168
+ * them. We stop redeeming them.
169
+ */
170
+ async revoke(id) {
171
+ return toMandate(await this.sweep.request("DELETE", `/v1/mandates/${encodeURIComponent(id)}`));
172
+ }
173
+ }
174
+ class Charges {
175
+ sweep;
176
+ constructor(sweep) {
177
+ this.sweep = sweep;
178
+ }
179
+ /**
180
+ * Collect one pull against a mandate.
181
+ *
182
+ * Answers 202, never 200: the charge row exists immediately, the money
183
+ * arrives seconds to minutes later. Learn the outcome from
184
+ * `charge.succeeded` / `charge.failed`, or by polling `retrieve`.
185
+ *
186
+ * `idempotencyKey` is required by this signature rather than by a runtime
187
+ * 400. Use a natural key — an invoice id, or `${userId}:${period}` — so that
188
+ * a retry after a timeout cannot collect twice.
189
+ */
190
+ async create(params, opts) {
191
+ if (!opts?.idempotencyKey?.trim()) {
192
+ throw new Error("Sweep: charges.create needs an idempotencyKey. Use a natural one — an invoice id, " +
193
+ "or `${userId}:${period}` — so a retry collects once rather than twice.");
194
+ }
195
+ return toCharge(await this.sweep.request("POST", "/v1/charges", {
196
+ idempotencyKey: opts.idempotencyKey.trim(),
197
+ body: {
198
+ mandate: typeof params.mandate === "string" ? params.mandate : params.mandate.id,
199
+ amount: params.amount,
200
+ description: params.description,
201
+ metadata: params.metadata,
202
+ },
203
+ }));
204
+ }
205
+ async retrieve(id) {
206
+ return toCharge(await this.sweep.request("GET", `/v1/charges/${encodeURIComponent(id)}`));
207
+ }
208
+ async list(params = {}) {
209
+ const body = await this.sweep.request("GET", "/v1/charges", {
210
+ query: { mandate: params.mandate, status: params.status, limit: params.limit },
211
+ });
212
+ return rows(body).map(toCharge);
213
+ }
214
+ }
215
+ // ─── wire → typed ─────────────────────────────────────────────────────────────
216
+ function rows(body) {
217
+ if (Array.isArray(body))
218
+ return body;
219
+ const d = body?.data;
220
+ return Array.isArray(d) ? d : [];
221
+ }
222
+ function date(v) {
223
+ return new Date(String(v));
224
+ }
225
+ function maybeDate(v) {
226
+ return v == null ? null : new Date(String(v));
227
+ }
228
+ function toMandate(raw) {
229
+ return {
230
+ id: raw.id,
231
+ mode: "external",
232
+ status: raw.status,
233
+ externalRef: raw.external_ref,
234
+ email: raw.email ?? null,
235
+ verifiedEmail: raw.verified_email ?? null,
236
+ walletAddress: raw.wallet_address ?? null,
237
+ maxAmount: raw.max_amount,
238
+ currency: "USDC",
239
+ interval: raw.interval,
240
+ periodDuration: raw.period_duration,
241
+ chains: raw.chains ?? [],
242
+ testMode: !!raw.test_mode,
243
+ expiresAt: date(raw.expires_at),
244
+ authorizationUrl: raw.authorization_url ?? null,
245
+ authorizationUrlExpiresAt: date(raw.authorization_url_expires_at),
246
+ authorizedAt: maybeDate(raw.authorized_at),
247
+ revokedAt: maybeDate(raw.revoked_at),
248
+ metadata: raw.metadata ?? null,
249
+ createdAt: date(raw.created_at),
250
+ };
251
+ }
252
+ function toCharge(raw) {
253
+ return {
254
+ id: raw.id,
255
+ mandate: raw.mandate,
256
+ status: raw.status,
257
+ amount: raw.amount,
258
+ currency: raw.currency ?? "USDC",
259
+ description: raw.description ?? null,
260
+ externalRef: raw.external_ref,
261
+ metadata: raw.metadata ?? null,
262
+ testMode: !!raw.test_mode,
263
+ sourceChain: raw.source_chain ?? null,
264
+ settlementChain: raw.settlement_chain ?? null,
265
+ txHash: raw.tx_hash ?? null,
266
+ failureReason: raw.failure_reason ?? null,
267
+ createdAt: date(raw.created_at),
268
+ settledAt: maybeDate(raw.settled_at),
269
+ };
270
+ }
271
+ function safeJson(res) {
272
+ return res.json().catch(() => null);
273
+ }
274
+ function sleep(ms) {
275
+ return new Promise((r) => setTimeout(r, ms));
276
+ }
277
+ /* ── Hosted plans ──────────────────────────────────────────────────────────── */
278
+ function toSession(raw) {
279
+ const plan = raw["plan"];
280
+ return {
281
+ id: raw["session_id"],
282
+ url: raw["checkout_url"],
283
+ status: raw["status"],
284
+ ...(raw["session_token"] ? { sessionToken: raw["session_token"] } : {}),
285
+ plan: {
286
+ name: plan["name"],
287
+ amount: plan["amount"],
288
+ currency: "USDC",
289
+ interval: plan["interval"],
290
+ },
291
+ expiresAt: new Date(raw["expires_at"]),
292
+ };
293
+ }
294
+ function toRetrievedSession(raw) {
295
+ const plan = raw["plan"];
296
+ return {
297
+ id: raw["id"],
298
+ status: raw["status"],
299
+ externalRef: raw["external_ref"],
300
+ successUrl: raw["success_url"],
301
+ cancelUrl: raw["cancel_url"],
302
+ metadata: raw["metadata"] ?? null,
303
+ testMode: !!raw["test_mode"],
304
+ plan: {
305
+ name: plan["name"],
306
+ amount: plan["amount"],
307
+ currency: "USDC",
308
+ interval: plan["interval"],
309
+ },
310
+ subscriptionId: raw["subscription_id"] ?? null,
311
+ expiresAt: new Date(raw["expires_at"]),
312
+ createdAt: new Date(raw["created_at"]),
313
+ };
314
+ }
315
+ function toSubscription(raw) {
316
+ const plan = raw["plan"];
317
+ const date = (v) => (v ? new Date(v) : null);
318
+ return {
319
+ id: raw["id"],
320
+ externalRef: raw["external_ref"],
321
+ status: raw["status"],
322
+ walletAddress: raw["wallet_address"] ?? null,
323
+ activationMethod: raw["activation_method"],
324
+ testMode: !!raw["test_mode"],
325
+ plan: {
326
+ id: plan["id"],
327
+ name: plan["name"],
328
+ amount: plan["amount"],
329
+ currency: "USDC",
330
+ interval: plan["interval"],
331
+ },
332
+ txHash: raw["tx_hash"] ?? null,
333
+ currentPeriodStart: new Date(raw["current_period_start"]),
334
+ currentPeriodEnd: new Date(raw["current_period_end"]),
335
+ trialStart: date(raw["trial_start"]),
336
+ trialEnd: date(raw["trial_end"]),
337
+ cancelledAt: date(raw["cancelled_at"]),
338
+ createdAt: new Date(raw["created_at"]),
339
+ updatedAt: new Date(raw["updated_at"]),
340
+ };
341
+ }
342
+ class Sessions {
343
+ sweep;
344
+ constructor(sweep) {
345
+ this.sweep = sweep;
346
+ }
347
+ /**
348
+ * Start a hosted checkout and get the URL to send the payer to.
349
+ *
350
+ * This is the server half of a "Subscribe with USDC" button: call it from
351
+ * your own endpoint, where `externalRef` comes from your session rather than
352
+ * from the page, and return `url` for the browser to follow.
353
+ */
354
+ async create(params) {
355
+ return toSession(await this.sweep.request("POST", "/v1/checkout/sessions", {
356
+ body: {
357
+ plan_id: params.plan,
358
+ external_ref: params.externalRef,
359
+ success_url: params.successUrl,
360
+ cancel_url: params.cancelUrl,
361
+ ...(params.metadata && { metadata: params.metadata }),
362
+ },
363
+ }));
364
+ }
365
+ /**
366
+ * Read a session back. `subscriptionId` is set once checkout completed.
367
+ *
368
+ * Do not treat this as the signal to grant access: a payer who closed the tab
369
+ * leaves a session you will poll forever. Act on subscription.created.
370
+ */
371
+ async retrieve(id) {
372
+ return toRetrievedSession(await this.sweep.request("GET", `/v1/checkout/sessions/${encodeURIComponent(id)}`));
373
+ }
374
+ /** Close an open session early, so its link stops working. */
375
+ async expire(id) {
376
+ return this.sweep.request("POST", `/v1/checkout/sessions/${encodeURIComponent(id)}/expire`);
377
+ }
378
+ }
379
+ class Subscriptions {
380
+ sweep;
381
+ constructor(sweep) {
382
+ this.sweep = sweep;
383
+ }
384
+ async retrieve(id) {
385
+ return toSubscription(await this.sweep.request("GET", `/v1/subscriptions/${encodeURIComponent(id)}`));
386
+ }
387
+ /**
388
+ * The live subscription for one of YOUR user ids, or null.
389
+ *
390
+ * Only active, trialing and past_due count as live. Use it to answer "is
391
+ * this person a subscriber right now" on a page load, not as a substitute
392
+ * for handling the webhooks — polling this per request is a round trip you
393
+ * do not need when subscription.created already told you.
394
+ */
395
+ async status(externalRef) {
396
+ try {
397
+ return toSubscription(await this.sweep.request("GET", "/v1/subscriptions/status", {
398
+ query: { external_ref: externalRef },
399
+ }));
400
+ }
401
+ catch (e) {
402
+ // The endpoint 404s when nothing is live, which is an answer rather than
403
+ // a fault — every caller would otherwise write this same try/catch.
404
+ if (e instanceof SweepError && e.status === 404)
405
+ return null;
406
+ throw e;
407
+ }
408
+ }
409
+ async list(params = {}) {
410
+ const body = await this.sweep.request("GET", "/v1/subscriptions", {
411
+ query: {
412
+ status: params.status,
413
+ plan_id: params.plan,
414
+ external_ref: params.externalRef,
415
+ limit: params.limit,
416
+ },
417
+ });
418
+ const rows = Array.isArray(body) ? body : body.data ?? [];
419
+ return rows.map((r) => toSubscription(r));
420
+ }
421
+ /**
422
+ * Stop a subscription. No further charges are attempted.
423
+ *
424
+ * It also revokes the renewal delegations behind it, so this is not merely a
425
+ * status flag. No money moves: nothing was ever held, so there is nothing to
426
+ * return — see the note on refunds in the README.
427
+ */
428
+ async cancel(id, opts = {}) {
429
+ return toSubscription(await this.sweep.request("POST", `/v1/subscriptions/${encodeURIComponent(id)}/cancel`, {
430
+ body: opts.reason ? { cancel_reason: opts.reason } : {},
431
+ }));
432
+ }
433
+ }
@@ -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
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * @sweepconsole/node — charge a USDC wallet from your own app.
3
+ *
4
+ * The whole integration is four calls:
5
+ *
6
+ * import { Sweep, usdc } from "@sweepconsole/node";
7
+ * const sweep = new Sweep(process.env.SWEEP_API_KEY!);
8
+ *
9
+ * // 1 — when a user subscribes
10
+ * const mandate = await sweep.mandates.create({
11
+ * externalRef: user.id,
12
+ * email: user.email,
13
+ * maxAmount: usdc("15.00"), // the CEILING, not the price
14
+ * interval: "monthly",
15
+ * chains: ["base", "arbitrum", "optimism"],
16
+ * expiresAt: new Date("2027-01-01"),
17
+ * });
18
+ *
19
+ * // 2 — send them to sign it
20
+ * res.redirect(mandate.authorizationUrl!);
21
+ *
22
+ * // 3 — react to what happens (mount with express.raw)
23
+ * app.post("/webhooks/sweep", express.raw({ type: "application/json" }),
24
+ * sweep.webhooks.express(process.env.SWEEP_WEBHOOK_SECRET!, {
25
+ * "mandate.authorized": (e) => db.users.activate(e.externalRef),
26
+ * "charge.succeeded": (e) => db.users.extend(e.externalRef),
27
+ * }));
28
+ *
29
+ * // 4 — charge on your own clock
30
+ * await sweep.charges.create(
31
+ * { mandate: user.sweepMandate, amount: usdc("9.00"), description: "Pro — September" },
32
+ * { idempotencyKey: `${user.id}:2027-01` },
33
+ * );
34
+ *
35
+ * Sweep owns no schedule. Your app decides when to charge; this moves the money
36
+ * and enforces the ceiling the payer signed.
37
+ */
38
+ import { Sweep as SweepClient, type SweepOptions } from "./client.js";
39
+ import { construct, expressHandler, verify } from "./webhooks.js";
40
+ export { usdc, format } from "./money.js";
41
+ export type { SweepOptions } from "./client.js";
42
+ export type { Chain, Charge, ChargeStatus, CheckoutSession, CreateChargeParams, CreateMandateParams, CreateSessionParams, FailureCode, Interval, Mandate, MandateStatus, RetrievedSession, Subscription, SubscriptionStatus, Usdc, WebhookEvent, WebhookEventType, WebhookPayloads, TypedWebhookEvent, } from "./types.js";
43
+ export type { ConstructOptions } from "./webhooks.js";
44
+ export { AmountOverCap, ChargeConflict, IdempotencyKeyInFlight, IdempotencyKeyRequired, IdempotencyKeyReused, MandateExists, MandateNotActive, MandateNotFound, MandateRevoked, PeriodCapExceeded, RailNotEnabled, SweepError, WebhookSignatureError, } from "./errors.js";
45
+ export declare class Sweep extends SweepClient {
46
+ readonly webhooks: {
47
+ verify: typeof verify;
48
+ construct: typeof construct;
49
+ express: typeof expressHandler;
50
+ };
51
+ constructor(apiKey: string, options?: SweepOptions);
52
+ }
53
+ export default Sweep;