@shaferllc/keel 0.79.0 → 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/docs/ai-manifest.json +8 -1
- package/docs/billing.md +242 -0
- package/docs/packages.md +3 -1
- package/llms-full.txt +252 -1
- package/llms.txt +1 -0
- package/package.json +6 -2
package/docs/billing.md
ADDED
|
@@ -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
|
+
```
|
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
|
|
package/llms-full.txt
CHANGED
|
@@ -5332,6 +5332,255 @@ all gates/policies/hooks (a test helper).
|
|
|
5332
5332
|
|
|
5333
5333
|
|
|
5334
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
|
+
|
|
5335
5584
|
---
|
|
5336
5585
|
|
|
5337
5586
|
<!-- source: docs/broadcasting.md -->
|
|
@@ -14329,7 +14578,9 @@ adds the conventions a *shippable* package needs so it can carry its own schema
|
|
|
14329
14578
|
and assets instead of asking the app to wire them by hand.
|
|
14330
14579
|
|
|
14331
14580
|
[Keel Watch](./watch.md) — the debug dashboard — is a first-party package and the
|
|
14332
|
-
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.
|
|
14333
14584
|
|
|
14334
14585
|
## The shape of a package
|
|
14335
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",
|