@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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Machize Contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,374 @@
1
+ # @basaltkit/subscriptions
2
+
3
+ Billing for the Basalt framework, in the style of Laravel Cashier/Soulbscription: declarative plans, subscriptions with a trial period, features with usage limits, Stripe integration, and idempotent webhooks. You need this module when your SaaS application charges monthly fees and limits features by plan.
4
+
5
+ ## What this module solves
6
+
7
+ In a typical SaaS you sell **plans** (e.g. Free, Pro, Enterprise): a plan is a package with a price and a set of **features** — things the customer can or can't do, and in what quantity (3 projects on Free, 50 on Pro; 1000 API calls per month). A **subscription** is the link between a customer and a plan, with a status (active, trialing, past due, canceled).
8
+
9
+ Implementing this by hand is treacherous: trial periods that expire, mid-month plan changes (with *proration* — the proportional adjustment of the amount), monthly limits that need to reset, and syncing with the payment processor (the *gateway*, e.g. Stripe), which communicates via **webhooks** — HTTP requests the gateway sends to your application when a payment succeeds or fails. Those webhooks arrive duplicated and out of order, and processing them twice corrupts the state.
10
+
11
+ This module gives you all of that ready to go: you define plans in code (`definePlans`), manage the lifecycle (`subscribe`, `checkout`, `swap`, `cancel`, `resume`), check and consume features (`features(...).can/consume`, with atomic quotas that are never exceeded even under concurrent requests), and process webhooks **idempotently** (each event is applied exactly once, even if it arrives ten times). The local state is the "source of read truth": checking a feature never makes calls to Stripe.
12
+
13
+ ## Installation
14
+
15
+ ```bash
16
+ pnpm add @basaltkit/subscriptions
17
+ ```
18
+
19
+ The package depends on `@basaltkit/core` and `@basaltkit/fastify` (for HTTP routes and guards) and has `zod` as a *peer dependency*.
20
+
21
+ ## Get started in 5 minutes
22
+
23
+ 1. **Define the plans.** `price: 0` is free; an object gives monthly/yearly prices; `'custom'` is "talk to us". Features can be: boolean (on/off), number (lifetime balance), `meter(n)` (quota that resets every month), or `Infinity` (unlimited):
24
+
25
+ ```ts
26
+ // src/billing/plans.ts
27
+ import { definePlans, meter } from '@basaltkit/subscriptions'
28
+
29
+ export const plans = definePlans({
30
+ free: { price: 0, features: { projects: 3, api: false } },
31
+ pro: {
32
+ price: { monthly: 29, yearly: 290 },
33
+ trial: '14d', // 14-day trial period
34
+ features: { projects: 50, api: true, 'api.requests': meter(1000) },
35
+ },
36
+ scale: { price: 'custom', features: { projects: Number.POSITIVE_INFINITY, api: true } },
37
+ })
38
+ ```
39
+
40
+ 2. **Create the service** (no gateway yet — everything works locally):
41
+
42
+ ```ts
43
+ // src/billing/subscriptions.ts
44
+ import { Subscriptions } from '@basaltkit/subscriptions'
45
+ import { plans } from './plans.js'
46
+
47
+ export const subscriptions = new Subscriptions({
48
+ plans,
49
+ fallbackPlan: 'free', // plan applied to those without a subscription
50
+ })
51
+ ```
52
+
53
+ 3. **Subscribe a customer.** `billableId` is the identifier of who pays — by convention, the tenant's id:
54
+
55
+ ```ts
56
+ const record = await subscriptions.subscribe('acme', 'pro')
57
+ console.log(record.status) // 'trialing' (the plan has a trial)
58
+ console.log(await subscriptions.subscribed('acme')) // true
59
+ console.log(await subscriptions.onTrial('acme')) // true
60
+ ```
61
+
62
+ 4. **Check and consume features:**
63
+
64
+ ```ts
65
+ const features = subscriptions.features('acme')
66
+
67
+ console.log(await features.can('api')) // true
68
+ console.log(await features.remaining('projects')) // 50
69
+
70
+ await features.consume('projects', 2) // records the creation of 2 projects
71
+ console.log(await features.remaining('projects')) // 48
72
+ ```
73
+
74
+ 5. When the limit runs out, `consume` throws `QuotaExceededError`; a feature that's off in the plan throws `FeatureUnavailableError`. Just catch these errors to show an "upgrade" prompt.
75
+
76
+ ## Usage guide
77
+
78
+ ### Defining plans
79
+
80
+ Each plan (`PlanDefinition`) has:
81
+
82
+ | Field | Type | Required? | Description |
83
+ |---|---|---|---|
84
+ | `price` | `number \| { monthly, yearly } \| 'custom'` | Yes | `0` = free; a number = same price in both periods; `'custom'` = sales |
85
+ | `trial` | `DurationInput` (e.g. `'14d'`) | No | Trial period duration |
86
+ | `features` | `Record<string, FeatureValue>` | Yes | `boolean` (flag) · `number` (lifetime balance) · `meter(n)` (monthly quota) · `Infinity` (unlimited) |
87
+
88
+ `meter(n)` counters reset every calendar month (bucket `YYYY-MM`); numeric balances are lifetime.
89
+
90
+ ### Subscribing
91
+
92
+ ```ts
93
+ await subscriptions.subscribe('acme', 'pro') // monthly (default)
94
+ await subscriptions.subscribe('acme', 'pro', { period: 'yearly' }) // yearly
95
+ ```
96
+
97
+ - A plan with `trial` → `trialing` status until the trial period ends.
98
+ - With a gateway configured, **paid** plans are also created in the gateway (with the trial in days, if any); free plans never touch the gateway.
99
+ - Recommended alternative in production: **Checkout** (the customer enters their card on a page hosted by the gateway):
100
+
101
+ ```ts
102
+ const { url } = await subscriptions.checkout('acme', 'pro', {
103
+ successUrl: 'https://app.example.com/thank-you',
104
+ cancelUrl: 'https://app.example.com/pricing',
105
+ })
106
+ // redirect the customer to `url`; the subscription becomes 'incomplete'
107
+ // and switches to 'active' when the payment.succeeded webhook arrives
108
+ ```
109
+
110
+ ### Changing plans (swap)
111
+
112
+ ```ts
113
+ await subscriptions.swap('acme', 'scale') // with proration (immediate adjustment)
114
+ await subscriptions.swap('acme', 'scale', { prorate: false }) // only changes at the next renewal
115
+ ```
116
+
117
+ Requires an active subscription (otherwise `NotSubscribedError`). If the subscription is linked to the gateway, the change is pushed there with the chosen proration behavior.
118
+
119
+ ### Canceling and resuming
120
+
121
+ ```ts
122
+ await subscriptions.cancel('acme') // at the end of the period (default) — stays active until then
123
+ await subscriptions.resume('acme') // change your mind before the end: undoes the cancellation
124
+ await subscriptions.cancel('acme', { atPeriodEnd: false }) // immediate: status 'canceled' right away
125
+ ```
126
+
127
+ ### Customer portal (self-service)
128
+
129
+ ```ts
130
+ const { url } = await subscriptions.portal('acme', { returnUrl: 'https://app.example.com/account' })
131
+ // redirect to `url` — the customer updates their card, changes plan, or cancels on their own
132
+ ```
133
+
134
+ ### Features: the `features(billableId)` API
135
+
136
+ | Method | Returns | Description |
137
+ |---|---|---|
138
+ | `can(feature)` | `Promise<boolean>` | Does the plan grant access (limit > 0)? |
139
+ | `limit(feature)` | `Promise<number>` | Normalized limit (`false`→0, `true`→`Infinity`) |
140
+ | `usage(feature)` | `Promise<number>` | Consumption in the current period |
141
+ | `remaining(feature)` | `Promise<number>` | How much is still left to consume |
142
+ | `consume(feature, amount = 1)` | `Promise<number>` | Records consumption atomically; throws `QuotaExceededError` if exceeded, `FeatureUnavailableError` if there's no access |
143
+
144
+ Anyone without an active subscription uses the `fallbackPlan` (if defined); without a fallback, they have no access to anything.
145
+
146
+ ### Trial periods
147
+
148
+ - **Local** trials (no gateway): run `expireTrials()` periodically (e.g. from the scheduler). Free plan → `active`; paid plan → `past_due`.
149
+ - **Gateway-managed** trials: the gateway charges at the end of the trial and the webhook makes the transition (`payment.succeeded` → `active`, `payment.failed` → `past_due`). `expireTrials()` deliberately ignores them.
150
+
151
+ ### Stripe gateway
152
+
153
+ The driver talks directly to the Stripe REST API (no SDK). You need to tell it how to map your plans to Stripe *Price IDs* and how to get the *Customer ID* for each billable:
154
+
155
+ ```ts
156
+ import { StripeBillingGateway, Subscriptions } from '@basaltkit/subscriptions'
157
+ import { plans } from './plans.js'
158
+
159
+ const gateway = new StripeBillingGateway({
160
+ secretKey: process.env.STRIPE_SECRET_KEY!,
161
+ webhookSecret: process.env.STRIPE_WEBHOOK_SECRET!, // whsec_...
162
+ priceId: (plan, period) => ({
163
+ pro: { monthly: 'price_pro_m', yearly: 'price_pro_y' },
164
+ })[plan]![period],
165
+ customerId: async (billableId) => getOrCreateStripeCustomer(billableId),
166
+ })
167
+
168
+ export const subscriptions = new Subscriptions({ plans, gateway, fallbackPlan: 'free' })
169
+ ```
170
+
171
+ For development and testing there's `FakeBillingGateway`, which records all calls in arrays (`created`, `canceled`, `checkouts`, `portals`, `swaps`) and accepts the webhook signature `'valid'`.
172
+
173
+ ### Gateway webhooks
174
+
175
+ `handleWebhook(event)` applies a `WebhookEvent` already translated into domain terms: `subscription.canceled` → `canceled`, `payment.failed` → `past_due`, `payment.succeeded` → `active`. Processing is idempotent by `event.id` (returns `false` for duplicates), and if saving the state fails, the id is released so the gateway's retry can reprocess it.
176
+
177
+ Over HTTP, use the ready-made route (next section) — signature verification is handled by the gateway driver.
178
+
179
+ ### HTTP integration (plugin, guards, and routes)
180
+
181
+ `subscriptionsPlugin` registers the service in the container (token `SUBSCRIPTIONS`) and adds route **guards**: with `meta: { subscribed: true | 'plan' }`, the route requires an active subscription (otherwise HTTP 402); with `meta: { feature: 'api' }`, it requires the feature (otherwise HTTP 403). The billable is the tenant from the request context.
182
+
183
+ ```ts
184
+ import { createApp } from '@basaltkit/core'
185
+ import { fastifyPlugin, route } from '@basaltkit/fastify'
186
+ import {
187
+ billingRoutes,
188
+ billingWebhookRoute,
189
+ subscriptionsPlugin,
190
+ } from '@basaltkit/subscriptions'
191
+ import { plans } from './billing/plans.js'
192
+ import { gateway } from './billing/gateway.js'
193
+
194
+ const app = await createApp({
195
+ plugins: [
196
+ // ... your tenancy plugin, which sets context.tenant ...
197
+ subscriptionsPlugin({ plans, fallbackPlan: 'free', gateway }),
198
+ fastifyPlugin({
199
+ routes: [
200
+ route({
201
+ method: 'GET',
202
+ url: '/reports',
203
+ meta: { subscribed: 'pro' }, // requires the "pro" plan
204
+ async handler() { return { ok: true } },
205
+ }),
206
+ route({
207
+ method: 'GET',
208
+ url: '/api-data',
209
+ meta: { feature: 'api' }, // requires the "api" feature
210
+ async handler() { return { data: [] } },
211
+ }),
212
+ ...billingRoutes({
213
+ successUrl: 'https://app.example.com/thank-you',
214
+ cancelUrl: 'https://app.example.com/pricing',
215
+ }),
216
+ billingWebhookRoute(gateway),
217
+ ],
218
+ }),
219
+ ],
220
+ }).boot()
221
+ ```
222
+
223
+ Routes created:
224
+
225
+ - `POST /billing/checkout` — body `{ plan, period?, successUrl?, cancelUrl? }`, returns `{ url }` to redirect to;
226
+ - `POST /billing/portal` — optional body `{ returnUrl? }`, returns `{ url }`;
227
+ - `POST /billing/webhook` — endpoint for the gateway; returns 200 with `{ received, duplicate }`.
228
+
229
+ Important: Stripe verifies the signature over the request's **raw body**. Configure a raw-body parser for the webhook route, so `request.body` arrives as a string.
230
+
231
+ ### Production: Redis stores
232
+
233
+ In-memory stores are per-process. In production:
234
+
235
+ ```ts
236
+ import { Redis } from 'ioredis'
237
+ import { RedisUsageStore, RedisWebhookStore, Subscriptions } from '@basaltkit/subscriptions'
238
+ import { plans } from './plans.js'
239
+
240
+ const redis = new Redis(process.env.REDIS_URL!)
241
+
242
+ export const subscriptions = new Subscriptions({
243
+ plans,
244
+ usage: new RedisUsageStore(redis), // atomic quotas via a Lua script (EVAL)
245
+ webhooks: new RedisWebhookStore(redis), // durable dedupe via SET NX EX
246
+ // store: implement SubscriptionStore over your database
247
+ })
248
+ ```
249
+
250
+ `SubscriptionStore` (the subscriptions themselves) should live in your database — implement `get/save/all`.
251
+
252
+ ### Domain hooks
253
+
254
+ The plugin emits hooks on Basalt's `HookBus`: `billing:subscribed`, `billing:checkout_started`, `billing:swapped`, `billing:canceled`, `billing:trial_expired`, `billing:webhook`. Use them to send emails/notifications:
255
+
256
+ ```ts
257
+ app.hooks.on('billing:trial_expired', ({ subscription }) => {
258
+ // e.g.: notifier.notify(...) or mailer.send(...)
259
+ })
260
+ ```
261
+
262
+ ## API reference
263
+
264
+ ### Plans
265
+
266
+ | Export | Signature | Description |
267
+ |---|---|---|
268
+ | `definePlans` | `<T extends Plans>(plans: T) => T` | Declares the plan catalog (preserves types) |
269
+ | `meter` | `(limit: number) => Meter` | Metered feature with a monthly reset |
270
+ | `planPrice` | `(plan, period) => number \| 'custom'` | A plan's price for a period |
271
+ | `featureLimit` | `(value?) => number` | Normalized limit (`false`→0, `true`→`Infinity`) |
272
+ | `isMeter` | `(value?) => value is Meter` | (Advanced) tests whether a value is a meter |
273
+ | `UnknownPlanError` | error | `BILLING_UNKNOWN_PLAN` — undefined plan |
274
+
275
+ ### `class Subscriptions`
276
+
277
+ `new Subscriptions(options: SubscriptionsOptions)`:
278
+
279
+ | Option | Type | Required? | Default | Description |
280
+ |---|---|---|---|---|
281
+ | `plans` | `Plans` | Yes | — | Plan catalog |
282
+ | `store` | `SubscriptionStore` | No | `MemorySubscriptionStore` | Subscription persistence |
283
+ | `usage` | `UsageStore` | No | `MemoryUsageStore` | Consumption counters |
284
+ | `gateway` | `BillingGateway` | No | — | Payment processor |
285
+ | `webhooks` | `WebhookStore` | No | `MemoryWebhookStore` | Webhook dedupe (Redis in production) |
286
+ | `fallbackPlan` | `string` | No | — | Plan for those without a subscription (validated at startup) |
287
+ | `hooks` | `HookBus` | No | — | Hook bus (the plugin passes it automatically) |
288
+
289
+ Methods:
290
+
291
+ | Method | Signature | Description |
292
+ |---|---|---|
293
+ | `plan` | `(name) => PlanDefinition` | Gets a plan; throws `UnknownPlanError` |
294
+ | `subscribe` | `(billableId, plan, { period? }?) => Promise<SubscriptionRecord>` | Creates the subscription (gateway only for paid plans) |
295
+ | `checkout` | `(billableId, plan, { period?, successUrl, cancelUrl }) => Promise<{ url }>` | Hosted Checkout session; saves `incomplete` state |
296
+ | `portal` | `(billableId, { returnUrl }) => Promise<{ url }>` | Customer Portal session |
297
+ | `get` | `(billableId) => Promise<SubscriptionRecord \| null>` | Reads the subscription |
298
+ | `subscribed` | `(billableId, plan?) => Promise<boolean>` | Active (or in a valid trial), optionally on a specific plan |
299
+ | `onTrial` | `(billableId) => Promise<boolean>` | Is it in a trial period? |
300
+ | `swap` | `(billableId, plan, { prorate? }?) => Promise<SubscriptionRecord>` | Changes plan (proration by default) |
301
+ | `cancel` | `(billableId, { atPeriodEnd? }?) => Promise<SubscriptionRecord>` | Cancels (at the end of the period by default) |
302
+ | `resume` | `(billableId) => Promise<SubscriptionRecord>` | Undoes a scheduled cancellation |
303
+ | `features` | `(billableId) => { can, limit, usage, remaining, consume }` | Features API (see above) |
304
+ | `handleWebhook` | `(event: WebhookEvent) => Promise<boolean>` | Applies an event idempotently; `false` = duplicate |
305
+ | `expireTrials` | `() => Promise<SubscriptionRecord[]>` | Settles expired local trials (run it in the scheduler) |
306
+
307
+ `SubscriptionRecord`: `{ billableId, plan, period, status, trialEndsAt?, cancelAtPeriodEnd?, canceledAt?, gatewayRef? }` with `status ∈ 'active' | 'trialing' | 'past_due' | 'canceled' | 'incomplete'`.
308
+
309
+ Errors (all with `code` and HTTP `status`): `NotSubscribedError` (`BILLING_SUBSCRIPTION_REQUIRED`, 402), `FeatureUnavailableError` (`BILLING_FEATURE_UNAVAILABLE`, 403), `QuotaExceededError` (`BILLING_QUOTA_EXCEEDED`, 402), `GatewayUnsupportedError` (`BILLING_GATEWAY_UNSUPPORTED`, 501).
310
+
311
+ ### Gateways
312
+
313
+ `BillingGateway` (Advanced — the contract for writing a gateway driver): `name`, `createSubscription`, `cancelSubscription`, `verifyWebhook`, and, optionally, `createCheckoutSession`, `createPortalSession`, `swapSubscription`. `verifyWebhook(rawBody, signature)` validates the signature (throws `WebhookInvalidError`, `BILLING_WEBHOOK_INVALID`, 400) and translates the payload into a `WebhookEvent` — `{ id, type, billableId, gatewayRef? }` with `type ∈ 'subscription.canceled' | 'payment.failed' | 'payment.succeeded'` — or `null` for events that are verified but irrelevant.
314
+
315
+ `StripeGatewayOptions`:
316
+
317
+ | Option | Type | Required? | Default | Description |
318
+ |---|---|---|---|---|
319
+ | `secretKey` | `string` | Yes | — | Stripe API secret key |
320
+ | `webhookSecret` | `string` | Yes | — | Endpoint secret (`whsec_...`) |
321
+ | `priceId` | `(plan, period) => string` | Yes | — | Maps plan+period → Stripe Price ID |
322
+ | `customerId` | `(billableId) => string \| Promise<string>` | Yes | — | Gets/ensures the Stripe Customer ID |
323
+ | `resolveBillableId` | `(event) => string \| undefined` | No | reads `metadata.billableId` | (Advanced) extracts the billable from an event |
324
+ | `tolerance` | `number` | No | `300` | Webhook timestamp tolerance (seconds) |
325
+ | `fetch` / `now` / `apiBase` | — | No | globals | (Advanced) injections for tests |
326
+
327
+ Specific error: `StripeRequestError` (`BILLING_GATEWAY_ERROR`, with `httpStatus`).
328
+
329
+ `FakeBillingGateway` — a test/development gateway; records calls in `created`, `canceled`, `checkouts`, `portals`, `swaps`, and only accepts the `'valid'` signature in `verifyWebhook`.
330
+
331
+ ### Stores
332
+
333
+ | Export | Description |
334
+ |---|---|
335
+ | `SubscriptionStore` (Advanced) | `get/save/all` — implement over your DB; `MemorySubscriptionStore` included |
336
+ | `UsageStore` (Advanced) | `get/increment/consume` — `consume` must be atomic; `MemoryUsageStore` included |
337
+ | `WebhookStore` (Advanced) | `markProcessed(id)` (claim; `true` = new) / `release(id)`; `MemoryWebhookStore` included |
338
+ | `RedisUsageStore` | `new RedisUsageStore(redis, { prefix? = 'basalt:usage', ttlSeconds? = 60 days })` — atomic quotas via EVAL |
339
+ | `RedisWebhookStore` | `new RedisWebhookStore(redis, { prefix? = 'basalt:webhook', ttlSeconds? = 7 days })` — durable dedupe via SET NX EX |
340
+ | `RedisLike` / `RedisWebhookClient` | (Advanced) minimal surfaces compatible with ioredis — inject your own client |
341
+
342
+ ### Plugin and HTTP routes
343
+
344
+ | Export | Description |
345
+ |---|---|
346
+ | `SUBSCRIPTIONS` | Service token in the container |
347
+ | `subscriptionsPlugin(options)` | Registers the service and the `meta.subscribed`/`meta.feature` guards; `options` = `SubscriptionsOptions` without `hooks` |
348
+ | `billingRoutes({ successUrl, cancelUrl, portalReturnUrl? })` | Routes `POST /billing/checkout` and `POST /billing/portal` for the current tenant |
349
+ | `billingWebhookRoute(gateway)` | Route `POST /billing/webhook` — signature verified by the driver, idempotent processing |
350
+
351
+ ## Common errors and solutions (FAQ)
352
+
353
+ **HTTP 402 `BILLING_SUBSCRIPTION_REQUIRED` on a guarded route** — The request's tenant doesn't have an active subscription (or there's no tenant in the context). Check the tenancy plugin and whether the customer has subscribed.
354
+
355
+ **Unexpected `QuotaExceededError`** — The plan's limit ran out for the current period. Remember: `meter(n)` resets by calendar month; a plain `number` is a lifetime balance that never resets.
356
+
357
+ **The Stripe webhook always returns 400 `BILLING_WEBHOOK_INVALID`** — It's almost always the raw body: Stripe signs the exact bytes, and any re-serialization breaks the HMAC. Configure raw body on the webhook route and confirm the `webhookSecret`. Also check clock skew (5-minute tolerance).
358
+
359
+ **After Checkout, the subscription stays `incomplete` forever** — The `payment.succeeded` webhook never arrived. Confirm the `/billing/webhook` endpoint is reachable by Stripe and that the `invoice.paid`/`invoice.payment_succeeded` events are enabled on the Stripe endpoint.
360
+
361
+ **`GatewayUnsupportedError` when calling `checkout`/`portal`** — You didn't configure `gateway`, or the driver doesn't implement that capability. Pass a `StripeBillingGateway` (or `FakeBillingGateway` in dev).
362
+
363
+ **Paid trials never move to `active`** — Gateway-backed trials are converted by the gateway's webhook, not by `expireTrials()`. Without a gateway, you really do need to run `expireTrials()` in a scheduler (and a local paid-plan trial ends up in `past_due`, because there's no way to charge).
364
+
365
+ **Quotas exceeded under concurrent traffic in production** — You're running `MemoryUsageStore` across multiple processes: each process has its own counter. Use `RedisUsageStore`, whose Lua script guarantees an atomic check-and-increment across instances.
366
+
367
+ ## How it connects to other modules
368
+
369
+ - **@basaltkit/core** — `createApp`, the container (token `SUBSCRIPTIONS`), the request context (where the tenant/billable comes from), and `HookBus` (`billing:*` hooks).
370
+ - **@basaltkit/fastify** — the routes (`billingRoutes`, `billingWebhookRoute`) and the `meta.subscribed`/`meta.feature` guards rest on the HTTP plugin.
371
+ - **@basaltkit/mailer** and **@basaltkit/notifications** — subscribe to the `billing:*` hooks to send emails/notifications ("your trial has expired", "payment failed").
372
+ - **@basaltkit/webhooks** — the opposite direction: this module *receives* webhooks from the gateway; `@basaltkit/webhooks` *sends* webhooks to your customers (you can forward `billing:*` events there).
373
+ - **@basaltkit/scheduler** — the natural place to run `expireTrials()` periodically.
374
+ - **@basaltkit/queue** — asynchronous processing of reactions to billing hooks.