@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
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Route model binding — a `:user` in the path arrives as a `User`, not a string.
3
+ *
4
+ * bindModel("user", User);
5
+ *
6
+ * router.get("/users/:user", (c) => {
7
+ * const user = boundModel(User); // already fetched. Not a string, not null.
8
+ * return c.json(user);
9
+ * });
10
+ *
11
+ * The row is looked up **before the handler runs**, and a miss is a 404 there and
12
+ * then. That's the whole value: the handler never sees a null, so it never has to
13
+ * remember to check for one — and "forgot the 404" stops being a bug you can write.
14
+ *
15
+ * Bind by another column when the URL isn't the id:
16
+ *
17
+ * bindModel("post", Post, { key: "slug" }); // /posts/hello-world
18
+ *
19
+ * Constrain what's reachable (row-level security — a row outside the scope 404s,
20
+ * so it can't be reached by guessing an id):
21
+ *
22
+ * bindModel("post", Post, { scope: (q, c) => q.where("authorId", currentUserId(c)) });
23
+ *
24
+ * Or resolve it yourself, for anything that isn't a model:
25
+ *
26
+ * bindRoute("tenant", (slug) => tenants.get(slug));
27
+ */
28
+ import { NotFoundException } from "./exceptions.js";
29
+ import { ctx } from "./request.js";
30
+ const bindings = new Map();
31
+ /**
32
+ * Bind a route parameter to a model. `:user` becomes a `User`; a row that doesn't
33
+ * exist (or is outside `scope`) is a 404 before your handler runs.
34
+ */
35
+ export function bindModel(param, model, options = {}) {
36
+ const column = options.key ?? model.primaryKey;
37
+ bindings.set(param, {
38
+ model: model,
39
+ resolve: async (value, c) => {
40
+ let query = model.query().where(column, value);
41
+ if (options.scope)
42
+ query = options.scope(query, c) ?? query;
43
+ const row = await query.first();
44
+ if (row)
45
+ return new model(row);
46
+ // A miss is a 404 *here*, not a null the handler has to remember to check.
47
+ if (options.missing)
48
+ return options.missing(value, c);
49
+ throw new NotFoundException(`No ${model.name} for "${value}".`);
50
+ },
51
+ });
52
+ }
53
+ /**
54
+ * Bind a route parameter to anything at all — a tenant from a map, a feature flag,
55
+ * a value from an API. Returning `undefined` or `null` is a 404.
56
+ */
57
+ export function bindRoute(param, resolve) {
58
+ bindings.set(param, {
59
+ resolve: async (value, c) => {
60
+ const resolved = await resolve(value, c);
61
+ if (resolved === undefined || resolved === null) {
62
+ throw new NotFoundException(`No match for "${value}".`);
63
+ }
64
+ return resolved;
65
+ },
66
+ });
67
+ }
68
+ /** Whether a parameter has a binding registered. */
69
+ export function hasBinding(param) {
70
+ return bindings.has(param);
71
+ }
72
+ /** Drop every binding — a clean slate between tests. */
73
+ export function clearBindings() {
74
+ bindings.clear();
75
+ }
76
+ /** The parameter names in a route pattern: `/users/:user/posts/:post` → both. */
77
+ export function paramNames(pattern) {
78
+ return [...pattern.matchAll(/:([A-Za-z_][A-Za-z0-9_]*)/g)].map((m) => m[1]);
79
+ }
80
+ /**
81
+ * Resolve this route's bound parameters and stash them on the request. Installed
82
+ * by the HTTP kernel for any route whose pattern has a bound parameter — so the
83
+ * work is skipped entirely for routes that don't.
84
+ *
85
+ * @internal
86
+ */
87
+ export function resolveBindings(params, c) {
88
+ const present = params.filter((p) => bindings.has(p));
89
+ if (!present.length)
90
+ return;
91
+ return (async () => {
92
+ const resolved = {};
93
+ // In series, on purpose: a scoped binding usually depends on one resolved
94
+ // before it (`/users/:user/posts/:post`, where the post is scoped to the user).
95
+ for (const param of present) {
96
+ const value = c.req.param(param);
97
+ if (value === undefined)
98
+ continue;
99
+ resolved[param] = await bindings.get(param).resolve(value, c);
100
+ }
101
+ c.set("bindings", { ...c.get("bindings"), ...resolved });
102
+ })();
103
+ }
104
+ /** Middleware that resolves the bound params of a route pattern. @internal */
105
+ export function bindingMiddleware(pattern) {
106
+ const params = paramNames(pattern);
107
+ if (!params.length)
108
+ return undefined;
109
+ return async (c, next) => {
110
+ await resolveBindings(params, c);
111
+ await next();
112
+ };
113
+ }
114
+ /* -------------------------------- accessors ------------------------------- */
115
+ /** The raw bound value for a parameter, if any. */
116
+ export function boundValue(param, c) {
117
+ const store = (c ?? currentCtx())?.get("bindings");
118
+ return store?.[param];
119
+ }
120
+ /**
121
+ * The model bound to this route — already fetched, never null.
122
+ *
123
+ * router.get("/posts/:post", () => {
124
+ * const post = boundModel(Post); // a Post, guaranteed
125
+ * });
126
+ *
127
+ * Pass the parameter name when one model is bound to several (`/users/:user/friends/:friend`).
128
+ */
129
+ export function boundModel(model, param, c) {
130
+ const ctx = c ?? currentCtx();
131
+ const store = ctx?.get("bindings") ?? {};
132
+ if (param) {
133
+ const value = store[param];
134
+ if (value === undefined) {
135
+ throw new Error(`Nothing is bound to ":${param}". Register it with bindModel("${param}", ${model.name}).`);
136
+ }
137
+ return value;
138
+ }
139
+ // No name given: find the one param bound to this model.
140
+ const matches = Object.entries(store).filter(([name]) => bindings.get(name)?.model === model);
141
+ if (!matches.length) {
142
+ throw new Error(`No route parameter is bound to ${model.name}. Register one with bindModel("<param>", ${model.name}).`);
143
+ }
144
+ if (matches.length > 1) {
145
+ // Two params, same model — we can't guess which one you meant.
146
+ throw new Error(`${matches.length} parameters are bound to ${model.name} (${matches.map(([n]) => `":${n}"`).join(", ")}). ` +
147
+ `Say which: boundModel(${model.name}, "${matches[0][0]}").`);
148
+ }
149
+ return matches[0][1];
150
+ }
151
+ /** The request in scope, or undefined outside one. */
152
+ function currentCtx() {
153
+ try {
154
+ return ctx();
155
+ }
156
+ catch {
157
+ return undefined; // called outside a request
158
+ }
159
+ }
@@ -6,6 +6,7 @@
6
6
  import { Hono } from "hono";
7
7
  import { contextStorage } from "hono/context-storage";
8
8
  import { Config } from "../config.js";
9
+ import { bindingMiddleware } from "../binding.js";
9
10
  import { HttpException, NotFoundException, ValidationException, STATUS_TEXT, } from "../exceptions.js";
10
11
  import { Router } from "./router.js";
11
12
  import { instrument, runRequest, newRequestId, currentRequestId } from "../instrumentation.js";
@@ -142,7 +143,15 @@ export class HttpKernel {
142
143
  for (const [param, rgx] of Object.entries(route.wheres)) {
143
144
  path = path.replace(new RegExp(`:${param}(\\??)`), `:${param}{${rgx}}$1`);
144
145
  }
145
- const middleware = [setRoute, ...route.middleware.map((m) => router.resolveMiddleware(m))];
146
+ // Route model binding: resolve `:user` into a User *before* the handler (and
147
+ // before route middleware, so a policy check can read the bound model). Only
148
+ // routes that actually have params pay for it.
149
+ const binding = bindingMiddleware(route.path);
150
+ const middleware = [
151
+ setRoute,
152
+ ...(binding ? [binding] : []),
153
+ ...route.middleware.map((m) => router.resolveMiddleware(m)),
154
+ ];
146
155
  hono.on(route.methods, [path], ...middleware, honoHandler);
147
156
  }
148
157
  hono.notFound((c) => this.handle(new NotFoundException(`No route for ${c.req.method} ${c.req.path}`), c));
@@ -76,6 +76,8 @@ export type { Ctx, RouteHandler, RouteDefinition, Method, Matcher, MiddlewareRef
76
76
  export { Inertia, inertia, inertiaPageAttr } from "./inertia.js";
77
77
  export type { InertiaPage, InertiaOptions } from "./inertia.js";
78
78
  export { HttpKernel } from "./http/kernel.js";
79
+ export { bindModel, bindRoute, boundModel, boundValue, hasBinding, clearBindings } from "./binding.js";
80
+ export type { BindingOptions, ModelClass } from "./binding.js";
79
81
  export { defineCommand, arg, flag, ConsoleKernel, ConsoleError, parseArgv } from "./console.js";
80
82
  export type { CommandDefinition, CommandContext, AnyCommand, ArgSpec, FlagSpec, ArgsSpec, FlagsSpec, ConsoleKernelOptions, } from "./console.js";
81
83
  export { createUi, stripAnsi } from "./console-ui.js";
@@ -41,6 +41,7 @@ export { instrument, runRequest, currentRequestId, newRequestId, } from "./instr
41
41
  export { Router, Route, RouteGroup, RouteResource, matchers } from "./http/router.js";
42
42
  export { Inertia, inertia, inertiaPageAttr } from "./inertia.js";
43
43
  export { HttpKernel } from "./http/kernel.js";
44
+ export { bindModel, bindRoute, boundModel, boundValue, hasBinding, clearBindings } from "./binding.js";
44
45
  export { defineCommand, arg, flag, ConsoleKernel, ConsoleError, parseArgv } from "./console.js";
45
46
  export { createUi, stripAnsi } from "./console-ui.js";
46
47
  export { createPrompt } from "./console-prompt.js";
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shaferllc/keel",
3
- "version": "0.78.2",
3
+ "version": "0.80.0",
4
4
  "description": "The house framework for Node.js — a service container, providers, routing, JSX views, and a code-generating console.",
5
5
  "repo": "https://github.com/shaferllc/keel",
6
6
  "generated": "run `npm run build:ai` to regenerate",
@@ -40,6 +40,13 @@
40
40
  "path": "docs/authorization.md",
41
41
  "example": "docs/examples/authorization.ts"
42
42
  },
43
+ {
44
+ "slug": "billing",
45
+ "title": "Billing",
46
+ "summary": "Keel Billing is a subscription-billing layer — a port of Laravel Cashier — for charging customers, managing subscriptions, and reconciling gateway state through webhooks.",
47
+ "path": "docs/billing.md",
48
+ "example": null
49
+ },
43
50
  {
44
51
  "slug": "broadcasting",
45
52
  "title": "Broadcasting",
@@ -643,6 +650,21 @@
643
650
  "kind": "value",
644
651
  "module": "helpers"
645
652
  },
653
+ {
654
+ "name": "BindingOptions",
655
+ "kind": "type",
656
+ "module": "binding"
657
+ },
658
+ {
659
+ "name": "bindModel",
660
+ "kind": "value",
661
+ "module": "binding"
662
+ },
663
+ {
664
+ "name": "bindRoute",
665
+ "kind": "value",
666
+ "module": "binding"
667
+ },
646
668
  {
647
669
  "name": "body",
648
670
  "kind": "value",
@@ -658,6 +680,16 @@
658
680
  "kind": "value",
659
681
  "module": "helpers"
660
682
  },
683
+ {
684
+ "name": "boundModel",
685
+ "kind": "value",
686
+ "module": "binding"
687
+ },
688
+ {
689
+ "name": "boundValue",
690
+ "kind": "value",
691
+ "module": "binding"
692
+ },
661
693
  {
662
694
  "name": "broadcast",
663
695
  "kind": "value",
@@ -783,6 +815,11 @@
783
815
  "kind": "value",
784
816
  "module": "authorization"
785
817
  },
818
+ {
819
+ "name": "clearBindings",
820
+ "kind": "value",
821
+ "module": "binding"
822
+ },
786
823
  {
787
824
  "name": "clearChannels",
788
825
  "kind": "value",
@@ -1413,6 +1450,11 @@
1413
1450
  "kind": "value",
1414
1451
  "module": "social"
1415
1452
  },
1453
+ {
1454
+ "name": "hasBinding",
1455
+ "kind": "value",
1456
+ "module": "binding"
1457
+ },
1416
1458
  {
1417
1459
  "name": "hash",
1418
1460
  "kind": "value",
@@ -1843,6 +1885,11 @@
1843
1885
  "kind": "value",
1844
1886
  "module": "model"
1845
1887
  },
1888
+ {
1889
+ "name": "ModelClass",
1890
+ "kind": "type",
1891
+ "module": "binding"
1892
+ },
1846
1893
  {
1847
1894
  "name": "ModelFactory",
1848
1895
  "kind": "value",
@@ -0,0 +1,242 @@
1
+ # Billing
2
+
3
+ Keel Billing is a subscription-billing layer — a port of [Laravel
4
+ Cashier](https://laravel.com/docs/13.x/billing) — for charging customers,
5
+ managing subscriptions, and reconciling gateway state through webhooks. It ships
6
+ as a Keel [package](./packages.md) and supports two gateways behind one API:
7
+ **Stripe** and **Paddle**.
8
+
9
+ It attaches to a model with a mixin. Your `User` becomes billable, gains a
10
+ gateway customer, and can create subscriptions and charges:
11
+
12
+ ```ts
13
+ import { Model } from "@shaferllc/keel/core";
14
+ import { Billable } from "@shaferllc/keel/billing";
15
+
16
+ export class User extends Billable(Model) {
17
+ static table = "users";
18
+ declare email: string;
19
+ }
20
+ ```
21
+
22
+ ## Install
23
+
24
+ ```ts
25
+ // bootstrap/providers.ts
26
+ import { BillingServiceProvider } from "@shaferllc/keel/billing";
27
+
28
+ export const providers = [AppServiceProvider, BillingServiceProvider];
29
+ ```
30
+
31
+ Publish the config and create the tables:
32
+
33
+ ```bash
34
+ keel vendor:publish --tag billing-config # writes config/billing.ts
35
+ keel migrate # creates subscriptions + subscription_items,
36
+ # and adds billing columns to users
37
+ ```
38
+
39
+ Set your keys in `.env`:
40
+
41
+ ```ini
42
+ BILLING_GATEWAY=stripe # or "paddle"
43
+ STRIPE_SECRET_KEY=sk_...
44
+ STRIPE_WEBHOOK_SECRET=whsec_...
45
+ # Paddle:
46
+ PADDLE_API_KEY=...
47
+ PADDLE_WEBHOOK_SECRET=...
48
+ PADDLE_CLIENT_TOKEN=...
49
+ PADDLE_SANDBOX=true
50
+ ```
51
+
52
+ ## One API, two gateways
53
+
54
+ Everything you call goes through a gateway-neutral interface, so switching from
55
+ Stripe to Paddle is a config change. The active gateway comes from
56
+ `config("billing.default")`; a billable can also carry its own in
57
+ `billing_gateway`. Money is always an integer in the smallest currency unit
58
+ (cents). See [Gateway differences](#gateway-differences) for where Paddle's
59
+ merchant-of-record model diverges.
60
+
61
+ ## Customers
62
+
63
+ A gateway customer is created lazily the first time you need one, but you can
64
+ create it up front:
65
+
66
+ ```ts
67
+ await user.createAsCustomer(); // creates the customer, stores its id
68
+ user.hasBillingId(); // true
69
+ await user.getCustomerId(); // the id (creates if missing)
70
+ ```
71
+
72
+ Override what gets synced by defining `billingName()` / `billingEmail()` on your
73
+ model. By default they read `name` / `email`.
74
+
75
+ ## Subscriptions
76
+
77
+ Build a subscription with the fluent builder:
78
+
79
+ ```ts
80
+ await user
81
+ .newSubscription("default", "price_pro")
82
+ .trialDays(14)
83
+ .quantity(3)
84
+ .create(paymentMethodId); // paymentMethodId optional if a default is on file
85
+ ```
86
+
87
+ Multiple prices (add-ons) are an array; `withMetadata`, `trialUntil`, and
88
+ `skipTrial` are also available. To send the customer to a hosted checkout
89
+ instead of charging now, swap `.create()` for `.checkout()`:
90
+
91
+ ```ts
92
+ const session = await user
93
+ .newSubscription("default", "price_pro")
94
+ .checkout({ successUrl: "...", cancelUrl: "..." });
95
+ // Stripe: redirect to session.url. Paddle: open the overlay with session.clientToken.
96
+ ```
97
+
98
+ ### Status
99
+
100
+ Status questions are answered from local columns — no gateway round-trip:
101
+
102
+ ```ts
103
+ await user.subscribed(); // valid (active | trial | grace)
104
+ await user.subscribedToPrice("price_pro");
105
+ await user.onTrial();
106
+ const sub = await user.subscription(); // the "default" subscription, or null
107
+
108
+ sub.active(); sub.onTrial(); sub.recurring();
109
+ sub.canceled(); sub.onGracePeriod(); sub.ended();
110
+ sub.paused(); sub.valid(); sub.hasIncompletePayment();
111
+ ```
112
+
113
+ ### Changing a subscription
114
+
115
+ ```ts
116
+ await sub.swap("price_enterprise"); // change price(s)
117
+ await sub.updateQuantity(10);
118
+ await sub.incrementQuantity(2);
119
+ await sub.decrementQuantity();
120
+ ```
121
+
122
+ Each of these calls the gateway and syncs the result back into the local row.
123
+
124
+ ### Cancelling
125
+
126
+ ```ts
127
+ await sub.cancel(); // at period end — access continues through the grace period
128
+ await sub.onGracePeriod();// true
129
+ await sub.resume(); // revive a subscription still in its grace period
130
+ await sub.cancelNow(); // immediately; sub.ended() becomes true
131
+ ```
132
+
133
+ ### Trials
134
+
135
+ ```ts
136
+ await sub.endTrial();
137
+ await sub.extendTrial(new Date("2026-01-01"));
138
+ user.onGenericTrial(); // a trial_ends_at on the user, before any subscription
139
+ ```
140
+
141
+ ## Single charges
142
+
143
+ ```ts
144
+ const charge = await user.charge(2000, { paymentMethod: "pm_1", description: "Credits" });
145
+ await user.refund(charge.id); // full refund
146
+ await user.refund(charge.id, 500); // partial
147
+
148
+ const session = await user.checkout("price_onetime", { successUrl, cancelUrl });
149
+ ```
150
+
151
+ ## Payment methods (Stripe)
152
+
153
+ Collect a card with a SetupIntent, then create the subscription with the
154
+ resulting payment method:
155
+
156
+ ```ts
157
+ const intent = await user.createSetupIntent(); // return intent.clientSecret to the front end
158
+ const methods = await user.paymentMethods();
159
+ ```
160
+
161
+ These are Stripe-only capabilities; calling them on the Paddle gateway throws a
162
+ `BillingError` (Paddle collects cards in its own hosted checkout).
163
+
164
+ ## Invoices
165
+
166
+ ```ts
167
+ const invoices = await user.invoices(); // GatewayInvoice[] — total, currency, status, url
168
+ ```
169
+
170
+ ## Webhooks
171
+
172
+ The package mounts one webhook endpoint per gateway at
173
+ `config("billing.webhook.path")`:
174
+
175
+ ```
176
+ POST /billing/webhook/stripe
177
+ POST /billing/webhook/paddle
178
+ ```
179
+
180
+ Point your gateway dashboard at the matching URL. Each request is verified
181
+ against the gateway's signing secret (HMAC-SHA256 over the raw body), the local
182
+ subscription is synced, and typed events fire:
183
+
184
+ ```ts
185
+ import { listen } from "@shaferllc/keel/core";
186
+
187
+ listen("billing.subscription.updated", (e) => {
188
+ // e.gateway, e.subscriptionId, e.providerId, e.status
189
+ });
190
+ listen("billing.webhook.received", (e) => { /* e.gateway, e.type, e.id */ });
191
+ ```
192
+
193
+ Events: `billing.webhook.received`, `billing.subscription.created` / `.updated`
194
+ / `.deleted`.
195
+
196
+ An update to a subscription already in your database is always synced. A brand
197
+ new subscription born from a Paddle checkout has no local row yet — register a
198
+ resolver so the handler can create it:
199
+
200
+ ```ts
201
+ import { resolveBillableUsing } from "@shaferllc/keel/billing";
202
+
203
+ resolveBillableUsing(async (customerId) => {
204
+ const user = (await User.query().where("billing_customer_id", customerId).first());
205
+ return user ? { id: user.id, type: "User" } : null;
206
+ });
207
+ ```
208
+
209
+ ## Gateway differences
210
+
211
+ | Concern | Stripe | Paddle |
212
+ |---------|--------|--------|
213
+ | Create a subscription server-side | `create(pmId)` | Not supported — use `checkout()`; the webhook creates the local row |
214
+ | One-off `charge()` | Confirms a PaymentIntent | Not supported — use `checkout({ mode })` / transactions |
215
+ | SetupIntent / `paymentMethods()` | Supported | Throws `BillingError` (hosted checkout) |
216
+ | Checkout handle | `session.url` (redirect) | `session.clientToken` (overlay/inline) |
217
+ | Webhook signature | `Stripe-Signature: t=…,v1=…` | `Paddle-Signature: ts=…;h1=…` |
218
+
219
+ ## Schema
220
+
221
+ The migration is gateway-neutral: `subscriptions` (with `gateway`,
222
+ `provider_id`, `provider_status`, `provider_price`, trial/grace timestamps),
223
+ `subscription_items`, and columns on `users` (`billing_gateway`,
224
+ `billing_customer_id`, `pm_type`, `pm_last_four`, `trial_ends_at`). Cashier
225
+ targets the standard `users` billable table.
226
+
227
+ ## Testing
228
+
229
+ The package ships a `FakeGateway` — an in-memory gateway that records every call
230
+ — so you can drive billing without touching a network:
231
+
232
+ ```ts
233
+ import { BillingManager, setBilling, FakeGateway } from "@shaferllc/keel/billing";
234
+
235
+ const fake = new FakeGateway();
236
+ const manager = new BillingManager(config); // config.default = "fake"
237
+ manager.register("fake", () => fake);
238
+ setBilling(manager);
239
+
240
+ await user.newSubscription("default", "price_pro").create();
241
+ fake.calls.filter((c) => c.method === "createSubscription"); // assert what was asked
242
+ ```
@@ -0,0 +1,83 @@
1
+ // Type-check harness for the route-model-binding section of docs/routing.md.
2
+ // Compile-only — never executed.
3
+ import {
4
+ bindModel,
5
+ bindRoute,
6
+ boundModel,
7
+ boundValue,
8
+ hasBinding,
9
+ clearBindings,
10
+ Model,
11
+ Router,
12
+ make,
13
+ ForbiddenException,
14
+ type Ctx,
15
+ type BindingOptions,
16
+ } from "@shaferllc/keel/core";
17
+ import type { MiddlewareHandler } from "hono";
18
+
19
+ class Post extends Model {
20
+ static override table = "posts";
21
+ declare id: number;
22
+ declare slug: string;
23
+ declare authorId: number;
24
+ }
25
+
26
+ interface Tenant {
27
+ id: number;
28
+ name: string;
29
+ }
30
+
31
+ declare const tenants: Map<string, Tenant>;
32
+ declare function currentUserId(c: Ctx): number;
33
+ declare function edit(c: Ctx): Response;
34
+
35
+ export function basic() {
36
+ bindModel("post", Post);
37
+
38
+ make(Router).get("/posts/:post", (c) => {
39
+ const post: Post = boundModel(Post); // a Post, guaranteed
40
+ return c.json({ id: post.id });
41
+ });
42
+ }
43
+
44
+ export function byAnotherColumn() {
45
+ bindModel("post", Post, { key: "slug" });
46
+ }
47
+
48
+ /** scope is row-level security: a row outside it 404s. */
49
+ export function scoped() {
50
+ const options: BindingOptions<Post> = {
51
+ key: "slug",
52
+ scope: (query, c) => query.where("authorId", currentUserId(c)),
53
+ missing: () => new Post({ id: 0 }),
54
+ };
55
+ bindModel("post", Post, options);
56
+ }
57
+
58
+ export function middlewareSeesTheModel() {
59
+ const mustOwn: MiddlewareHandler = async (c, next) => {
60
+ if (boundModel(Post).authorId !== currentUserId(c)) throw new ForbiddenException();
61
+ await next();
62
+ };
63
+
64
+ make(Router).get("/posts/:post/edit", edit).middleware(mustOwn);
65
+ }
66
+
67
+ export function anythingAtAll() {
68
+ bindRoute("tenant", (slug) => tenants.get(slug));
69
+
70
+ make(Router).get("/t/:tenant", (c) => {
71
+ const tenant = boundValue<Tenant>("tenant");
72
+ return c.json({ name: tenant?.name });
73
+ });
74
+ }
75
+
76
+ export function disambiguate(): [Post, Post] {
77
+ return [boundModel(Post, "post"), boundModel(Post, "original")];
78
+ }
79
+
80
+ export function registry(): boolean {
81
+ clearBindings();
82
+ return hasBinding("post");
83
+ }
package/docs/packages.md CHANGED
@@ -7,7 +7,9 @@ adds the conventions a *shippable* package needs so it can carry its own schema
7
7
  and assets instead of asking the app to wire them by hand.
8
8
 
9
9
  [Keel Watch](./watch.md) — the debug dashboard — is a first-party package and the
10
- reference implementation of everything below.
10
+ reference implementation of everything below. [Billing](./billing.md) (a Cashier
11
+ port for Stripe and Paddle) is another, and shows a package contributing models,
12
+ a schema migration, gateway drivers, and verified webhook routes.
11
13
 
12
14
  ## The shape of a package
13
15