@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.
Files changed (51) hide show
  1. package/README.md +3 -1
  2. package/dist/billing/billable.d.ts +83 -0
  3. package/dist/billing/billable.js +177 -0
  4. package/dist/billing/billing.config.stub +33 -0
  5. package/dist/billing/builder.d.ts +54 -0
  6. package/dist/billing/builder.js +104 -0
  7. package/dist/billing/config.d.ts +43 -0
  8. package/dist/billing/config.js +35 -0
  9. package/dist/billing/crypto.d.ts +11 -0
  10. package/dist/billing/crypto.js +27 -0
  11. package/dist/billing/drivers/fake.d.ts +58 -0
  12. package/dist/billing/drivers/fake.js +190 -0
  13. package/dist/billing/drivers/index.d.ts +11 -0
  14. package/dist/billing/drivers/index.js +22 -0
  15. package/dist/billing/drivers/paddle.d.ts +39 -0
  16. package/dist/billing/drivers/paddle.js +197 -0
  17. package/dist/billing/drivers/stripe.d.ts +33 -0
  18. package/dist/billing/drivers/stripe.js +278 -0
  19. package/dist/billing/events.d.ts +25 -0
  20. package/dist/billing/events.js +7 -0
  21. package/dist/billing/gateway.d.ts +170 -0
  22. package/dist/billing/gateway.js +24 -0
  23. package/dist/billing/index.d.ts +28 -0
  24. package/dist/billing/index.js +19 -0
  25. package/dist/billing/manager.d.ts +34 -0
  26. package/dist/billing/manager.js +61 -0
  27. package/dist/billing/migration.d.ts +13 -0
  28. package/dist/billing/migration.js +68 -0
  29. package/dist/billing/provider.d.ts +20 -0
  30. package/dist/billing/provider.js +42 -0
  31. package/dist/billing/routes.d.ts +11 -0
  32. package/dist/billing/routes.js +21 -0
  33. package/dist/billing/subscription-item.d.ts +18 -0
  34. package/dist/billing/subscription-item.js +11 -0
  35. package/dist/billing/subscription.d.ts +85 -0
  36. package/dist/billing/subscription.js +157 -0
  37. package/dist/billing/webhooks.d.ts +26 -0
  38. package/dist/billing/webhooks.js +75 -0
  39. package/dist/core/binding.d.ts +91 -0
  40. package/dist/core/binding.js +159 -0
  41. package/dist/core/http/kernel.js +10 -1
  42. package/dist/core/index.d.ts +2 -0
  43. package/dist/core/index.js +1 -0
  44. package/docs/ai-manifest.json +48 -1
  45. package/docs/billing.md +242 -0
  46. package/docs/examples/binding.ts +83 -0
  47. package/docs/packages.md +3 -1
  48. package/docs/routing.md +84 -0
  49. package/llms-full.txt +336 -1
  50. package/llms.txt +1 -0
  51. 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.78.2",
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",