@basaltkit/subscriptions 1.0.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.
@@ -0,0 +1,476 @@
1
+ import * as _basaltkit_core from '@basaltkit/core';
2
+ import { DurationInput, BasaltError, HookBus } from '@basaltkit/core';
3
+ import { BasaltRoute } from '@basaltkit/fastify';
4
+
5
+ declare class UnknownPlanError extends BasaltError {
6
+ constructor(plan: string);
7
+ }
8
+ /** Metered feature: the limit resets every billing month. */
9
+ interface Meter {
10
+ readonly meter: true;
11
+ readonly limit: number;
12
+ }
13
+ /** `'api.requests': meter(100_000)` — consumed monthly. */
14
+ declare function meter(limit: number): Meter;
15
+ /**
16
+ * boolean → on/off flag · number → consumable balance (lifetime)
17
+ * meter(n) → monthly-reset quota · Infinity → unlimited
18
+ */
19
+ type FeatureValue = boolean | number | Meter;
20
+ type BillingPeriod = 'monthly' | 'yearly';
21
+ interface PlanDefinition {
22
+ /** 0 = free · number = same price both periods · object = per period · 'custom' = sales-led */
23
+ price: number | {
24
+ monthly: number;
25
+ yearly: number;
26
+ } | 'custom';
27
+ trial?: DurationInput;
28
+ features: Record<string, FeatureValue>;
29
+ }
30
+ type Plans = Record<string, PlanDefinition>;
31
+ declare function definePlans<T extends Plans>(plans: T): T;
32
+ declare function planPrice(plan: PlanDefinition, period: BillingPeriod): number | 'custom';
33
+ /** Normalized limit of a feature: false→0, true→Infinity. */
34
+ declare function featureLimit(value: FeatureValue | undefined): number;
35
+ declare function isMeter(value: FeatureValue | undefined): value is Meter;
36
+
37
+ type SubscriptionStatus = 'active' | 'trialing' | 'past_due' | 'canceled' | 'incomplete';
38
+ interface SubscriptionRecord {
39
+ /** The billable entity — the tenant id by convention. */
40
+ billableId: string;
41
+ plan: string;
42
+ period: BillingPeriod;
43
+ status: SubscriptionStatus;
44
+ trialEndsAt?: number;
45
+ cancelAtPeriodEnd?: boolean;
46
+ canceledAt?: number;
47
+ /** Reference in the payment gateway (e.g. Stripe subscription id). */
48
+ gatewayRef?: string;
49
+ }
50
+ interface SubscriptionStore {
51
+ get(billableId: string): Promise<SubscriptionRecord | null>;
52
+ save(record: SubscriptionRecord): Promise<void>;
53
+ all(): Promise<SubscriptionRecord[]>;
54
+ }
55
+ declare class MemorySubscriptionStore implements SubscriptionStore {
56
+ private readonly records;
57
+ get(billableId: string): Promise<SubscriptionRecord | null>;
58
+ save(record: SubscriptionRecord): Promise<void>;
59
+ all(): Promise<SubscriptionRecord[]>;
60
+ }
61
+ /**
62
+ * Deduplicates webhook processing across restarts and instances. `markProcessed`
63
+ * claims an event id (true = new, process it; false = already seen, skip);
64
+ * `release` frees the claim so a failed apply can be retried by the gateway.
65
+ */
66
+ interface WebhookStore {
67
+ markProcessed(id: string): Promise<boolean>;
68
+ release(id: string): Promise<void>;
69
+ }
70
+ declare class MemoryWebhookStore implements WebhookStore {
71
+ private readonly seen;
72
+ markProcessed(id: string): Promise<boolean>;
73
+ release(id: string): Promise<void>;
74
+ }
75
+ interface UsageConsumeResult {
76
+ /** Whether the increment was applied (false when it would exceed the limit). */
77
+ applied: boolean;
78
+ /** Usage after the operation (unchanged when not applied). */
79
+ used: number;
80
+ }
81
+ /** Usage counters: `periodKey` is 'lifetime' or 'YYYY-MM' for meters. */
82
+ interface UsageStore {
83
+ get(billableId: string, feature: string, periodKey: string): Promise<number>;
84
+ /** Unconditional increment — used for unlimited features. Returns the new total. */
85
+ increment(billableId: string, feature: string, periodKey: string, amount: number): Promise<number>;
86
+ /**
87
+ * Atomically increments by `amount` only if the result stays within `limit`.
88
+ * Must be atomic under concurrency so a quota is never overshot.
89
+ */
90
+ consume(billableId: string, feature: string, periodKey: string, amount: number, limit: number): Promise<UsageConsumeResult>;
91
+ }
92
+ declare class MemoryUsageStore implements UsageStore {
93
+ private readonly counters;
94
+ private key;
95
+ get(billableId: string, feature: string, periodKey: string): Promise<number>;
96
+ increment(billableId: string, feature: string, periodKey: string, amount: number): Promise<number>;
97
+ consume(billableId: string, feature: string, periodKey: string, amount: number, limit: number): Promise<UsageConsumeResult>;
98
+ }
99
+
100
+ /** Minimal ioredis-compatible surface — inject your client, no hard dependency. */
101
+ interface RedisLike {
102
+ get(key: string): Promise<string | null>;
103
+ eval(script: string, numKeys: number, ...args: (string | number)[]): Promise<unknown>;
104
+ }
105
+ interface RedisUsageStoreOptions {
106
+ /** Key prefix. Default: 'basalt:usage'. */
107
+ prefix?: string;
108
+ /**
109
+ * TTL in seconds for periodic (monthly) counters, so old buckets are
110
+ * reclaimed. Lifetime counters never expire. Default: 60 days.
111
+ */
112
+ ttlSeconds?: number;
113
+ }
114
+ declare class RedisUsageStore implements UsageStore {
115
+ private readonly redis;
116
+ private readonly prefix;
117
+ private readonly ttlSeconds;
118
+ constructor(redis: RedisLike, options?: RedisUsageStoreOptions);
119
+ private key;
120
+ private ttlFor;
121
+ get(billableId: string, feature: string, periodKey: string): Promise<number>;
122
+ increment(billableId: string, feature: string, periodKey: string, amount: number): Promise<number>;
123
+ consume(billableId: string, feature: string, periodKey: string, amount: number, limit: number): Promise<UsageConsumeResult>;
124
+ }
125
+
126
+ /** Minimal ioredis-compatible surface for webhook dedupe. */
127
+ interface RedisWebhookClient {
128
+ set(key: string, value: string, ex: 'EX', seconds: number, nx: 'NX'): Promise<'OK' | null>;
129
+ del(key: string): Promise<number>;
130
+ }
131
+ interface RedisWebhookStoreOptions {
132
+ /** Key prefix. Default: 'basalt:webhook'. */
133
+ prefix?: string;
134
+ /**
135
+ * How long a processed id stays deduplicated, in seconds. Cover the
136
+ * gateway's retry window with margin. Default: 7 days.
137
+ */
138
+ ttlSeconds?: number;
139
+ }
140
+ /**
141
+ * Durable webhook dedupe via Redis `SET key value NX EX` — the atomic claim
142
+ * returns 'OK' only for the first caller, so idempotency holds across process
143
+ * restarts and multiple instances.
144
+ */
145
+ declare class RedisWebhookStore implements WebhookStore {
146
+ private readonly redis;
147
+ private readonly prefix;
148
+ private readonly ttlSeconds;
149
+ constructor(redis: RedisWebhookClient, options?: RedisWebhookStoreOptions);
150
+ private key;
151
+ markProcessed(id: string): Promise<boolean>;
152
+ release(id: string): Promise<void>;
153
+ }
154
+
155
+ declare class WebhookInvalidError extends BasaltError {
156
+ readonly status = 400;
157
+ constructor();
158
+ }
159
+ /** Gateway-agnostic webhook event, already translated to domain terms. */
160
+ interface WebhookEvent {
161
+ /** Unique id at the gateway — used for idempotent processing. */
162
+ id: string;
163
+ type: 'subscription.canceled' | 'payment.failed' | 'payment.succeeded';
164
+ billableId: string;
165
+ /** Gateway subscription id, when the event carries one (e.g. after Checkout). */
166
+ gatewayRef?: string;
167
+ }
168
+ interface CreateSubscriptionInput {
169
+ billableId: string;
170
+ plan: string;
171
+ period: BillingPeriod;
172
+ price: number;
173
+ /**
174
+ * Trial length in days. When set, the gateway runs the trial and charges at
175
+ * its end, driving the trial→active/past_due transition via webhook.
176
+ */
177
+ trialDays?: number;
178
+ }
179
+ /** Input for a hosted Checkout session (the customer enters payment there). */
180
+ interface CheckoutInput {
181
+ billableId: string;
182
+ plan: string;
183
+ period: BillingPeriod;
184
+ successUrl: string;
185
+ cancelUrl: string;
186
+ trialDays?: number;
187
+ }
188
+ /** Input for a Customer Portal session (self-service card/cancel management). */
189
+ interface PortalInput {
190
+ billableId: string;
191
+ returnUrl: string;
192
+ }
193
+ /** Input for changing a subscription's plan mid-cycle with proration. */
194
+ interface SwapInput {
195
+ plan: string;
196
+ period: BillingPeriod;
197
+ /** How the gateway settles the mid-cycle difference. Default create_prorations. */
198
+ prorationBehavior?: 'create_prorations' | 'none' | 'always_invoice';
199
+ }
200
+ /**
201
+ * Payment gateway driver contract. The app talks to Basalt; only drivers
202
+ * talk to Stripe/Paddle/Lemon Squeezy. A driver translates raw webhook
203
+ * payloads into WebhookEvent — app code never sees gateway payloads.
204
+ */
205
+ interface BillingGateway {
206
+ readonly name: string;
207
+ createSubscription(input: CreateSubscriptionInput): Promise<{
208
+ gatewayRef: string;
209
+ }>;
210
+ cancelSubscription(gatewayRef: string, options: {
211
+ atPeriodEnd: boolean;
212
+ }): Promise<void>;
213
+ /**
214
+ * Verifies the signature and translates the payload. Throws
215
+ * WebhookInvalidError on a bad signature. Returns null for a verified event
216
+ * the gateway doesn't map to a domain event (gateways emit many event types
217
+ * we don't act on).
218
+ */
219
+ verifyWebhook(rawBody: string, signature: string | undefined): WebhookEvent | null;
220
+ /** Hosted Checkout session — returns a URL to redirect the customer to. */
221
+ createCheckoutSession?(input: CheckoutInput): Promise<{
222
+ url: string;
223
+ id: string;
224
+ }>;
225
+ /** Customer Portal session — returns a URL for self-service billing. */
226
+ createPortalSession?(input: PortalInput): Promise<{
227
+ url: string;
228
+ }>;
229
+ /** Changes the plan on an existing subscription, applying proration. */
230
+ swapSubscription?(gatewayRef: string, input: SwapInput): Promise<void>;
231
+ }
232
+ /** Controllable in-process gateway — the test/dev driver. */
233
+ declare class FakeBillingGateway implements BillingGateway {
234
+ readonly name = "fake";
235
+ readonly created: CreateSubscriptionInput[];
236
+ readonly canceled: {
237
+ gatewayRef: string;
238
+ atPeriodEnd: boolean;
239
+ }[];
240
+ readonly checkouts: CheckoutInput[];
241
+ readonly portals: PortalInput[];
242
+ readonly swaps: {
243
+ gatewayRef: string;
244
+ input: SwapInput;
245
+ }[];
246
+ private counter;
247
+ createSubscription(input: CreateSubscriptionInput): Promise<{
248
+ gatewayRef: string;
249
+ }>;
250
+ cancelSubscription(gatewayRef: string, options: {
251
+ atPeriodEnd: boolean;
252
+ }): Promise<void>;
253
+ createCheckoutSession(input: CheckoutInput): Promise<{
254
+ url: string;
255
+ id: string;
256
+ }>;
257
+ createPortalSession(input: PortalInput): Promise<{
258
+ url: string;
259
+ }>;
260
+ swapSubscription(gatewayRef: string, input: SwapInput): Promise<void>;
261
+ verifyWebhook(rawBody: string, signature: string | undefined): WebhookEvent;
262
+ }
263
+
264
+ declare class StripeRequestError extends BasaltError {
265
+ readonly httpStatus: number;
266
+ constructor(httpStatus: number, message: string);
267
+ }
268
+ interface StripeGatewayOptions {
269
+ secretKey: string;
270
+ /** The endpoint signing secret (`whsec_...`) used to verify webhooks. */
271
+ webhookSecret: string;
272
+ /** Resolves the Stripe Price ID for a plan + billing period. */
273
+ priceId: (plan: string, period: BillingPeriod) => string;
274
+ /** Resolves (or ensures) the Stripe Customer ID for a billable entity. */
275
+ customerId: (billableId: string) => string | Promise<string>;
276
+ /**
277
+ * Extracts the billable id from a verified event. Default: reads
278
+ * `data.object.metadata.billableId` — which createSubscription sets on the
279
+ * subscription. Override for events whose object carries it elsewhere.
280
+ */
281
+ resolveBillableId?: (event: unknown) => string | undefined;
282
+ /** Webhook timestamp tolerance in seconds. Default: 300 (5 minutes). */
283
+ tolerance?: number;
284
+ /** Injected fetch (tests). Default: global fetch. */
285
+ fetch?: typeof fetch;
286
+ /** Clock in ms (tests). Default: Date.now. */
287
+ now?: () => number;
288
+ /** API base, for tests/mocks. Default: https://api.stripe.com */
289
+ apiBase?: string;
290
+ }
291
+ /**
292
+ * Stripe billing gateway targeting the Stripe REST API directly — no `stripe`
293
+ * SDK dependency. HTTP goes through an injectable fetch; webhook signatures are
294
+ * verified with node:crypto using Stripe's documented scheme.
295
+ */
296
+ declare class StripeBillingGateway implements BillingGateway {
297
+ private readonly options;
298
+ readonly name = "stripe";
299
+ private readonly fetch;
300
+ private readonly now;
301
+ private readonly tolerance;
302
+ private readonly apiBase;
303
+ private readonly resolveBillableId;
304
+ constructor(options: StripeGatewayOptions);
305
+ createSubscription(input: CreateSubscriptionInput): Promise<{
306
+ gatewayRef: string;
307
+ }>;
308
+ cancelSubscription(gatewayRef: string, options: {
309
+ atPeriodEnd: boolean;
310
+ }): Promise<void>;
311
+ createCheckoutSession(input: CheckoutInput): Promise<{
312
+ url: string;
313
+ id: string;
314
+ }>;
315
+ createPortalSession(input: PortalInput): Promise<{
316
+ url: string;
317
+ }>;
318
+ swapSubscription(gatewayRef: string, input: SwapInput): Promise<void>;
319
+ verifyWebhook(rawBody: string, signature: string | undefined): WebhookEvent | null;
320
+ private request;
321
+ }
322
+
323
+ declare class NotSubscribedError extends BasaltError {
324
+ readonly status = 402;
325
+ constructor();
326
+ }
327
+ declare class FeatureUnavailableError extends BasaltError {
328
+ readonly status = 403;
329
+ constructor(feature: string);
330
+ }
331
+ declare class QuotaExceededError extends BasaltError {
332
+ readonly status = 402;
333
+ constructor(feature: string, remaining: number);
334
+ }
335
+ /** A billing action needs a gateway (or gateway capability) that isn't configured. */
336
+ declare class GatewayUnsupportedError extends BasaltError {
337
+ readonly status = 501;
338
+ constructor(capability: string);
339
+ }
340
+ interface SubscriptionsOptions {
341
+ plans: Plans;
342
+ store?: SubscriptionStore;
343
+ usage?: UsageStore;
344
+ gateway?: BillingGateway;
345
+ /** Webhook dedupe store. Default: in-memory (per-process). */
346
+ webhooks?: WebhookStore;
347
+ /** Plan applied to billables without a subscription (e.g. 'free'). */
348
+ fallbackPlan?: string;
349
+ hooks?: HookBus;
350
+ }
351
+ declare class Subscriptions {
352
+ private readonly plans;
353
+ private readonly store;
354
+ private readonly usage;
355
+ private readonly gateway;
356
+ private readonly fallbackPlan;
357
+ private readonly hooks;
358
+ private readonly webhooks;
359
+ constructor(options: SubscriptionsOptions);
360
+ plan(name: string): PlanDefinition;
361
+ subscribe(billableId: string, planName: string, options?: {
362
+ period?: BillingPeriod;
363
+ }): Promise<SubscriptionRecord>;
364
+ /**
365
+ * Starts a hosted Checkout flow for a paid plan. Records the intended
366
+ * subscription locally as `incomplete` — it becomes `active` when the
367
+ * gateway confirms payment via webhook (`payment.succeeded`). Returns the
368
+ * URL to redirect the customer to.
369
+ */
370
+ checkout(billableId: string, planName: string, options: {
371
+ period?: BillingPeriod;
372
+ successUrl: string;
373
+ cancelUrl: string;
374
+ }): Promise<{
375
+ url: string;
376
+ }>;
377
+ /**
378
+ * Opens a Customer Portal session for self-service billing (update card,
379
+ * change plan, cancel). Returns the URL to redirect the customer to.
380
+ */
381
+ portal(billableId: string, options: {
382
+ returnUrl: string;
383
+ }): Promise<{
384
+ url: string;
385
+ }>;
386
+ get(billableId: string): Promise<SubscriptionRecord | null>;
387
+ /** Active = status active, or trialing with the trial still running. */
388
+ subscribed(billableId: string, plan?: string): Promise<boolean>;
389
+ onTrial(billableId: string): Promise<boolean>;
390
+ /**
391
+ * Changes the plan on an active subscription. When the subscription is
392
+ * gateway-backed, the change is pushed to the gateway with proration so the
393
+ * customer is credited/charged the mid-cycle difference (pass
394
+ * `{ prorate: false }` to switch at the next renewal with no immediate
395
+ * settlement).
396
+ */
397
+ swap(billableId: string, planName: string, options?: {
398
+ prorate?: boolean;
399
+ }): Promise<SubscriptionRecord>;
400
+ cancel(billableId: string, options?: {
401
+ atPeriodEnd?: boolean;
402
+ }): Promise<SubscriptionRecord>;
403
+ resume(billableId: string): Promise<SubscriptionRecord>;
404
+ /** Feature checks and consumption, Soulbscription-style. */
405
+ features(billableId: string): {
406
+ can: (feature: string) => Promise<boolean>;
407
+ limit: (feature: string) => Promise<number>;
408
+ usage: (feature: string) => Promise<number>;
409
+ remaining: (feature: string) => Promise<number>;
410
+ consume: (feature: string, amount?: number) => Promise<number>;
411
+ };
412
+ /**
413
+ * Applies a gateway webhook: idempotent by event id, updates local state
414
+ * and emits domain hooks. Local state is the read model — feature checks
415
+ * never call the gateway.
416
+ */
417
+ handleWebhook(event: WebhookEvent): Promise<boolean>;
418
+ /**
419
+ * Maintenance (run from the scheduler): settles expired local trials.
420
+ * Gateway-backed trials are settled by the gateway's webhook, not here.
421
+ */
422
+ expireTrials(): Promise<SubscriptionRecord[]>;
423
+ private isActive;
424
+ }
425
+
426
+ declare module '@basaltkit/core' {
427
+ interface BasaltHooks {
428
+ 'billing:subscribed': {
429
+ subscription: SubscriptionRecord;
430
+ };
431
+ 'billing:swapped': {
432
+ subscription: SubscriptionRecord;
433
+ from: string;
434
+ };
435
+ 'billing:canceled': {
436
+ subscription: SubscriptionRecord;
437
+ };
438
+ 'billing:trial_expired': {
439
+ subscription: SubscriptionRecord;
440
+ };
441
+ 'billing:webhook': {
442
+ event: WebhookEvent;
443
+ };
444
+ 'billing:checkout_started': {
445
+ billableId: string;
446
+ plan: string;
447
+ url: string;
448
+ };
449
+ }
450
+ }
451
+ declare const SUBSCRIPTIONS: _basaltkit_core.Token<Subscriptions>;
452
+ type SubscriptionsPluginOptions = Omit<SubscriptionsOptions, 'hooks'>;
453
+ declare function subscriptionsPlugin(options: SubscriptionsPluginOptions): _basaltkit_core.BasaltPlugin<unknown>;
454
+ interface BillingRoutesOptions {
455
+ /** Where Stripe returns the customer after Checkout. */
456
+ successUrl: string;
457
+ cancelUrl: string;
458
+ /** Where the Customer Portal returns the customer. Default: successUrl. */
459
+ portalReturnUrl?: string;
460
+ }
461
+ /**
462
+ * Hosted billing routes for the current tenant: `POST /billing/checkout`
463
+ * (subscribe via the gateway's hosted page) and `POST /billing/portal`
464
+ * (self-service management). Both return `{ url }` to redirect to. The
465
+ * success/cancel/return URLs are configured here; the request body may
466
+ * override them per call.
467
+ */
468
+ declare function billingRoutes(options: BillingRoutesOptions): BasaltRoute[];
469
+ /**
470
+ * Webhook endpoint: POST /billing/webhook — signature verified by the
471
+ * gateway driver, processing idempotent by event id. Returns 200 with
472
+ * { received, duplicate } so gateways stop retrying.
473
+ */
474
+ declare function billingWebhookRoute(gateway: BillingGateway): BasaltRoute;
475
+
476
+ export { type BillingGateway, type BillingPeriod, type BillingRoutesOptions, type CheckoutInput, type CreateSubscriptionInput, FakeBillingGateway, FeatureUnavailableError, type FeatureValue, GatewayUnsupportedError, MemorySubscriptionStore, MemoryUsageStore, MemoryWebhookStore, type Meter, NotSubscribedError, type PlanDefinition, type Plans, type PortalInput, QuotaExceededError, type RedisLike, RedisUsageStore, type RedisUsageStoreOptions, type RedisWebhookClient, RedisWebhookStore, type RedisWebhookStoreOptions, SUBSCRIPTIONS, StripeBillingGateway, type StripeGatewayOptions, StripeRequestError, type SubscriptionRecord, type SubscriptionStatus, type SubscriptionStore, Subscriptions, type SubscriptionsOptions, type SubscriptionsPluginOptions, type SwapInput, UnknownPlanError, type UsageConsumeResult, type UsageStore, type WebhookEvent, WebhookInvalidError, type WebhookStore, billingRoutes, billingWebhookRoute, definePlans, featureLimit, isMeter, meter, planPrice, subscriptionsPlugin };