@relipay/node 0.1.0-beta.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/index.js ADDED
@@ -0,0 +1,681 @@
1
+ /**
2
+ * @relipay/node — server SDK for ReliPay.
3
+ *
4
+ * One client instance per Application. Construct with the Application's
5
+ * secret key (`rp_live_…` or `rp_test_…`) and the URL of your ReliPay
6
+ * deployment. Never ship the secret key to the browser — for browser code
7
+ * use `@relipay/react` with the Application's public key instead.
8
+ *
9
+ * @example Smoke-test your credentials
10
+ * ```ts
11
+ * import { ReliPay } from "@relipay/node";
12
+ *
13
+ * const relipay = new ReliPay({
14
+ * apiUrl: process.env.RELIPAY_URL!,
15
+ * secretKey: process.env.RELIPAY_SECRET!,
16
+ * });
17
+ *
18
+ * const me = await relipay.applications.me();
19
+ * console.log(`Connected to "${me.name}" (${me.slug})`);
20
+ * ```
21
+ */
22
+ /**
23
+ * An error thrown by the ReliPay SDK. Always carries a stable `code` and
24
+ * (when the server provided one) a concrete `fix` field. **Read `error.fix`
25
+ * first** when debugging — it is by far the most actionable piece of data.
26
+ */
27
+ export class RelipayError extends Error {
28
+ code;
29
+ fix;
30
+ docs;
31
+ statusCode;
32
+ /** Server-assigned request id — share with support to look up the matching log entry. */
33
+ requestId;
34
+ constructor(error) {
35
+ super(error.message);
36
+ this.name = 'RelipayError';
37
+ this.code = error.code;
38
+ this.fix = error.fix;
39
+ this.docs = error.docs;
40
+ this.statusCode = error.statusCode;
41
+ this.requestId = error.requestId;
42
+ }
43
+ }
44
+ /**
45
+ * Top-level ReliPay client. Auth and billing live as namespaces
46
+ * (`relipay.applications`, `relipay.auth`, `relipay.billing`) so an agent
47
+ * reading `relipay.` in an editor sees a discoverable surface.
48
+ */
49
+ export class ReliPay {
50
+ apiUrl;
51
+ secretKey;
52
+ fetchImpl;
53
+ /** Operations on the calling Application itself. */
54
+ applications;
55
+ /** Auth operations — sign-in, sign-up, sessions, passkeys, magic-link. */
56
+ auth;
57
+ /** Billing operations — plans, checkout, subscriptions, coupons. */
58
+ billing;
59
+ /** End-user organizations — create, invite, members, role changes. */
60
+ organizations;
61
+ /** License key verification + activation. */
62
+ licenses;
63
+ /** Usage metering — record events, aggregate windows. */
64
+ usage;
65
+ /** Prepaid credits — balance reads, idempotent drawdown, ledger. */
66
+ credits;
67
+ constructor(config) {
68
+ if (!config.apiUrl) {
69
+ throw new RelipayError({
70
+ code: 'CONFIG_MISSING_API_URL',
71
+ message: 'ReliPay client requires `apiUrl`.',
72
+ fix: 'Pass `apiUrl: process.env.RELIPAY_URL` when constructing the client.',
73
+ });
74
+ }
75
+ if (!config.secretKey || !config.secretKey.startsWith('rp_')) {
76
+ throw new RelipayError({
77
+ code: 'CONFIG_INVALID_SECRET_KEY',
78
+ message: 'ReliPay client requires a valid `secretKey` (starts with `rp_`).',
79
+ fix: 'Get a key from the ReliPay panel under Application → API Keys, then pass it as `secretKey`.',
80
+ });
81
+ }
82
+ this.apiUrl = config.apiUrl.replace(/\/$/, '');
83
+ this.secretKey = config.secretKey;
84
+ this.fetchImpl = config.fetch ?? fetch;
85
+ this.applications = new ApplicationsClient(this);
86
+ this.auth = new AuthClient(this);
87
+ this.billing = new BillingClient(this);
88
+ this.organizations = new OrganizationsClient(this);
89
+ this.licenses = new LicensesClient(this);
90
+ this.usage = new UsageClient(this);
91
+ this.credits = new CreditsClient(this);
92
+ }
93
+ /** @internal */
94
+ async request(method, path, body, extraHeaders) {
95
+ const res = await this.fetchImpl(`${this.apiUrl}${path}`, {
96
+ method,
97
+ headers: {
98
+ Authorization: `Bearer ${this.secretKey}`,
99
+ ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
100
+ ...extraHeaders,
101
+ },
102
+ ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
103
+ });
104
+ const json = (await res.json().catch(() => ({})));
105
+ if (!res.ok || ('success' in json && json.success === false)) {
106
+ const requestId = res.headers.get('x-request-id') ?? undefined;
107
+ const err = 'error' in json
108
+ ? json.error
109
+ : {
110
+ code: 'UNKNOWN_ERROR',
111
+ message: `Request failed with status ${res.status}.`,
112
+ fix: 'Check the ReliPay API logs for the matching request id.',
113
+ };
114
+ const resolvedRequestId = ('requestId' in err && err.requestId) || requestId;
115
+ throw new RelipayError({
116
+ ...err,
117
+ statusCode: res.status,
118
+ ...(resolvedRequestId !== undefined && { requestId: resolvedRequestId }),
119
+ });
120
+ }
121
+ return json.data;
122
+ }
123
+ }
124
+ class ApplicationsClient {
125
+ client;
126
+ constructor(client) {
127
+ this.client = client;
128
+ }
129
+ /**
130
+ * Verify credentials and fetch the calling Application. Use this as your
131
+ * SDK smoke test — if it returns, your secret key is good and you're
132
+ * pointed at the right ReliPay deployment.
133
+ *
134
+ * @example
135
+ * ```ts
136
+ * const me = await relipay.applications.me();
137
+ * console.log(`Connected to "${me.name}" (${me.slug})`);
138
+ * ```
139
+ *
140
+ * @throws {RelipayError} with `code: "API_KEY_INVALID"` if the key is wrong/revoked/expired.
141
+ */
142
+ me() {
143
+ return this.client.request('GET', '/api/v1/me/');
144
+ }
145
+ }
146
+ class AuthClient {
147
+ client;
148
+ constructor(client) {
149
+ this.client = client;
150
+ }
151
+ /**
152
+ * Create a new end-user in the calling Application via email + password.
153
+ * Returns the user and a JWT to use for subsequent per-user calls
154
+ * (e.g. `getCurrentUser(token)`).
155
+ *
156
+ * @example
157
+ * ```ts
158
+ * const { endUser, token } = await relipay.auth.signUp({
159
+ * email: 'alice@example.com',
160
+ * password: 'correct-horse-battery-staple',
161
+ * });
162
+ * // store token in your session, return it to the browser, etc.
163
+ * ```
164
+ *
165
+ * @throws {RelipayError} `EMAIL_ALREADY_EXISTS` (409) if the email is taken in this Application.
166
+ * @throws {RelipayError} `PASSWORD_TOO_SHORT` (400) if shorter than the Application's `passwordMinLength`.
167
+ * @throws {RelipayError} `AUTH_METHOD_DISABLED` (400) if the Application doesn't have `"password"` enabled.
168
+ */
169
+ signUp(input) {
170
+ return this.client.request('POST', '/api/v1/auth/sign-up', input);
171
+ }
172
+ /**
173
+ * Authenticate an existing end-user with email + password.
174
+ *
175
+ * Returns a discriminated union over `mfaRequired`:
176
+ * - `mfaRequired === false` → full `AuthResultDto` with access+refresh.
177
+ * - `mfaRequired === true` → `mfaChallengeToken` (5-minute lifetime).
178
+ * Prompt the user for their TOTP / backup code and call
179
+ * `mfaVerify({ mfaChallengeToken, code })` to receive a real session.
180
+ *
181
+ * **Branch on `result.mfaRequired` before reading `accessToken`** — the
182
+ * MFA-required branch has no session tokens.
183
+ *
184
+ * @throws {RelipayError} `INVALID_CREDENTIALS` (401) — single code on purpose.
185
+ * Don't try to distinguish wrong-email from wrong-password from the SDK side either.
186
+ */
187
+ signIn(input) {
188
+ return this.client.request('POST', '/api/v1/auth/sign-in', input);
189
+ }
190
+ /**
191
+ * Exchange an MFA challenge token + TOTP/backup code for a real session.
192
+ * Use after `signIn` (or OAuth callback) returns `mfaRequired: true`.
193
+ *
194
+ * @throws {RelipayError} `MFA_CHALLENGE_INVALID` (401) if the token is
195
+ * forged, expired, or signed with a different secret.
196
+ * @throws {RelipayError} `MFA_CHALLENGE_WRONG_APPLICATION` (401) if the
197
+ * token was issued under a different Application.
198
+ * @throws {RelipayError} `MFA_CODE_INVALID` (401) if the code doesn't
199
+ * verify against the user's TOTP secret or remaining backup codes.
200
+ */
201
+ mfaVerify(input) {
202
+ return this.client.request('POST', '/api/v1/auth/mfa-verify', input);
203
+ }
204
+ /**
205
+ * Request a magic-link sign-in email. Enumeration-safe: same response
206
+ * shape whether the email exists or not. When the Application has
207
+ * email transport configured, the link is sent and `magicLinkToken`
208
+ * is null; otherwise the raw token is returned for you to forward.
209
+ */
210
+ requestMagicLink(input) {
211
+ return this.client.request('POST', '/api/v1/auth/magic-link/request', input);
212
+ }
213
+ /**
214
+ * Consume a magic-link token. Returns `SignInOutcome` — branch on
215
+ * `mfaRequired` before reading `accessToken`. For MFA-enrolled users
216
+ * the response carries `mfaChallengeToken` and you must complete via
217
+ * `mfaVerify(...)`.
218
+ */
219
+ verifyMagicLink(input) {
220
+ return this.client.request('POST', '/api/v1/auth/magic-link/verify', input);
221
+ }
222
+ /**
223
+ * Begin a passkey authentication ceremony. Returns the WebAuthn options
224
+ * to forward to the browser (`navigator.credentials.get(...)`) along
225
+ * with `expectedChallenge` — bind the challenge to your session and
226
+ * pass both back via `verifyPasskeyAuthentication(...)`.
227
+ */
228
+ startPasskeyAuthentication(input) {
229
+ return this.client.request('POST', '/api/v1/auth/passkey/authenticate/start', input ?? {});
230
+ }
231
+ /**
232
+ * Complete a passkey authentication. Returns the same `SignInOutcome`
233
+ * shape as `signIn` — but passkeys are themselves a strong factor, so
234
+ * `mfaRequired` will always be `false` in practice.
235
+ */
236
+ verifyPasskeyAuthentication(input) {
237
+ return this.client.request('POST', '/api/v1/auth/passkey/authenticate/complete', input);
238
+ }
239
+ /**
240
+ * Begin a passkey registration ceremony for an authenticated user.
241
+ * Forward `options` to `navigator.credentials.create(...)`; store
242
+ * `expectedChallenge` in session; POST both back via
243
+ * `verifyPasskeyRegistration(...)`.
244
+ */
245
+ startPasskeyRegistration(accessToken) {
246
+ return this.client.request('POST', '/api/v1/auth/passkey/register/start', undefined, {
247
+ 'X-Relipay-User-Token': accessToken,
248
+ });
249
+ }
250
+ verifyPasskeyRegistration(accessToken, input) {
251
+ return this.client.request('POST', '/api/v1/auth/passkey/register/complete', input, {
252
+ 'X-Relipay-User-Token': accessToken,
253
+ });
254
+ }
255
+ /** List the user's registered passkeys. */
256
+ listPasskeys(accessToken) {
257
+ return this.client.request('GET', '/api/v1/auth/passkeys', undefined, {
258
+ 'X-Relipay-User-Token': accessToken,
259
+ });
260
+ }
261
+ /** Remove a passkey. Returns `{deleted: false}` if the row doesn't belong to this user. */
262
+ deletePasskey(accessToken, credentialRowId) {
263
+ return this.client.request('DELETE', `/api/v1/auth/passkeys/${encodeURIComponent(credentialRowId)}`, undefined, { 'X-Relipay-User-Token': accessToken });
264
+ }
265
+ // ---------- End-user organizations / teams ----------
266
+ //
267
+ // All authenticated. `authConfig.organizationsEnabled` must be true on
268
+ // the Application — otherwise every call returns `ORGANIZATIONS_NOT_ENABLED`.
269
+ createOrganization(accessToken, input) {
270
+ return this.client.request('POST', '/api/v1/users/me/organizations/', input, {
271
+ 'X-Relipay-User-Token': accessToken,
272
+ });
273
+ }
274
+ listMyOrganizations(accessToken) {
275
+ return this.client.request('GET', '/api/v1/users/me/organizations/', undefined, {
276
+ 'X-Relipay-User-Token': accessToken,
277
+ });
278
+ }
279
+ inviteToOrganization(accessToken, organizationId, input) {
280
+ return this.client.request('POST', `/api/v1/users/me/organizations/${encodeURIComponent(organizationId)}/invitations`, input, { 'X-Relipay-User-Token': accessToken });
281
+ }
282
+ acceptOrganizationInvitation(accessToken, input) {
283
+ return this.client.request('POST', '/api/v1/auth/organizations/accept-invitation', input, {
284
+ 'X-Relipay-User-Token': accessToken,
285
+ });
286
+ }
287
+ /**
288
+ * Resolve the end-user behind a presented access token.
289
+ *
290
+ * @throws {RelipayError} `USER_TOKEN_INVALID` (401) if expired/forged/wrong-secret.
291
+ * @throws {RelipayError} `USER_TOKEN_WRONG_APPLICATION` (401) if the token was issued
292
+ * by a different Application than the calling secret key represents.
293
+ */
294
+ getCurrentUser(accessToken) {
295
+ return this.client.request('GET', '/api/v1/users/me/', undefined, {
296
+ 'X-Relipay-User-Token': accessToken,
297
+ });
298
+ }
299
+ /**
300
+ * Exchange a refresh token for a fresh {access, refresh} pair. The presented
301
+ * refresh is revoked atomically — call this **once** and store the new
302
+ * `refreshToken` from the response immediately.
303
+ *
304
+ * @throws {RelipayError} `REFRESH_TOKEN_REUSED` (401) if you replay an already-used token.
305
+ * This is a strong signal the original was leaked; treat as compromise.
306
+ * @throws {RelipayError} `REFRESH_TOKEN_EXPIRED` (401) after the 30-day refresh window.
307
+ */
308
+ refresh(refreshToken) {
309
+ // /auth/refresh returns the same shape as /auth/mfa-verify — always a
310
+ // full session (refresh requires a prior MFA-verified session by
311
+ // definition).
312
+ return this.client.request('POST', '/api/v1/auth/refresh', { refreshToken });
313
+ }
314
+ /**
315
+ * Revoke a refresh token. Idempotent — no-op for unknown tokens. The
316
+ * access token paired with this refresh remains valid until its short
317
+ * (15 min) expiry; for true "log out everywhere" semantics, also clear
318
+ * the access token from your client.
319
+ */
320
+ signOut(refreshToken) {
321
+ return this.client.request('POST', '/api/v1/auth/sign-out', { refreshToken });
322
+ }
323
+ /**
324
+ * Request a password-reset token for an email. Always succeeds — never
325
+ * tells you whether the email exists. **You must email the returned
326
+ * `resetToken` to the user**: ReliPay does not send email.
327
+ *
328
+ * @example
329
+ * ```ts
330
+ * const { resetToken } = await relipay.auth.requestPasswordReset({ email });
331
+ * if (resetToken) await sendgrid.send({ to: email, subject: 'Reset', text: `link: ${url(resetToken)}` });
332
+ * ```
333
+ */
334
+ requestPasswordReset(input) {
335
+ return this.client.request('POST', '/api/v1/auth/forgot-password', input);
336
+ }
337
+ /**
338
+ * Consume a reset token + set a new password. Single-use. On success,
339
+ * every refresh token for the user is revoked.
340
+ *
341
+ * @throws {RelipayError} `PASSWORD_RESET_TOKEN_INVALID` / `_USED` / `_EXPIRED` / `_WRONG_APPLICATION`
342
+ * @throws {RelipayError} `PASSWORD_TOO_SHORT` if below the Application's `passwordMinLength`
343
+ */
344
+ resetPassword(input) {
345
+ return this.client.request('POST', '/api/v1/auth/reset-password', input);
346
+ }
347
+ /**
348
+ * Authenticated password change. Pass the user's *current* access token.
349
+ * On success, every refresh token for the user is revoked — other devices
350
+ * are signed out.
351
+ */
352
+ changePassword(accessToken, input) {
353
+ return this.client.request('POST', '/api/v1/auth/change-password', input, {
354
+ 'X-Relipay-User-Token': accessToken,
355
+ });
356
+ }
357
+ /**
358
+ * Revoke every refresh token for the calling user. "Sign out of all
359
+ * devices." The caller's access token remains valid until 15-min expiry
360
+ * — clear it client-side for full logout.
361
+ */
362
+ signOutEverywhere(accessToken) {
363
+ return this.client.request('POST', '/api/v1/auth/sign-out-everywhere', undefined, { 'X-Relipay-User-Token': accessToken });
364
+ }
365
+ /**
366
+ * Send (or re-send) an email-verification link to the current user.
367
+ * If email transport is configured on the Application, ReliPay sends
368
+ * the email and `verificationToken` is null. Otherwise the raw token
369
+ * is returned for the caller to forward via their own provider.
370
+ *
371
+ * Pass `verifyUrl` containing `{token}` to template the link target
372
+ * (e.g. `https://app.example.com/verify?t={token}`).
373
+ */
374
+ sendVerificationEmail(accessToken, input) {
375
+ return this.client.request('POST', '/api/v1/auth/send-verification', input ?? {}, {
376
+ 'X-Relipay-User-Token': accessToken,
377
+ });
378
+ }
379
+ /**
380
+ * Consume an email-verification token. Single-use, 24-hour lifetime.
381
+ * Marks `emailVerified: true` on the user record. Cross-Application
382
+ * tokens are refused with `EMAIL_VERIFICATION_TOKEN_WRONG_APPLICATION`.
383
+ */
384
+ verifyEmail(input) {
385
+ return this.client.request('POST', '/api/v1/auth/verify-email', input);
386
+ }
387
+ }
388
+ class OrganizationsClient {
389
+ client;
390
+ constructor(client) {
391
+ this.client = client;
392
+ }
393
+ /** Create an organization; the calling user becomes the OWNER. */
394
+ create(accessToken, input) {
395
+ return this.client.request('POST', '/api/v1/users/me/organizations/', input, {
396
+ 'X-Relipay-User-Token': accessToken,
397
+ });
398
+ }
399
+ /** List organizations the calling user belongs to, with their role. */
400
+ listMine(accessToken) {
401
+ return this.client.request('GET', '/api/v1/users/me/organizations/', undefined, {
402
+ 'X-Relipay-User-Token': accessToken,
403
+ });
404
+ }
405
+ /** Fetch one organization the caller belongs to. */
406
+ get(accessToken, organizationId) {
407
+ return this.client.request('GET', `/api/v1/users/me/organizations/${encodeURIComponent(organizationId)}`, undefined, { 'X-Relipay-User-Token': accessToken });
408
+ }
409
+ /** Update org name / metadata. OWNER + ADMIN only. */
410
+ update(accessToken, organizationId, input) {
411
+ return this.client.request('PATCH', `/api/v1/users/me/organizations/${encodeURIComponent(organizationId)}`, input, { 'X-Relipay-User-Token': accessToken });
412
+ }
413
+ /** List members of an organization the caller belongs to. */
414
+ listMembers(accessToken, organizationId) {
415
+ return this.client.request('GET', `/api/v1/users/me/organizations/${encodeURIComponent(organizationId)}/members`, undefined, { 'X-Relipay-User-Token': accessToken });
416
+ }
417
+ /**
418
+ * Invite a user. Returns the raw token ONCE — surface via your own
419
+ * email/share channel. OWNER + ADMIN only.
420
+ */
421
+ invite(accessToken, organizationId, input) {
422
+ return this.client.request('POST', `/api/v1/users/me/organizations/${encodeURIComponent(organizationId)}/invitations`, input, { 'X-Relipay-User-Token': accessToken });
423
+ }
424
+ /** Revoke a pending invitation. OWNER + ADMIN only. Idempotent. */
425
+ revokeInvitation(accessToken, organizationId, invitationId) {
426
+ return this.client.request('POST', `/api/v1/users/me/organizations/${encodeURIComponent(organizationId)}/invitations/${encodeURIComponent(invitationId)}/revoke`, undefined, { 'X-Relipay-User-Token': accessToken });
427
+ }
428
+ /**
429
+ * Change a member's role. OWNER manages anyone; ADMIN manages MEMBER
430
+ * only. Last-OWNER guard refuses demoting the only OWNER.
431
+ */
432
+ setMemberRole(accessToken, organizationId, targetEndUserId, input) {
433
+ return this.client.request('PATCH', `/api/v1/users/me/organizations/${encodeURIComponent(organizationId)}/members/${encodeURIComponent(targetEndUserId)}`, input, { 'X-Relipay-User-Token': accessToken });
434
+ }
435
+ /** Remove a member (or self). Refuses removing the last OWNER. */
436
+ removeMember(accessToken, organizationId, targetEndUserId) {
437
+ return this.client.request('DELETE', `/api/v1/users/me/organizations/${encodeURIComponent(organizationId)}/members/${encodeURIComponent(targetEndUserId)}`, undefined, { 'X-Relipay-User-Token': accessToken });
438
+ }
439
+ /** Self-leave. Refuses leaving as the last OWNER. */
440
+ leave(accessToken, organizationId) {
441
+ return this.client.request('POST', `/api/v1/users/me/organizations/${encodeURIComponent(organizationId)}/leave`, undefined, { 'X-Relipay-User-Token': accessToken });
442
+ }
443
+ /**
444
+ * Accept an organization invitation by raw token. Refuses cross-
445
+ * Application invitations. Idempotent if the caller is already a member.
446
+ */
447
+ acceptInvitation(accessToken, input) {
448
+ return this.client.request('POST', '/api/v1/auth/organizations/accept-invitation', input, { 'X-Relipay-User-Token': accessToken });
449
+ }
450
+ }
451
+ class LicensesClient {
452
+ client;
453
+ constructor(client) {
454
+ this.client = client;
455
+ }
456
+ /**
457
+ * Verify a license key + record an activation for this machine. Call
458
+ * once at app startup; you'll get a deterministic body (`ok=false` for
459
+ * invalid licenses — never an HTTP error — so your software can loop
460
+ * on the result without try/catch noise).
461
+ *
462
+ * `machineFingerprint` should be a stable identifier you derive client-
463
+ * side (hostname + OS + mac address, hashed). The same fingerprint
464
+ * across re-verifications does NOT consume a new seat.
465
+ *
466
+ * @example
467
+ * ```ts
468
+ * const result = await relipay.licenses.verify({
469
+ * key,
470
+ * machineFingerprint,
471
+ * label: 'Adam\'s MacBook',
472
+ * });
473
+ * if (!result.ok) showLicenseError(result.reason);
474
+ * ```
475
+ */
476
+ verify(input) {
477
+ return this.client.request('POST', '/api/v1/licenses/verify', input);
478
+ }
479
+ }
480
+ class UsageClient {
481
+ client;
482
+ constructor(client) {
483
+ this.client = client;
484
+ }
485
+ /**
486
+ * Record a usage event against a named meter. `quantity` can be
487
+ * negative to credit back (e.g. refunds). `occurredAt` defaults to
488
+ * server time; pass an ISO string when ingesting historical events.
489
+ */
490
+ record(input) {
491
+ return this.client.request('POST', '/api/v1/usage/record', input);
492
+ }
493
+ /**
494
+ * Sum recorded quantity for a meter, optionally bounded by a time
495
+ * window and/or scoped to one end-user. Use to drive in-app "you've
496
+ * used X of your Y quota this month" displays.
497
+ */
498
+ aggregate(input) {
499
+ const params = new URLSearchParams();
500
+ params.set('meterSlug', input.meterSlug);
501
+ if (input.from)
502
+ params.set('from', input.from);
503
+ if (input.to)
504
+ params.set('to', input.to);
505
+ if (input.endUserId)
506
+ params.set('endUserId', input.endUserId);
507
+ return this.client.request('GET', `/api/v1/usage/aggregate?${params.toString()}`);
508
+ }
509
+ }
510
+ /**
511
+ * Prepaid credits — the "lead pack" / pay-as-you-go drawdown model. The
512
+ * customer's backend grants credits (by selling a CREDIT-kind plan, which
513
+ * grants automatically on payment) and draws them down per unit consumed.
514
+ *
515
+ * All calls are server-to-server (secret key) and scoped to an end-user id.
516
+ */
517
+ class CreditsClient {
518
+ client;
519
+ constructor(client) {
520
+ this.client = client;
521
+ }
522
+ /** Current spendable balance for an end-user (0 if they've never held credits). */
523
+ getBalance(endUserId) {
524
+ return this.client.request('GET', `/api/v1/credits/balance?endUserId=${encodeURIComponent(endUserId)}`);
525
+ }
526
+ /**
527
+ * Deduct credits from an end-user. Throws `RelipayError` with
528
+ * `code: "CREDITS_INSUFFICIENT"` (HTTP 402) when the balance is too low.
529
+ *
530
+ * Pass `idempotencyKey` (e.g. the lead id) so a retried call never
531
+ * double-charges — a repeat with the same key returns the original result
532
+ * with `applied: false`.
533
+ */
534
+ consume(input) {
535
+ return this.client.request('POST', '/api/v1/credits/consume', input);
536
+ }
537
+ /** Recent ledger entries for an end-user, newest first. */
538
+ listLedger(endUserId, limit) {
539
+ const params = new URLSearchParams({ endUserId });
540
+ if (limit !== undefined)
541
+ params.set('limit', String(limit));
542
+ return this.client.request('GET', `/api/v1/credits/ledger?${params.toString()}`);
543
+ }
544
+ }
545
+ /**
546
+ * Verify the HMAC signature on an inbound webhook from ReliPay. Returns
547
+ * `true` only when (a) the timestamp is fresh (within `toleranceSeconds`,
548
+ * default 300) AND (b) the signature matches a constant-time compare.
549
+ *
550
+ * Use against the `X-Relipay-Signature` header and the raw request body
551
+ * BYTES (not the parsed JSON — any reserialization breaks the HMAC).
552
+ *
553
+ * @example
554
+ * ```ts
555
+ * import { verifyWebhookSignature } from '@relipay/node';
556
+ *
557
+ * app.post('/webhooks/relipay', { config: { rawBody: true } }, (req) => {
558
+ * const ok = verifyWebhookSignature({
559
+ * header: req.headers['x-relipay-signature'] as string,
560
+ * payload: req.rawBody!,
561
+ * secret: process.env.RELIPAY_WEBHOOK_SECRET!,
562
+ * });
563
+ * if (!ok) return reply.status(401).send({ error: 'bad signature' });
564
+ * // safe to act on req.body
565
+ * });
566
+ * ```
567
+ */
568
+ export function verifyWebhookSignature(args) {
569
+ if (!args.header)
570
+ return false;
571
+ const tolerance = (args.toleranceSeconds ?? 300) * 1000;
572
+ const nowMs = args.now ? args.now() : Date.now();
573
+ const parts = args.header.split(',').reduce((acc, p) => {
574
+ const [k, v] = p.split('=', 2);
575
+ if (k && v)
576
+ acc[k.trim()] = v.trim();
577
+ return acc;
578
+ }, {});
579
+ const t = Number(parts.t);
580
+ const v1 = parts.v1;
581
+ if (!Number.isFinite(t) || !v1)
582
+ return false;
583
+ if (Math.abs(nowMs - t * 1000) > tolerance)
584
+ return false;
585
+ // Lazy-load Node crypto so the SDK still runs in edge runtimes that
586
+ // don't bundle it (signature verification is the only place we need
587
+ // crypto — everything else uses fetch).
588
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
589
+ const { createHmac, timingSafeEqual } = require('node:crypto');
590
+ const body = typeof args.payload === 'string' ? Buffer.from(args.payload, 'utf8') : args.payload;
591
+ const signed = `${t}.${body.toString('utf8')}`;
592
+ const expected = createHmac('sha256', args.secret).update(signed).digest('hex');
593
+ const a = Buffer.from(expected, 'hex');
594
+ const b = Buffer.from(v1, 'hex');
595
+ if (a.length !== b.length)
596
+ return false;
597
+ return timingSafeEqual(a, b);
598
+ }
599
+ class BillingClient {
600
+ client;
601
+ constructor(client) {
602
+ this.client = client;
603
+ }
604
+ /**
605
+ * List the calling Application's active plans. Public — pricing pages
606
+ * typically render straight from this. Application API key only; no
607
+ * user JWT needed.
608
+ *
609
+ * `amount` is in the smallest currency unit (cents/paise/sen) — never
610
+ * a float. Format on display: `${amount / 100} ${currency}`.
611
+ */
612
+ getPlans() {
613
+ return this.client.request('GET', '/api/v1/billing/plans');
614
+ }
615
+ /**
616
+ * Fetch the current end-user's active subscription, or `null` if they
617
+ * have none. Returns the most recent ACTIVE / PENDING / PAST_DUE row.
618
+ *
619
+ * Pass the user's access token (the SDK puts it in `X-Relipay-User-Token`).
620
+ */
621
+ getSubscription(accessToken) {
622
+ return this.client.request('GET', '/api/v1/billing/subscription', undefined, {
623
+ 'X-Relipay-User-Token': accessToken,
624
+ });
625
+ }
626
+ /**
627
+ * Start a hosted-checkout session. Returns the URL to redirect the user
628
+ * to and the local PENDING Subscription row. Subscription activation
629
+ * happens via the provider's webhook — not synchronously here.
630
+ *
631
+ * Pass `couponCode` to apply a discount. The whole checkout fails if the
632
+ * coupon doesn't validate (typed `RelipayError` with the precise reason).
633
+ *
634
+ * @example
635
+ * ```ts
636
+ * const { url, discountAmount } = await relipay.billing.createCheckout(userAccessToken, {
637
+ * planSlug: 'pro_monthly',
638
+ * successUrl: 'https://yourapp.com/billing?status=ok',
639
+ * cancelUrl: 'https://yourapp.com/billing?status=cancel',
640
+ * couponCode: 'LAUNCH50', // optional
641
+ * });
642
+ * res.redirect(url);
643
+ * ```
644
+ */
645
+ createCheckout(accessToken, input) {
646
+ return this.client.request('POST', '/api/v1/billing/checkout', input, {
647
+ 'X-Relipay-User-Token': accessToken,
648
+ });
649
+ }
650
+ /**
651
+ * Validate a coupon for the current user against a plan, *without*
652
+ * applying it. Render "$50 off" on a pricing page before submit.
653
+ *
654
+ * @throws {RelipayError} with one of `COUPON_NOT_FOUND` / `COUPON_INACTIVE`
655
+ * / `COUPON_NOT_YET_STARTED` / `COUPON_EXPIRED` / `COUPON_NOT_APPLICABLE`
656
+ * / `COUPON_CURRENCY_MISMATCH` / `COUPON_REDEMPTION_LIMIT_REACHED` /
657
+ * `COUPON_USER_LIMIT_REACHED`. Surface the message + fix to the user.
658
+ */
659
+ validateCoupon(accessToken, input) {
660
+ return this.client.request('POST', '/api/v1/billing/coupons/validate', input, {
661
+ 'X-Relipay-User-Token': accessToken,
662
+ });
663
+ }
664
+ /**
665
+ * List the billing providers configured + enabled for this Application,
666
+ * in the order the geo router would prefer them. Forward the end-user's
667
+ * `country` (ISO 3166-1 alpha-2) when you have it — the panel/SDK will
668
+ * surface India-specific providers (Razorpay) for IN-country users, etc.
669
+ *
670
+ * Returns the resolved country (echoed back from the server's view of
671
+ * `CF-IPCountry` etc.) plus the ordered provider list. Use this to render
672
+ * a "Pay with..." picker on your pricing page.
673
+ */
674
+ getProviders(country) {
675
+ const headers = {};
676
+ if (country)
677
+ headers['x-country'] = country.toUpperCase();
678
+ return this.client.request('GET', '/api/v1/billing/providers', undefined, headers);
679
+ }
680
+ }
681
+ //# sourceMappingURL=index.js.map