@shaferllc/keel 0.78.2 → 0.80.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/README.md +3 -1
- package/dist/billing/billable.d.ts +83 -0
- package/dist/billing/billable.js +177 -0
- package/dist/billing/billing.config.stub +33 -0
- package/dist/billing/builder.d.ts +54 -0
- package/dist/billing/builder.js +104 -0
- package/dist/billing/config.d.ts +43 -0
- package/dist/billing/config.js +35 -0
- package/dist/billing/crypto.d.ts +11 -0
- package/dist/billing/crypto.js +27 -0
- package/dist/billing/drivers/fake.d.ts +58 -0
- package/dist/billing/drivers/fake.js +190 -0
- package/dist/billing/drivers/index.d.ts +11 -0
- package/dist/billing/drivers/index.js +22 -0
- package/dist/billing/drivers/paddle.d.ts +39 -0
- package/dist/billing/drivers/paddle.js +197 -0
- package/dist/billing/drivers/stripe.d.ts +33 -0
- package/dist/billing/drivers/stripe.js +278 -0
- package/dist/billing/events.d.ts +25 -0
- package/dist/billing/events.js +7 -0
- package/dist/billing/gateway.d.ts +170 -0
- package/dist/billing/gateway.js +24 -0
- package/dist/billing/index.d.ts +28 -0
- package/dist/billing/index.js +19 -0
- package/dist/billing/manager.d.ts +34 -0
- package/dist/billing/manager.js +61 -0
- package/dist/billing/migration.d.ts +13 -0
- package/dist/billing/migration.js +68 -0
- package/dist/billing/provider.d.ts +20 -0
- package/dist/billing/provider.js +42 -0
- package/dist/billing/routes.d.ts +11 -0
- package/dist/billing/routes.js +21 -0
- package/dist/billing/subscription-item.d.ts +18 -0
- package/dist/billing/subscription-item.js +11 -0
- package/dist/billing/subscription.d.ts +85 -0
- package/dist/billing/subscription.js +157 -0
- package/dist/billing/webhooks.d.ts +26 -0
- package/dist/billing/webhooks.js +75 -0
- package/dist/core/binding.d.ts +91 -0
- package/dist/core/binding.js +159 -0
- package/dist/core/http/kernel.js +10 -1
- package/dist/core/index.d.ts +2 -0
- package/dist/core/index.js +1 -0
- package/docs/ai-manifest.json +48 -1
- package/docs/billing.md +242 -0
- package/docs/examples/binding.ts +83 -0
- package/docs/packages.md +3 -1
- package/docs/routing.md +84 -0
- package/llms-full.txt +336 -1
- package/llms.txt +1 -0
- package/package.json +6 -2
package/docs/routing.md
CHANGED
|
@@ -254,6 +254,90 @@ router.any("/webhook", [HookController, "handle"]); // every verb
|
|
|
254
254
|
router.route(["GET", "POST"], "/search", handler); // a specific set
|
|
255
255
|
```
|
|
256
256
|
|
|
257
|
+
## Route model binding
|
|
258
|
+
|
|
259
|
+
A `:post` in the path can arrive as a **`Post`**, not a string:
|
|
260
|
+
|
|
261
|
+
```ts
|
|
262
|
+
import { bindModel, boundModel } from "@shaferllc/keel/core";
|
|
263
|
+
|
|
264
|
+
bindModel("post", Post); // once, in a provider
|
|
265
|
+
|
|
266
|
+
router.get("/posts/:post", (c) => {
|
|
267
|
+
const post = boundModel(Post); // already fetched. Not a string, not null.
|
|
268
|
+
return c.json(post);
|
|
269
|
+
});
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
The row is looked up **before your handler runs**, and a miss is a 404 there and
|
|
273
|
+
then. That's the whole value: the handler never sees a `null`, so it never has to
|
|
274
|
+
remember to check for one — **"forgot the 404" stops being a bug you can write.**
|
|
275
|
+
|
|
276
|
+
Compare what you'd otherwise type in every handler:
|
|
277
|
+
|
|
278
|
+
```ts
|
|
279
|
+
router.get("/posts/:id", async (c) => {
|
|
280
|
+
const post = await Post.find(c.req.param("id"));
|
|
281
|
+
if (!post) throw new NotFoundException(); // ...every time, forever
|
|
282
|
+
return c.json(post);
|
|
283
|
+
});
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
### By another column
|
|
287
|
+
|
|
288
|
+
When the URL isn't the id:
|
|
289
|
+
|
|
290
|
+
```ts
|
|
291
|
+
bindModel("post", Post, { key: "slug" }); // /posts/hello-world
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
### `scope` — this is security, not a filter
|
|
295
|
+
|
|
296
|
+
```ts
|
|
297
|
+
bindModel("post", Post, {
|
|
298
|
+
scope: (query, c) => query.where("authorId", currentUserId(c)),
|
|
299
|
+
});
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
A row outside the scope is a **404**, not a 403 and not a filtered list — so it
|
|
303
|
+
cannot be reached by *guessing its id*. That's the difference between row-level
|
|
304
|
+
security and decoration. `/posts/2` doesn't 403 (which would confirm the row
|
|
305
|
+
exists); it simply isn't there.
|
|
306
|
+
|
|
307
|
+
The scope gets the request, so it can depend on who's asking.
|
|
308
|
+
|
|
309
|
+
### Middleware sees the model
|
|
310
|
+
|
|
311
|
+
Binding runs **before** route middleware, so a policy can read the model rather
|
|
312
|
+
than re-fetching it:
|
|
313
|
+
|
|
314
|
+
```ts
|
|
315
|
+
const mustOwn: MiddlewareHandler = async (c, next) => {
|
|
316
|
+
if (boundModel(Post).authorId !== currentUserId(c)) throw new ForbiddenException();
|
|
317
|
+
await next();
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
router.get("/posts/:post/edit", edit).middleware(mustOwn);
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
### Anything that isn't a model
|
|
324
|
+
|
|
325
|
+
```ts
|
|
326
|
+
bindRoute("tenant", (slug) => tenants.get(slug)); // undefined ⇒ 404
|
|
327
|
+
|
|
328
|
+
router.get("/t/:tenant", () => {
|
|
329
|
+
const tenant = boundValue<Tenant>("tenant");
|
|
330
|
+
});
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
### Notes
|
|
334
|
+
|
|
335
|
+
- An **unbound** param is untouched — still just a string via `c.req.param()`.
|
|
336
|
+
- Two params bound to the same model? Say which: `boundModel(Post, "original")`.
|
|
337
|
+
Guessing would be worse than asking.
|
|
338
|
+
- `missing()` substitutes a value instead of 404ing, if you'd rather.
|
|
339
|
+
- Only routes with parameters pay for any of this.
|
|
340
|
+
|
|
257
341
|
## Inspecting routes
|
|
258
342
|
|
|
259
343
|
```bash
|
package/llms-full.txt
CHANGED
|
@@ -2138,6 +2138,90 @@ router.any("/webhook", [HookController, "handle"]); // every verb
|
|
|
2138
2138
|
router.route(["GET", "POST"], "/search", handler); // a specific set
|
|
2139
2139
|
```
|
|
2140
2140
|
|
|
2141
|
+
## Route model binding
|
|
2142
|
+
|
|
2143
|
+
A `:post` in the path can arrive as a **`Post`**, not a string:
|
|
2144
|
+
|
|
2145
|
+
```ts
|
|
2146
|
+
import { bindModel, boundModel } from "@shaferllc/keel/core";
|
|
2147
|
+
|
|
2148
|
+
bindModel("post", Post); // once, in a provider
|
|
2149
|
+
|
|
2150
|
+
router.get("/posts/:post", (c) => {
|
|
2151
|
+
const post = boundModel(Post); // already fetched. Not a string, not null.
|
|
2152
|
+
return c.json(post);
|
|
2153
|
+
});
|
|
2154
|
+
```
|
|
2155
|
+
|
|
2156
|
+
The row is looked up **before your handler runs**, and a miss is a 404 there and
|
|
2157
|
+
then. That's the whole value: the handler never sees a `null`, so it never has to
|
|
2158
|
+
remember to check for one — **"forgot the 404" stops being a bug you can write.**
|
|
2159
|
+
|
|
2160
|
+
Compare what you'd otherwise type in every handler:
|
|
2161
|
+
|
|
2162
|
+
```ts
|
|
2163
|
+
router.get("/posts/:id", async (c) => {
|
|
2164
|
+
const post = await Post.find(c.req.param("id"));
|
|
2165
|
+
if (!post) throw new NotFoundException(); // ...every time, forever
|
|
2166
|
+
return c.json(post);
|
|
2167
|
+
});
|
|
2168
|
+
```
|
|
2169
|
+
|
|
2170
|
+
### By another column
|
|
2171
|
+
|
|
2172
|
+
When the URL isn't the id:
|
|
2173
|
+
|
|
2174
|
+
```ts
|
|
2175
|
+
bindModel("post", Post, { key: "slug" }); // /posts/hello-world
|
|
2176
|
+
```
|
|
2177
|
+
|
|
2178
|
+
### `scope` — this is security, not a filter
|
|
2179
|
+
|
|
2180
|
+
```ts
|
|
2181
|
+
bindModel("post", Post, {
|
|
2182
|
+
scope: (query, c) => query.where("authorId", currentUserId(c)),
|
|
2183
|
+
});
|
|
2184
|
+
```
|
|
2185
|
+
|
|
2186
|
+
A row outside the scope is a **404**, not a 403 and not a filtered list — so it
|
|
2187
|
+
cannot be reached by *guessing its id*. That's the difference between row-level
|
|
2188
|
+
security and decoration. `/posts/2` doesn't 403 (which would confirm the row
|
|
2189
|
+
exists); it simply isn't there.
|
|
2190
|
+
|
|
2191
|
+
The scope gets the request, so it can depend on who's asking.
|
|
2192
|
+
|
|
2193
|
+
### Middleware sees the model
|
|
2194
|
+
|
|
2195
|
+
Binding runs **before** route middleware, so a policy can read the model rather
|
|
2196
|
+
than re-fetching it:
|
|
2197
|
+
|
|
2198
|
+
```ts
|
|
2199
|
+
const mustOwn: MiddlewareHandler = async (c, next) => {
|
|
2200
|
+
if (boundModel(Post).authorId !== currentUserId(c)) throw new ForbiddenException();
|
|
2201
|
+
await next();
|
|
2202
|
+
};
|
|
2203
|
+
|
|
2204
|
+
router.get("/posts/:post/edit", edit).middleware(mustOwn);
|
|
2205
|
+
```
|
|
2206
|
+
|
|
2207
|
+
### Anything that isn't a model
|
|
2208
|
+
|
|
2209
|
+
```ts
|
|
2210
|
+
bindRoute("tenant", (slug) => tenants.get(slug)); // undefined ⇒ 404
|
|
2211
|
+
|
|
2212
|
+
router.get("/t/:tenant", () => {
|
|
2213
|
+
const tenant = boundValue<Tenant>("tenant");
|
|
2214
|
+
});
|
|
2215
|
+
```
|
|
2216
|
+
|
|
2217
|
+
### Notes
|
|
2218
|
+
|
|
2219
|
+
- An **unbound** param is untouched — still just a string via `c.req.param()`.
|
|
2220
|
+
- Two params bound to the same model? Say which: `boundModel(Post, "original")`.
|
|
2221
|
+
Guessing would be worse than asking.
|
|
2222
|
+
- `missing()` substitutes a value instead of 404ing, if you'd rather.
|
|
2223
|
+
- Only routes with parameters pay for any of this.
|
|
2224
|
+
|
|
2141
2225
|
## Inspecting routes
|
|
2142
2226
|
|
|
2143
2227
|
```bash
|
|
@@ -5248,6 +5332,255 @@ all gates/policies/hooks (a test helper).
|
|
|
5248
5332
|
|
|
5249
5333
|
|
|
5250
5334
|
|
|
5335
|
+
---
|
|
5336
|
+
|
|
5337
|
+
<!-- source: docs/billing.md -->
|
|
5338
|
+
|
|
5339
|
+
# Billing
|
|
5340
|
+
|
|
5341
|
+
Keel Billing is a subscription-billing layer — a port of [Laravel
|
|
5342
|
+
Cashier](https://laravel.com/docs/13.x/billing) — for charging customers,
|
|
5343
|
+
managing subscriptions, and reconciling gateway state through webhooks. It ships
|
|
5344
|
+
as a Keel [package](./packages.md) and supports two gateways behind one API:
|
|
5345
|
+
**Stripe** and **Paddle**.
|
|
5346
|
+
|
|
5347
|
+
It attaches to a model with a mixin. Your `User` becomes billable, gains a
|
|
5348
|
+
gateway customer, and can create subscriptions and charges:
|
|
5349
|
+
|
|
5350
|
+
```ts
|
|
5351
|
+
import { Model } from "@shaferllc/keel/core";
|
|
5352
|
+
import { Billable } from "@shaferllc/keel/billing";
|
|
5353
|
+
|
|
5354
|
+
export class User extends Billable(Model) {
|
|
5355
|
+
static table = "users";
|
|
5356
|
+
declare email: string;
|
|
5357
|
+
}
|
|
5358
|
+
```
|
|
5359
|
+
|
|
5360
|
+
## Install
|
|
5361
|
+
|
|
5362
|
+
```ts
|
|
5363
|
+
// bootstrap/providers.ts
|
|
5364
|
+
import { BillingServiceProvider } from "@shaferllc/keel/billing";
|
|
5365
|
+
|
|
5366
|
+
export const providers = [AppServiceProvider, BillingServiceProvider];
|
|
5367
|
+
```
|
|
5368
|
+
|
|
5369
|
+
Publish the config and create the tables:
|
|
5370
|
+
|
|
5371
|
+
```bash
|
|
5372
|
+
keel vendor:publish --tag billing-config # writes config/billing.ts
|
|
5373
|
+
keel migrate # creates subscriptions + subscription_items,
|
|
5374
|
+
# and adds billing columns to users
|
|
5375
|
+
```
|
|
5376
|
+
|
|
5377
|
+
Set your keys in `.env`:
|
|
5378
|
+
|
|
5379
|
+
```ini
|
|
5380
|
+
BILLING_GATEWAY=stripe # or "paddle"
|
|
5381
|
+
STRIPE_SECRET_KEY=sk_...
|
|
5382
|
+
STRIPE_WEBHOOK_SECRET=whsec_...
|
|
5383
|
+
# Paddle:
|
|
5384
|
+
PADDLE_API_KEY=...
|
|
5385
|
+
PADDLE_WEBHOOK_SECRET=...
|
|
5386
|
+
PADDLE_CLIENT_TOKEN=...
|
|
5387
|
+
PADDLE_SANDBOX=true
|
|
5388
|
+
```
|
|
5389
|
+
|
|
5390
|
+
## One API, two gateways
|
|
5391
|
+
|
|
5392
|
+
Everything you call goes through a gateway-neutral interface, so switching from
|
|
5393
|
+
Stripe to Paddle is a config change. The active gateway comes from
|
|
5394
|
+
`config("billing.default")`; a billable can also carry its own in
|
|
5395
|
+
`billing_gateway`. Money is always an integer in the smallest currency unit
|
|
5396
|
+
(cents). See [Gateway differences](#gateway-differences) for where Paddle's
|
|
5397
|
+
merchant-of-record model diverges.
|
|
5398
|
+
|
|
5399
|
+
## Customers
|
|
5400
|
+
|
|
5401
|
+
A gateway customer is created lazily the first time you need one, but you can
|
|
5402
|
+
create it up front:
|
|
5403
|
+
|
|
5404
|
+
```ts
|
|
5405
|
+
await user.createAsCustomer(); // creates the customer, stores its id
|
|
5406
|
+
user.hasBillingId(); // true
|
|
5407
|
+
await user.getCustomerId(); // the id (creates if missing)
|
|
5408
|
+
```
|
|
5409
|
+
|
|
5410
|
+
Override what gets synced by defining `billingName()` / `billingEmail()` on your
|
|
5411
|
+
model. By default they read `name` / `email`.
|
|
5412
|
+
|
|
5413
|
+
## Subscriptions
|
|
5414
|
+
|
|
5415
|
+
Build a subscription with the fluent builder:
|
|
5416
|
+
|
|
5417
|
+
```ts
|
|
5418
|
+
await user
|
|
5419
|
+
.newSubscription("default", "price_pro")
|
|
5420
|
+
.trialDays(14)
|
|
5421
|
+
.quantity(3)
|
|
5422
|
+
.create(paymentMethodId); // paymentMethodId optional if a default is on file
|
|
5423
|
+
```
|
|
5424
|
+
|
|
5425
|
+
Multiple prices (add-ons) are an array; `withMetadata`, `trialUntil`, and
|
|
5426
|
+
`skipTrial` are also available. To send the customer to a hosted checkout
|
|
5427
|
+
instead of charging now, swap `.create()` for `.checkout()`:
|
|
5428
|
+
|
|
5429
|
+
```ts
|
|
5430
|
+
const session = await user
|
|
5431
|
+
.newSubscription("default", "price_pro")
|
|
5432
|
+
.checkout({ successUrl: "...", cancelUrl: "..." });
|
|
5433
|
+
// Stripe: redirect to session.url. Paddle: open the overlay with session.clientToken.
|
|
5434
|
+
```
|
|
5435
|
+
|
|
5436
|
+
### Status
|
|
5437
|
+
|
|
5438
|
+
Status questions are answered from local columns — no gateway round-trip:
|
|
5439
|
+
|
|
5440
|
+
```ts
|
|
5441
|
+
await user.subscribed(); // valid (active | trial | grace)
|
|
5442
|
+
await user.subscribedToPrice("price_pro");
|
|
5443
|
+
await user.onTrial();
|
|
5444
|
+
const sub = await user.subscription(); // the "default" subscription, or null
|
|
5445
|
+
|
|
5446
|
+
sub.active(); sub.onTrial(); sub.recurring();
|
|
5447
|
+
sub.canceled(); sub.onGracePeriod(); sub.ended();
|
|
5448
|
+
sub.paused(); sub.valid(); sub.hasIncompletePayment();
|
|
5449
|
+
```
|
|
5450
|
+
|
|
5451
|
+
### Changing a subscription
|
|
5452
|
+
|
|
5453
|
+
```ts
|
|
5454
|
+
await sub.swap("price_enterprise"); // change price(s)
|
|
5455
|
+
await sub.updateQuantity(10);
|
|
5456
|
+
await sub.incrementQuantity(2);
|
|
5457
|
+
await sub.decrementQuantity();
|
|
5458
|
+
```
|
|
5459
|
+
|
|
5460
|
+
Each of these calls the gateway and syncs the result back into the local row.
|
|
5461
|
+
|
|
5462
|
+
### Cancelling
|
|
5463
|
+
|
|
5464
|
+
```ts
|
|
5465
|
+
await sub.cancel(); // at period end — access continues through the grace period
|
|
5466
|
+
await sub.onGracePeriod();// true
|
|
5467
|
+
await sub.resume(); // revive a subscription still in its grace period
|
|
5468
|
+
await sub.cancelNow(); // immediately; sub.ended() becomes true
|
|
5469
|
+
```
|
|
5470
|
+
|
|
5471
|
+
### Trials
|
|
5472
|
+
|
|
5473
|
+
```ts
|
|
5474
|
+
await sub.endTrial();
|
|
5475
|
+
await sub.extendTrial(new Date("2026-01-01"));
|
|
5476
|
+
user.onGenericTrial(); // a trial_ends_at on the user, before any subscription
|
|
5477
|
+
```
|
|
5478
|
+
|
|
5479
|
+
## Single charges
|
|
5480
|
+
|
|
5481
|
+
```ts
|
|
5482
|
+
const charge = await user.charge(2000, { paymentMethod: "pm_1", description: "Credits" });
|
|
5483
|
+
await user.refund(charge.id); // full refund
|
|
5484
|
+
await user.refund(charge.id, 500); // partial
|
|
5485
|
+
|
|
5486
|
+
const session = await user.checkout("price_onetime", { successUrl, cancelUrl });
|
|
5487
|
+
```
|
|
5488
|
+
|
|
5489
|
+
## Payment methods (Stripe)
|
|
5490
|
+
|
|
5491
|
+
Collect a card with a SetupIntent, then create the subscription with the
|
|
5492
|
+
resulting payment method:
|
|
5493
|
+
|
|
5494
|
+
```ts
|
|
5495
|
+
const intent = await user.createSetupIntent(); // return intent.clientSecret to the front end
|
|
5496
|
+
const methods = await user.paymentMethods();
|
|
5497
|
+
```
|
|
5498
|
+
|
|
5499
|
+
These are Stripe-only capabilities; calling them on the Paddle gateway throws a
|
|
5500
|
+
`BillingError` (Paddle collects cards in its own hosted checkout).
|
|
5501
|
+
|
|
5502
|
+
## Invoices
|
|
5503
|
+
|
|
5504
|
+
```ts
|
|
5505
|
+
const invoices = await user.invoices(); // GatewayInvoice[] — total, currency, status, url
|
|
5506
|
+
```
|
|
5507
|
+
|
|
5508
|
+
## Webhooks
|
|
5509
|
+
|
|
5510
|
+
The package mounts one webhook endpoint per gateway at
|
|
5511
|
+
`config("billing.webhook.path")`:
|
|
5512
|
+
|
|
5513
|
+
```
|
|
5514
|
+
POST /billing/webhook/stripe
|
|
5515
|
+
POST /billing/webhook/paddle
|
|
5516
|
+
```
|
|
5517
|
+
|
|
5518
|
+
Point your gateway dashboard at the matching URL. Each request is verified
|
|
5519
|
+
against the gateway's signing secret (HMAC-SHA256 over the raw body), the local
|
|
5520
|
+
subscription is synced, and typed events fire:
|
|
5521
|
+
|
|
5522
|
+
```ts
|
|
5523
|
+
import { listen } from "@shaferllc/keel/core";
|
|
5524
|
+
|
|
5525
|
+
listen("billing.subscription.updated", (e) => {
|
|
5526
|
+
// e.gateway, e.subscriptionId, e.providerId, e.status
|
|
5527
|
+
});
|
|
5528
|
+
listen("billing.webhook.received", (e) => { /* e.gateway, e.type, e.id */ });
|
|
5529
|
+
```
|
|
5530
|
+
|
|
5531
|
+
Events: `billing.webhook.received`, `billing.subscription.created` / `.updated`
|
|
5532
|
+
/ `.deleted`.
|
|
5533
|
+
|
|
5534
|
+
An update to a subscription already in your database is always synced. A brand
|
|
5535
|
+
new subscription born from a Paddle checkout has no local row yet — register a
|
|
5536
|
+
resolver so the handler can create it:
|
|
5537
|
+
|
|
5538
|
+
```ts
|
|
5539
|
+
import { resolveBillableUsing } from "@shaferllc/keel/billing";
|
|
5540
|
+
|
|
5541
|
+
resolveBillableUsing(async (customerId) => {
|
|
5542
|
+
const user = (await User.query().where("billing_customer_id", customerId).first());
|
|
5543
|
+
return user ? { id: user.id, type: "User" } : null;
|
|
5544
|
+
});
|
|
5545
|
+
```
|
|
5546
|
+
|
|
5547
|
+
## Gateway differences
|
|
5548
|
+
|
|
5549
|
+
| Concern | Stripe | Paddle |
|
|
5550
|
+
|---------|--------|--------|
|
|
5551
|
+
| Create a subscription server-side | `create(pmId)` | Not supported — use `checkout()`; the webhook creates the local row |
|
|
5552
|
+
| One-off `charge()` | Confirms a PaymentIntent | Not supported — use `checkout({ mode })` / transactions |
|
|
5553
|
+
| SetupIntent / `paymentMethods()` | Supported | Throws `BillingError` (hosted checkout) |
|
|
5554
|
+
| Checkout handle | `session.url` (redirect) | `session.clientToken` (overlay/inline) |
|
|
5555
|
+
| Webhook signature | `Stripe-Signature: t=…,v1=…` | `Paddle-Signature: ts=…;h1=…` |
|
|
5556
|
+
|
|
5557
|
+
## Schema
|
|
5558
|
+
|
|
5559
|
+
The migration is gateway-neutral: `subscriptions` (with `gateway`,
|
|
5560
|
+
`provider_id`, `provider_status`, `provider_price`, trial/grace timestamps),
|
|
5561
|
+
`subscription_items`, and columns on `users` (`billing_gateway`,
|
|
5562
|
+
`billing_customer_id`, `pm_type`, `pm_last_four`, `trial_ends_at`). Cashier
|
|
5563
|
+
targets the standard `users` billable table.
|
|
5564
|
+
|
|
5565
|
+
## Testing
|
|
5566
|
+
|
|
5567
|
+
The package ships a `FakeGateway` — an in-memory gateway that records every call
|
|
5568
|
+
— so you can drive billing without touching a network:
|
|
5569
|
+
|
|
5570
|
+
```ts
|
|
5571
|
+
import { BillingManager, setBilling, FakeGateway } from "@shaferllc/keel/billing";
|
|
5572
|
+
|
|
5573
|
+
const fake = new FakeGateway();
|
|
5574
|
+
const manager = new BillingManager(config); // config.default = "fake"
|
|
5575
|
+
manager.register("fake", () => fake);
|
|
5576
|
+
setBilling(manager);
|
|
5577
|
+
|
|
5578
|
+
await user.newSubscription("default", "price_pro").create();
|
|
5579
|
+
fake.calls.filter((c) => c.method === "createSubscription"); // assert what was asked
|
|
5580
|
+
```
|
|
5581
|
+
|
|
5582
|
+
|
|
5583
|
+
|
|
5251
5584
|
---
|
|
5252
5585
|
|
|
5253
5586
|
<!-- source: docs/broadcasting.md -->
|
|
@@ -14245,7 +14578,9 @@ adds the conventions a *shippable* package needs so it can carry its own schema
|
|
|
14245
14578
|
and assets instead of asking the app to wire them by hand.
|
|
14246
14579
|
|
|
14247
14580
|
[Keel Watch](./watch.md) — the debug dashboard — is a first-party package and the
|
|
14248
|
-
reference implementation of everything below.
|
|
14581
|
+
reference implementation of everything below. [Billing](./billing.md) (a Cashier
|
|
14582
|
+
port for Stripe and Paddle) is another, and shows a package contributing models,
|
|
14583
|
+
a schema migration, gateway drivers, and verified webhook routes.
|
|
14249
14584
|
|
|
14250
14585
|
## The shape of a package
|
|
14251
14586
|
|
package/llms.txt
CHANGED
|
@@ -14,6 +14,7 @@ Userland imports everything from `@shaferllc/keel/core`.
|
|
|
14
14
|
- [Architecture](https://github.com/shaferllc/keel/blob/main/docs/architecture.md): Keel is small on purpose. This page maps the pieces and traces a request from socket to response. Nothing here is magic — every layer is a short, readable file in src/core/, and this guide is mostly a reading order for it.
|
|
15
15
|
- [Authentication](https://github.com/shaferllc/keel/blob/main/docs/authentication.md): Session-based auth built on the pieces you already have: sessions hold the login, hashing checks passwords.
|
|
16
16
|
- [Authorization](https://github.com/shaferllc/keel/blob/main/docs/authorization.md): Where authentication answers who you are, authorization answers what you're allowed to do.
|
|
17
|
+
- [Billing](https://github.com/shaferllc/keel/blob/main/docs/billing.md): Keel Billing is a subscription-billing layer — a port of Laravel Cashier — for charging customers, managing subscriptions, and reconciling gateway state through webhooks.
|
|
17
18
|
- [Broadcasting](https://github.com/shaferllc/keel/blob/main/docs/broadcasting.md): Push events to clients in real time over named channels.
|
|
18
19
|
- [Service Broker](https://github.com/shaferllc/keel/blob/main/docs/broker.md): Structure an application as services that talk to each other by name instead of by import.
|
|
19
20
|
- [Cache](https://github.com/shaferllc/keel/blob/main/docs/cache.md): A small cache with TTLs and the remember pattern.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shaferllc/keel",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.80.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "The house framework for Node.js — a service container, providers, routing, JSX views, and a code-generating console.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -61,6 +61,10 @@
|
|
|
61
61
|
"types": "./dist/api/index.d.ts",
|
|
62
62
|
"import": "./dist/api/index.js"
|
|
63
63
|
},
|
|
64
|
+
"./billing": {
|
|
65
|
+
"types": "./dist/billing/index.d.ts",
|
|
66
|
+
"import": "./dist/billing/index.js"
|
|
67
|
+
},
|
|
64
68
|
"./cli": {
|
|
65
69
|
"types": "./dist/core/cli/index.d.ts",
|
|
66
70
|
"import": "./dist/core/cli/index.js"
|
|
@@ -89,7 +93,7 @@
|
|
|
89
93
|
"test:coverage": "node --import tsx --test --experimental-test-coverage --test-coverage-exclude='tests/**' tests/*.test.ts",
|
|
90
94
|
"build:ai": "tsx scripts/build-ai.ts",
|
|
91
95
|
"build:watch-ui": "vite build --config src/watch/ui/vite.config.ts",
|
|
92
|
-
"build:watch-copy": "mkdir -p dist/watch/ui && cp -R src/watch/ui/dist dist/watch/ui/dist && cp src/watch/watch.config.stub dist/watch/ && cp src/openapi/openapi.config.stub dist/openapi/ && cp src/api/api.config.stub dist/api/",
|
|
96
|
+
"build:watch-copy": "mkdir -p dist/watch/ui && cp -R src/watch/ui/dist dist/watch/ui/dist && cp src/watch/watch.config.stub dist/watch/ && cp src/openapi/openapi.config.stub dist/openapi/ && cp src/api/api.config.stub dist/api/ && cp src/billing/billing.config.stub dist/billing/",
|
|
93
97
|
"build": "npm run build:ai && npm run build:watch-ui && rm -rf dist && tsc -p tsconfig.build.json && npm run build:watch-copy",
|
|
94
98
|
"prepare": "npm run build",
|
|
95
99
|
"typecheck:tests": "tsc --noEmit -p tsconfig.tests.json",
|