@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/README.md
CHANGED
|
@@ -160,11 +160,12 @@ src/core/ The framework
|
|
|
160
160
|
src/db/ Database adapters (D1, Postgres, libSQL)
|
|
161
161
|
src/api/ CRUD REST resources from a model
|
|
162
162
|
src/openapi/ Generates an OpenAPI spec from the routes
|
|
163
|
+
src/billing/ Subscription billing — Stripe + Paddle (Cashier port)
|
|
163
164
|
src/watch/ The debug dashboard
|
|
164
165
|
src/mcp/ The MCP server (docs + API for AI agents)
|
|
165
166
|
src/vite/ The Vite plugin
|
|
166
167
|
|
|
167
|
-
tests/
|
|
168
|
+
tests/ 767 tests
|
|
168
169
|
docs/ Every guide, plus type-checked examples of each
|
|
169
170
|
scripts/ build-ai (llms.txt, the MCP manifest), verify-release
|
|
170
171
|
```
|
|
@@ -227,6 +228,7 @@ See [docs/architecture.md](./docs/architecture.md) for the full picture.
|
|
|
227
228
|
| [Internationalization](./docs/i18n.md) | ICU messages, `Intl` formatters, locale detection |
|
|
228
229
|
| [Pages](./docs/pages.md) | Page-based routing — a file is a route |
|
|
229
230
|
| [Packages](./docs/packages.md) | Redistributable slices of an app: routes, migrations, commands |
|
|
231
|
+
| [Billing](./docs/billing.md) | Subscriptions, charges & webhooks — Stripe + Paddle (Cashier port) |
|
|
230
232
|
| [Watch](./docs/watch.md) | Debug dashboard — requests, queries, jobs, logs at `/watch` |
|
|
231
233
|
| [Views](./docs/views.md) | Hono JSX components, layouts, the View service |
|
|
232
234
|
| [Templates](./docs/templates.md) | `{{ }}` + `@`-tag templating engine, edge-safe |
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `Billable` mixin — Cashier's Billable trait, as a TypeScript mixin. Apply
|
|
3
|
+
* it to a model to give that model a gateway customer, subscriptions, charges,
|
|
4
|
+
* and checkout:
|
|
5
|
+
*
|
|
6
|
+
* class User extends Billable(Model) {
|
|
7
|
+
* static table = "users";
|
|
8
|
+
* declare email: string;
|
|
9
|
+
* }
|
|
10
|
+
*
|
|
11
|
+
* if (user.subscribed()) { ... }
|
|
12
|
+
* await user.newSubscription("default", "price_pro").trialDays(14).create(pmId);
|
|
13
|
+
* await user.charge(2000, { paymentMethod: pmId });
|
|
14
|
+
*
|
|
15
|
+
* Methods are thin: they resolve the active gateway (a model has no DI, so this
|
|
16
|
+
* goes through the `billing()` module singleton), call it, and persist local
|
|
17
|
+
* `Subscription` state. Customer-detail hooks (`billingEmail`, `billingName`)
|
|
18
|
+
* are overridable on the model.
|
|
19
|
+
*/
|
|
20
|
+
import { Model } from "../core/model.js";
|
|
21
|
+
import { Subscription } from "./subscription.js";
|
|
22
|
+
import { SubscriptionBuilder } from "./builder.js";
|
|
23
|
+
import type { BillingGateway, GatewayCharge, GatewayInvoice, GatewayRefund, SetupIntent, PaymentMethod, CheckoutSession, PriceRef } from "./gateway.js";
|
|
24
|
+
type ModelCtor = new (...args: any[]) => Model;
|
|
25
|
+
export interface ChargeOptions {
|
|
26
|
+
currency?: string;
|
|
27
|
+
paymentMethod?: string;
|
|
28
|
+
description?: string;
|
|
29
|
+
metadata?: Record<string, string>;
|
|
30
|
+
}
|
|
31
|
+
export interface ProductCheckoutOptions {
|
|
32
|
+
successUrl?: string;
|
|
33
|
+
cancelUrl?: string;
|
|
34
|
+
metadata?: Record<string, string>;
|
|
35
|
+
allowPromotionCodes?: boolean;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* The instance surface the mixin adds. Declared explicitly so the mixin's
|
|
39
|
+
* generated `.d.ts` references this named type rather than trying to emit the
|
|
40
|
+
* anonymous returned class — the latter fails (TS4094) because `Model` has
|
|
41
|
+
* private members.
|
|
42
|
+
*/
|
|
43
|
+
export interface BillableInstance {
|
|
44
|
+
billing_gateway: string | null;
|
|
45
|
+
billing_customer_id: string | null;
|
|
46
|
+
pm_type: string | null;
|
|
47
|
+
pm_last_four: string | null;
|
|
48
|
+
trial_ends_at: Date | null;
|
|
49
|
+
billingName(): string | undefined;
|
|
50
|
+
billingEmail(): string | undefined;
|
|
51
|
+
gatewayName(): string;
|
|
52
|
+
billingGateway(): BillingGateway;
|
|
53
|
+
hasBillingId(): boolean;
|
|
54
|
+
createAsCustomer(): Promise<string>;
|
|
55
|
+
getCustomerId(): Promise<string>;
|
|
56
|
+
billableId(): number | string;
|
|
57
|
+
billableType(): string;
|
|
58
|
+
subscriptions(): Promise<Subscription[]>;
|
|
59
|
+
subscription(type?: string): Promise<Subscription | null>;
|
|
60
|
+
newSubscription(type: string, prices: string | PriceRef | (string | PriceRef)[]): SubscriptionBuilder;
|
|
61
|
+
subscribed(type?: string): Promise<boolean>;
|
|
62
|
+
subscribedToPrice(price: string, type?: string): Promise<boolean>;
|
|
63
|
+
onTrial(type?: string): Promise<boolean>;
|
|
64
|
+
onGenericTrial(): boolean;
|
|
65
|
+
charge(amount: number, options?: ChargeOptions): Promise<GatewayCharge>;
|
|
66
|
+
refund(chargeId: string, amount?: number): Promise<GatewayRefund>;
|
|
67
|
+
invoices(): Promise<GatewayInvoice[]>;
|
|
68
|
+
checkout(prices: string | PriceRef | (string | PriceRef)[], options?: ProductCheckoutOptions): Promise<CheckoutSession>;
|
|
69
|
+
createSetupIntent(): Promise<SetupIntent>;
|
|
70
|
+
paymentMethods(): Promise<PaymentMethod[]>;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* The class a `Billable(Base)` call returns: billing instances plus all of
|
|
74
|
+
* `Base`. The widening construct signature comes first so `extends` derives the
|
|
75
|
+
* instance type from it (`InstanceType<TBase> & BillableInstance`); intersecting
|
|
76
|
+
* `TBase` last carries `Base`'s statics *and* its private-member brand, so
|
|
77
|
+
* `this`-typed statics like `Model.create` still resolve. An explicit return
|
|
78
|
+
* type is required regardless — the inferred anonymous mixin class can't be
|
|
79
|
+
* emitted to a `.d.ts` because `Model` has private members (TS4094).
|
|
80
|
+
*/
|
|
81
|
+
export type BillableClass<TBase extends ModelCtor> = (new (...args: any[]) => InstanceType<TBase> & BillableInstance) & TBase;
|
|
82
|
+
export declare function Billable<TBase extends ModelCtor>(Base: TBase): BillableClass<TBase>;
|
|
83
|
+
export {};
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `Billable` mixin — Cashier's Billable trait, as a TypeScript mixin. Apply
|
|
3
|
+
* it to a model to give that model a gateway customer, subscriptions, charges,
|
|
4
|
+
* and checkout:
|
|
5
|
+
*
|
|
6
|
+
* class User extends Billable(Model) {
|
|
7
|
+
* static table = "users";
|
|
8
|
+
* declare email: string;
|
|
9
|
+
* }
|
|
10
|
+
*
|
|
11
|
+
* if (user.subscribed()) { ... }
|
|
12
|
+
* await user.newSubscription("default", "price_pro").trialDays(14).create(pmId);
|
|
13
|
+
* await user.charge(2000, { paymentMethod: pmId });
|
|
14
|
+
*
|
|
15
|
+
* Methods are thin: they resolve the active gateway (a model has no DI, so this
|
|
16
|
+
* goes through the `billing()` module singleton), call it, and persist local
|
|
17
|
+
* `Subscription` state. Customer-detail hooks (`billingEmail`, `billingName`)
|
|
18
|
+
* are overridable on the model.
|
|
19
|
+
*/
|
|
20
|
+
import { billing } from "./manager.js";
|
|
21
|
+
import { Subscription } from "./subscription.js";
|
|
22
|
+
import { SubscriptionBuilder } from "./builder.js";
|
|
23
|
+
import { BillingError } from "./gateway.js";
|
|
24
|
+
export function Billable(Base) {
|
|
25
|
+
class BillableModel extends Base {
|
|
26
|
+
/* --------------------- overridable customer detail -------------------- */
|
|
27
|
+
/** The name to sync to the gateway customer. Override as needed. */
|
|
28
|
+
billingName() {
|
|
29
|
+
return this.name;
|
|
30
|
+
}
|
|
31
|
+
/** The email to sync to the gateway customer. Override as needed. */
|
|
32
|
+
billingEmail() {
|
|
33
|
+
return this.email;
|
|
34
|
+
}
|
|
35
|
+
/* ---------------------------- gateway wiring -------------------------- */
|
|
36
|
+
/** The gateway this billable uses (its own, or the configured default). */
|
|
37
|
+
gatewayName() {
|
|
38
|
+
return this.billing_gateway ?? billing().config().default;
|
|
39
|
+
}
|
|
40
|
+
billingGateway() {
|
|
41
|
+
return billing().gateway(this.gatewayName());
|
|
42
|
+
}
|
|
43
|
+
/* ------------------------------- customer ----------------------------- */
|
|
44
|
+
hasBillingId() {
|
|
45
|
+
return this.billing_customer_id != null && this.billing_customer_id !== "";
|
|
46
|
+
}
|
|
47
|
+
/** Create the gateway customer and store its id on this model. */
|
|
48
|
+
async createAsCustomer() {
|
|
49
|
+
const gateway = this.billingGateway();
|
|
50
|
+
const customer = await gateway.createCustomer({
|
|
51
|
+
...(this.billingName() ? { name: this.billingName() } : {}),
|
|
52
|
+
...(this.billingEmail() ? { email: this.billingEmail() } : {}),
|
|
53
|
+
});
|
|
54
|
+
this.billing_gateway = gateway.name;
|
|
55
|
+
this.billing_customer_id = customer.id;
|
|
56
|
+
await this.save();
|
|
57
|
+
return customer.id;
|
|
58
|
+
}
|
|
59
|
+
/** The gateway customer id, creating the customer if it doesn't exist yet. */
|
|
60
|
+
async getCustomerId() {
|
|
61
|
+
if (this.hasBillingId())
|
|
62
|
+
return this.billing_customer_id;
|
|
63
|
+
return this.createAsCustomer();
|
|
64
|
+
}
|
|
65
|
+
billableId() {
|
|
66
|
+
return this[this.constructor.primaryKey];
|
|
67
|
+
}
|
|
68
|
+
billableType() {
|
|
69
|
+
return this.constructor.name;
|
|
70
|
+
}
|
|
71
|
+
/* ----------------------------- subscriptions -------------------------- */
|
|
72
|
+
/** Every subscription this billable owns, newest first. */
|
|
73
|
+
async subscriptions() {
|
|
74
|
+
const rows = await Subscription.query()
|
|
75
|
+
.where("billable_id", this.billableId())
|
|
76
|
+
.where("billable_type", this.billableType())
|
|
77
|
+
.orderBy("id", "desc")
|
|
78
|
+
.get();
|
|
79
|
+
return rows.map((r) => new Subscription(r));
|
|
80
|
+
}
|
|
81
|
+
/** The named subscription (default: "default"), or null. */
|
|
82
|
+
async subscription(type = "default") {
|
|
83
|
+
const rows = await Subscription.query()
|
|
84
|
+
.where("billable_id", this.billableId())
|
|
85
|
+
.where("billable_type", this.billableType())
|
|
86
|
+
.where("type", type)
|
|
87
|
+
.orderBy("id", "desc")
|
|
88
|
+
.get();
|
|
89
|
+
return rows[0] ? new Subscription(rows[0]) : null;
|
|
90
|
+
}
|
|
91
|
+
/** Begin building a new subscription. */
|
|
92
|
+
newSubscription(type, prices) {
|
|
93
|
+
return new SubscriptionBuilder(this, type, prices);
|
|
94
|
+
}
|
|
95
|
+
/** Is the named subscription currently valid (active/trial/grace)? */
|
|
96
|
+
async subscribed(type = "default") {
|
|
97
|
+
return (await this.subscription(type))?.valid() ?? false;
|
|
98
|
+
}
|
|
99
|
+
/** Is the named subscription on the given price? */
|
|
100
|
+
async subscribedToPrice(price, type = "default") {
|
|
101
|
+
const sub = await this.subscription(type);
|
|
102
|
+
return !!sub && sub.valid() && sub.hasPrice(price);
|
|
103
|
+
}
|
|
104
|
+
/** On a trial of the named subscription? */
|
|
105
|
+
async onTrial(type = "default") {
|
|
106
|
+
return (await this.subscription(type))?.onTrial() ?? false;
|
|
107
|
+
}
|
|
108
|
+
/** On a generic trial (a `trial_ends_at` on the billable, no subscription yet)? */
|
|
109
|
+
onGenericTrial() {
|
|
110
|
+
return this.trial_ends_at != null && this.trial_ends_at.getTime() > Date.now();
|
|
111
|
+
}
|
|
112
|
+
/* ------------------------------- charges ------------------------------ */
|
|
113
|
+
/** Charge the customer a one-off amount (smallest currency unit). */
|
|
114
|
+
async charge(amount, options = {}) {
|
|
115
|
+
const customer = await this.getCustomerId();
|
|
116
|
+
return this.billingGateway().charge({
|
|
117
|
+
customer,
|
|
118
|
+
amount,
|
|
119
|
+
currency: options.currency ?? billing().config().currency,
|
|
120
|
+
...(options.paymentMethod ? { paymentMethod: options.paymentMethod } : {}),
|
|
121
|
+
...(options.description ? { description: options.description } : {}),
|
|
122
|
+
...(options.metadata ? { metadata: options.metadata } : {}),
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
/** Refund a prior charge (full, or a partial `amount`). */
|
|
126
|
+
async refund(chargeId, amount) {
|
|
127
|
+
return this.billingGateway().refund({
|
|
128
|
+
charge: chargeId,
|
|
129
|
+
...(amount != null ? { amount } : {}),
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
/** The customer's invoices. */
|
|
133
|
+
async invoices() {
|
|
134
|
+
if (!this.hasBillingId())
|
|
135
|
+
return [];
|
|
136
|
+
return this.billingGateway().listInvoices(this.billing_customer_id);
|
|
137
|
+
}
|
|
138
|
+
/* ------------------------------ checkout ------------------------------ */
|
|
139
|
+
/** Hosted checkout for a one-off product purchase. */
|
|
140
|
+
async checkout(prices, options = {}) {
|
|
141
|
+
const customer = await this.getCustomerId();
|
|
142
|
+
const items = (Array.isArray(prices) ? prices : [prices]).map((p) => typeof p === "string" ? { price: p } : p);
|
|
143
|
+
const params = {
|
|
144
|
+
mode: "payment",
|
|
145
|
+
customer,
|
|
146
|
+
items,
|
|
147
|
+
...(options.successUrl ? { successUrl: options.successUrl } : {}),
|
|
148
|
+
...(options.cancelUrl ? { cancelUrl: options.cancelUrl } : {}),
|
|
149
|
+
...(options.metadata ? { metadata: options.metadata } : {}),
|
|
150
|
+
...(options.allowPromotionCodes != null
|
|
151
|
+
? { allowPromotionCodes: options.allowPromotionCodes }
|
|
152
|
+
: {}),
|
|
153
|
+
};
|
|
154
|
+
return this.billingGateway().createCheckoutSession(params);
|
|
155
|
+
}
|
|
156
|
+
/* ------------------- optional gateway capabilities -------------------- */
|
|
157
|
+
/** Create a SetupIntent to collect a payment method (Stripe). */
|
|
158
|
+
async createSetupIntent() {
|
|
159
|
+
const gateway = this.billingGateway();
|
|
160
|
+
if (!gateway.createSetupIntent) {
|
|
161
|
+
throw new BillingError(`The "${gateway.name}" gateway does not support setup intents.`, gateway.name);
|
|
162
|
+
}
|
|
163
|
+
return gateway.createSetupIntent(await this.getCustomerId());
|
|
164
|
+
}
|
|
165
|
+
/** The customer's stored payment methods (Stripe). */
|
|
166
|
+
async paymentMethods() {
|
|
167
|
+
const gateway = this.billingGateway();
|
|
168
|
+
if (!gateway.paymentMethods) {
|
|
169
|
+
throw new BillingError(`The "${gateway.name}" gateway does not expose payment methods.`, gateway.name);
|
|
170
|
+
}
|
|
171
|
+
if (!this.hasBillingId())
|
|
172
|
+
return [];
|
|
173
|
+
return gateway.paymentMethods(this.billing_customer_id);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return BillableModel;
|
|
177
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { env } from "@shaferllc/keel/core";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Billing configuration — a Cashier-style port covering Stripe and Paddle.
|
|
5
|
+
* Published with `keel vendor:publish --tag billing-config`.
|
|
6
|
+
*/
|
|
7
|
+
export default {
|
|
8
|
+
// Which gateway is active: "stripe" | "paddle".
|
|
9
|
+
default: env("BILLING_GATEWAY", "stripe"),
|
|
10
|
+
|
|
11
|
+
// Default currency for one-off charges (ISO 4217, lowercase).
|
|
12
|
+
currency: env("BILLING_CURRENCY", "usd"),
|
|
13
|
+
|
|
14
|
+
// The model class stored in subscriptions.billable_type.
|
|
15
|
+
billableModel: "User",
|
|
16
|
+
|
|
17
|
+
// Webhook routes mount at `${path}/${gateway}` — e.g. /billing/webhook/stripe.
|
|
18
|
+
webhook: { path: "billing/webhook" },
|
|
19
|
+
|
|
20
|
+
gateways: {
|
|
21
|
+
stripe: {
|
|
22
|
+
key: env("STRIPE_SECRET_KEY", ""),
|
|
23
|
+
webhookSecret: env("STRIPE_WEBHOOK_SECRET", ""),
|
|
24
|
+
publishableKey: env("STRIPE_PUBLISHABLE_KEY", ""),
|
|
25
|
+
},
|
|
26
|
+
paddle: {
|
|
27
|
+
key: env("PADDLE_API_KEY", ""),
|
|
28
|
+
webhookSecret: env("PADDLE_WEBHOOK_SECRET", ""),
|
|
29
|
+
clientToken: env("PADDLE_CLIENT_TOKEN", ""),
|
|
30
|
+
sandbox: env("PADDLE_SANDBOX", false),
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The fluent subscription builder — Cashier's `newSubscription(...)->create()`.
|
|
3
|
+
* The `Billable` mixin hands it a small `BillableTarget` (so this file needn't
|
|
4
|
+
* import the mixin, avoiding a cycle); the builder shapes the request, calls the
|
|
5
|
+
* gateway, and persists a local `Subscription` synced from the result.
|
|
6
|
+
*
|
|
7
|
+
* await user.newSubscription("default", "price_pro")
|
|
8
|
+
* .trialDays(14)
|
|
9
|
+
* .quantity(3)
|
|
10
|
+
* .create(paymentMethodId);
|
|
11
|
+
*/
|
|
12
|
+
import { Subscription, type PriceArg } from "./subscription.js";
|
|
13
|
+
import type { CheckoutSession } from "./gateway.js";
|
|
14
|
+
/** What the builder needs from the billable that started it. */
|
|
15
|
+
export interface BillableTarget {
|
|
16
|
+
/** Ensure a gateway customer exists and return its id. */
|
|
17
|
+
getCustomerId(): Promise<string>;
|
|
18
|
+
gatewayName(): string;
|
|
19
|
+
billableId(): number | string;
|
|
20
|
+
billableType(): string;
|
|
21
|
+
}
|
|
22
|
+
export interface CheckoutOptions {
|
|
23
|
+
successUrl?: string;
|
|
24
|
+
cancelUrl?: string;
|
|
25
|
+
allowPromotionCodes?: boolean;
|
|
26
|
+
metadata?: Record<string, string>;
|
|
27
|
+
}
|
|
28
|
+
export declare class SubscriptionBuilder {
|
|
29
|
+
private owner;
|
|
30
|
+
private type;
|
|
31
|
+
private refs;
|
|
32
|
+
private _trialEnd?;
|
|
33
|
+
private _skipTrial;
|
|
34
|
+
private _quantity?;
|
|
35
|
+
private _metadata?;
|
|
36
|
+
constructor(owner: BillableTarget, type: string, prices: PriceArg | PriceArg[]);
|
|
37
|
+
/** Trial for N days from now. */
|
|
38
|
+
trialDays(days: number): this;
|
|
39
|
+
/** Trial until a specific moment. */
|
|
40
|
+
trialUntil(date: Date): this;
|
|
41
|
+
/** Start without any trial. */
|
|
42
|
+
skipTrial(): this;
|
|
43
|
+
/** Quantity for a single-price subscription. */
|
|
44
|
+
quantity(quantity: number): this;
|
|
45
|
+
withMetadata(metadata: Record<string, string>): this;
|
|
46
|
+
/** Create the subscription, billing `paymentMethod` (or the default on file). */
|
|
47
|
+
create(paymentMethod?: string): Promise<Subscription>;
|
|
48
|
+
/** Add a subscription for a customer who already has a payment method. */
|
|
49
|
+
add(): Promise<Subscription>;
|
|
50
|
+
/** Start a hosted checkout for this subscription instead of creating it now. */
|
|
51
|
+
checkout(options?: CheckoutOptions): Promise<CheckoutSession>;
|
|
52
|
+
private items;
|
|
53
|
+
private trialEnd;
|
|
54
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The fluent subscription builder — Cashier's `newSubscription(...)->create()`.
|
|
3
|
+
* The `Billable` mixin hands it a small `BillableTarget` (so this file needn't
|
|
4
|
+
* import the mixin, avoiding a cycle); the builder shapes the request, calls the
|
|
5
|
+
* gateway, and persists a local `Subscription` synced from the result.
|
|
6
|
+
*
|
|
7
|
+
* await user.newSubscription("default", "price_pro")
|
|
8
|
+
* .trialDays(14)
|
|
9
|
+
* .quantity(3)
|
|
10
|
+
* .create(paymentMethodId);
|
|
11
|
+
*/
|
|
12
|
+
import { billing } from "./manager.js";
|
|
13
|
+
import { Subscription, toRefs } from "./subscription.js";
|
|
14
|
+
const DAY = 86_400_000;
|
|
15
|
+
export class SubscriptionBuilder {
|
|
16
|
+
owner;
|
|
17
|
+
type;
|
|
18
|
+
refs;
|
|
19
|
+
_trialEnd;
|
|
20
|
+
_skipTrial = false;
|
|
21
|
+
_quantity;
|
|
22
|
+
_metadata;
|
|
23
|
+
constructor(owner, type, prices) {
|
|
24
|
+
this.owner = owner;
|
|
25
|
+
this.type = type;
|
|
26
|
+
this.refs = toRefs(prices);
|
|
27
|
+
}
|
|
28
|
+
/** Trial for N days from now. */
|
|
29
|
+
trialDays(days) {
|
|
30
|
+
this._trialEnd = new Date(Date.now() + days * DAY);
|
|
31
|
+
return this;
|
|
32
|
+
}
|
|
33
|
+
/** Trial until a specific moment. */
|
|
34
|
+
trialUntil(date) {
|
|
35
|
+
this._trialEnd = date;
|
|
36
|
+
return this;
|
|
37
|
+
}
|
|
38
|
+
/** Start without any trial. */
|
|
39
|
+
skipTrial() {
|
|
40
|
+
this._skipTrial = true;
|
|
41
|
+
return this;
|
|
42
|
+
}
|
|
43
|
+
/** Quantity for a single-price subscription. */
|
|
44
|
+
quantity(quantity) {
|
|
45
|
+
this._quantity = quantity;
|
|
46
|
+
return this;
|
|
47
|
+
}
|
|
48
|
+
withMetadata(metadata) {
|
|
49
|
+
this._metadata = metadata;
|
|
50
|
+
return this;
|
|
51
|
+
}
|
|
52
|
+
/** Create the subscription, billing `paymentMethod` (or the default on file). */
|
|
53
|
+
async create(paymentMethod) {
|
|
54
|
+
const customer = await this.owner.getCustomerId();
|
|
55
|
+
const gateway = billing().gateway(this.owner.gatewayName());
|
|
56
|
+
const remote = await gateway.createSubscription({
|
|
57
|
+
customer,
|
|
58
|
+
items: this.items(),
|
|
59
|
+
...(this.trialEnd() ? { trialEnd: this.trialEnd() } : {}),
|
|
60
|
+
...(paymentMethod ? { paymentMethod } : {}),
|
|
61
|
+
...(this._metadata ? { metadata: this._metadata } : {}),
|
|
62
|
+
});
|
|
63
|
+
const subscription = await Subscription.create({
|
|
64
|
+
billable_id: this.owner.billableId(),
|
|
65
|
+
billable_type: this.owner.billableType(),
|
|
66
|
+
type: this.type,
|
|
67
|
+
gateway: this.owner.gatewayName(),
|
|
68
|
+
provider_id: remote.id,
|
|
69
|
+
provider_status: remote.status,
|
|
70
|
+
starts_at: new Date(),
|
|
71
|
+
});
|
|
72
|
+
await subscription.syncFromGateway(remote);
|
|
73
|
+
return subscription;
|
|
74
|
+
}
|
|
75
|
+
/** Add a subscription for a customer who already has a payment method. */
|
|
76
|
+
add() {
|
|
77
|
+
return this.create();
|
|
78
|
+
}
|
|
79
|
+
/** Start a hosted checkout for this subscription instead of creating it now. */
|
|
80
|
+
async checkout(options = {}) {
|
|
81
|
+
const customer = await this.owner.getCustomerId();
|
|
82
|
+
const gateway = billing().gateway(this.owner.gatewayName());
|
|
83
|
+
return gateway.createCheckoutSession({
|
|
84
|
+
mode: "subscription",
|
|
85
|
+
customer,
|
|
86
|
+
items: this.items(),
|
|
87
|
+
...(this.trialEnd() ? { trialEnd: this.trialEnd() } : {}),
|
|
88
|
+
...(options.successUrl ? { successUrl: options.successUrl } : {}),
|
|
89
|
+
...(options.cancelUrl ? { cancelUrl: options.cancelUrl } : {}),
|
|
90
|
+
...(options.allowPromotionCodes != null
|
|
91
|
+
? { allowPromotionCodes: options.allowPromotionCodes }
|
|
92
|
+
: {}),
|
|
93
|
+
...(options.metadata ? { metadata: options.metadata } : {}),
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
items() {
|
|
97
|
+
if (this._quantity == null)
|
|
98
|
+
return this.refs;
|
|
99
|
+
return this.refs.map((r, i) => (i === 0 ? { ...r, quantity: this._quantity } : r));
|
|
100
|
+
}
|
|
101
|
+
trialEnd() {
|
|
102
|
+
return this._skipTrial ? undefined : this._trialEnd;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Billing configuration. Defaults live here and are merged under
|
|
3
|
+
* `config("billing")` by the provider; an app overrides any of them in
|
|
4
|
+
* `config/billing.ts` (publish it with `keel vendor:publish --tag billing-config`).
|
|
5
|
+
*
|
|
6
|
+
* The shape mirrors Keel's other multi-driver managers (mail, queue, database):
|
|
7
|
+
* a `default` gateway plus a `gateways` map the drivers read their keys from.
|
|
8
|
+
*/
|
|
9
|
+
export interface StripeGatewayConfig {
|
|
10
|
+
key: string;
|
|
11
|
+
webhookSecret: string;
|
|
12
|
+
publishableKey?: string;
|
|
13
|
+
/** Drivers read only what they know; extra keys are allowed. */
|
|
14
|
+
[k: string]: unknown;
|
|
15
|
+
}
|
|
16
|
+
export interface PaddleGatewayConfig {
|
|
17
|
+
key: string;
|
|
18
|
+
webhookSecret: string;
|
|
19
|
+
clientToken?: string;
|
|
20
|
+
sandbox?: boolean;
|
|
21
|
+
[k: string]: unknown;
|
|
22
|
+
}
|
|
23
|
+
export interface BillingConfig {
|
|
24
|
+
/** Which gateway is active: "stripe" | "paddle" | "fake". */
|
|
25
|
+
default: string;
|
|
26
|
+
/** Default currency for one-off charges, ISO 4217 lowercase. */
|
|
27
|
+
currency: string;
|
|
28
|
+
/** The class name stored in `subscriptions.billable_type`. */
|
|
29
|
+
billableModel: string;
|
|
30
|
+
/** Base URL path the webhook routes mount under; the gateway name is appended. */
|
|
31
|
+
webhook: {
|
|
32
|
+
path: string;
|
|
33
|
+
};
|
|
34
|
+
gateways: {
|
|
35
|
+
stripe: StripeGatewayConfig;
|
|
36
|
+
paddle: PaddleGatewayConfig;
|
|
37
|
+
/** Custom gateways may add their own loosely-typed config. */
|
|
38
|
+
[name: string]: Record<string, unknown>;
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
export declare const defaultConfig: BillingConfig;
|
|
42
|
+
/** Read the effective billing config off the application, filling any gaps. */
|
|
43
|
+
export declare function resolveConfig(): BillingConfig;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Billing configuration. Defaults live here and are merged under
|
|
3
|
+
* `config("billing")` by the provider; an app overrides any of them in
|
|
4
|
+
* `config/billing.ts` (publish it with `keel vendor:publish --tag billing-config`).
|
|
5
|
+
*
|
|
6
|
+
* The shape mirrors Keel's other multi-driver managers (mail, queue, database):
|
|
7
|
+
* a `default` gateway plus a `gateways` map the drivers read their keys from.
|
|
8
|
+
*/
|
|
9
|
+
import { config } from "../core/helpers.js";
|
|
10
|
+
export const defaultConfig = {
|
|
11
|
+
default: "stripe",
|
|
12
|
+
currency: "usd",
|
|
13
|
+
billableModel: "User",
|
|
14
|
+
webhook: { path: "billing/webhook" },
|
|
15
|
+
gateways: {
|
|
16
|
+
stripe: { key: "", webhookSecret: "" },
|
|
17
|
+
paddle: { key: "", webhookSecret: "", sandbox: false },
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
/** Read the effective billing config off the application, filling any gaps. */
|
|
21
|
+
export function resolveConfig() {
|
|
22
|
+
const raw = config("billing", {});
|
|
23
|
+
const gateways = (raw.gateways ?? {});
|
|
24
|
+
return {
|
|
25
|
+
...defaultConfig,
|
|
26
|
+
...raw,
|
|
27
|
+
webhook: { ...defaultConfig.webhook, ...(raw.webhook ?? {}) },
|
|
28
|
+
gateways: {
|
|
29
|
+
...defaultConfig.gateways,
|
|
30
|
+
...gateways,
|
|
31
|
+
stripe: { ...defaultConfig.gateways.stripe, ...(gateways.stripe ?? {}) },
|
|
32
|
+
paddle: { ...defaultConfig.gateways.paddle, ...(gateways.paddle ?? {}) },
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Webhook-signature primitives. Stripe and Paddle both sign webhooks with
|
|
3
|
+
* HMAC-SHA256 rendered as lowercase hex. The framework's own hex-HMAC helper
|
|
4
|
+
* (`src/core/http/router.ts`) and constant-time compare (`src/core/crypto.ts`)
|
|
5
|
+
* aren't exported, so we vendor tiny copies here — edge-safe Web Crypto, no
|
|
6
|
+
* `node:crypto`, consistent with how the rest of Keel signs.
|
|
7
|
+
*/
|
|
8
|
+
/** HMAC-SHA256 of `data` under `secret`, as lowercase hex. */
|
|
9
|
+
export declare function hmacSha256Hex(secret: string, data: string): Promise<string>;
|
|
10
|
+
/** Length-independent, constant-time string comparison (avoids timing leaks). */
|
|
11
|
+
export declare function constantTimeEqual(a: string, b: string): boolean;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Webhook-signature primitives. Stripe and Paddle both sign webhooks with
|
|
3
|
+
* HMAC-SHA256 rendered as lowercase hex. The framework's own hex-HMAC helper
|
|
4
|
+
* (`src/core/http/router.ts`) and constant-time compare (`src/core/crypto.ts`)
|
|
5
|
+
* aren't exported, so we vendor tiny copies here — edge-safe Web Crypto, no
|
|
6
|
+
* `node:crypto`, consistent with how the rest of Keel signs.
|
|
7
|
+
*/
|
|
8
|
+
/** HMAC-SHA256 of `data` under `secret`, as lowercase hex. */
|
|
9
|
+
export async function hmacSha256Hex(secret, data) {
|
|
10
|
+
const enc = new TextEncoder();
|
|
11
|
+
const key = await crypto.subtle.importKey("raw", enc.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
|
|
12
|
+
const sig = await crypto.subtle.sign("HMAC", key, enc.encode(data));
|
|
13
|
+
return [...new Uint8Array(sig)].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
14
|
+
}
|
|
15
|
+
/** Length-independent, constant-time string comparison (avoids timing leaks). */
|
|
16
|
+
export function constantTimeEqual(a, b) {
|
|
17
|
+
const enc = new TextEncoder();
|
|
18
|
+
const av = enc.encode(a);
|
|
19
|
+
const bv = enc.encode(b);
|
|
20
|
+
// Compare against the longer length so mismatched lengths still take the same
|
|
21
|
+
// path; the length inequality alone decides the result.
|
|
22
|
+
let diff = av.length ^ bv.length;
|
|
23
|
+
const len = Math.max(av.length, bv.length);
|
|
24
|
+
for (let i = 0; i < len; i++)
|
|
25
|
+
diff |= (av[i] ?? 0) ^ (bv[i] ?? 0);
|
|
26
|
+
return diff === 0;
|
|
27
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An in-memory gateway for tests and local development. It implements the whole
|
|
3
|
+
* `BillingGateway` surface deterministically (ids are sequential, no clock or
|
|
4
|
+
* randomness in the identifiers) and records every call on `.calls` so tests can
|
|
5
|
+
* assert what the higher layers asked of it — the billing analogue of
|
|
6
|
+
* `MemoryDriver`/`ArrayTransport`.
|
|
7
|
+
*
|
|
8
|
+
* const gw = new FakeGateway();
|
|
9
|
+
* manager.register("fake", () => gw);
|
|
10
|
+
* // …exercise Billable…
|
|
11
|
+
* gw.calls.filter((c) => c.method === "charge");
|
|
12
|
+
*
|
|
13
|
+
* Webhooks are signed exactly like a real gateway: HMAC-SHA256 hex of the raw
|
|
14
|
+
* body under the secret, in a `fake-signature` header. `signWebhook()` builds a
|
|
15
|
+
* matching body+headers pair for tests.
|
|
16
|
+
*/
|
|
17
|
+
import type { BillingGateway, CustomerDetails, GatewayCustomer, CreateSubscriptionParams, GatewaySubscription, SwapParams, CancelParams, ChargeParams, GatewayCharge, RefundParams, GatewayRefund, GatewayInvoice, CheckoutParams, CheckoutSession, SetupIntent, PaymentMethod, HeaderBag } from "../gateway.js";
|
|
18
|
+
export interface FakeCall {
|
|
19
|
+
method: string;
|
|
20
|
+
args: unknown[];
|
|
21
|
+
}
|
|
22
|
+
export declare class FakeGateway implements BillingGateway {
|
|
23
|
+
readonly name = "fake";
|
|
24
|
+
readonly calls: FakeCall[];
|
|
25
|
+
readonly customers: Map<string, GatewayCustomer>;
|
|
26
|
+
readonly subscriptions: Map<string, GatewaySubscription>;
|
|
27
|
+
readonly invoices: Map<string, GatewayInvoice[]>;
|
|
28
|
+
private seq;
|
|
29
|
+
private id;
|
|
30
|
+
private record;
|
|
31
|
+
private items;
|
|
32
|
+
createCustomer(details: CustomerDetails): Promise<GatewayCustomer>;
|
|
33
|
+
updateCustomer(id: string, details: CustomerDetails): Promise<GatewayCustomer>;
|
|
34
|
+
createSubscription(params: CreateSubscriptionParams): Promise<GatewaySubscription>;
|
|
35
|
+
swapSubscription(id: string, params: SwapParams): Promise<GatewaySubscription>;
|
|
36
|
+
updateQuantity(id: string, quantity: number, opts?: {
|
|
37
|
+
prorate?: boolean;
|
|
38
|
+
}): Promise<GatewaySubscription>;
|
|
39
|
+
cancelSubscription(id: string, params?: CancelParams): Promise<GatewaySubscription>;
|
|
40
|
+
resumeSubscription(id: string): Promise<GatewaySubscription>;
|
|
41
|
+
charge(params: ChargeParams): Promise<GatewayCharge>;
|
|
42
|
+
refund(params: RefundParams): Promise<GatewayRefund>;
|
|
43
|
+
listInvoices(customer: string): Promise<GatewayInvoice[]>;
|
|
44
|
+
createCheckoutSession(params: CheckoutParams): Promise<CheckoutSession>;
|
|
45
|
+
createSetupIntent(customer: string): Promise<SetupIntent>;
|
|
46
|
+
paymentMethods(customer: string): Promise<PaymentMethod[]>;
|
|
47
|
+
verifyWebhook(rawBody: string, headers: HeaderBag, secret: string): Promise<import("../gateway.js").WebhookEvent | null>;
|
|
48
|
+
/** Build a signed webhook body + headers pair for tests. */
|
|
49
|
+
signWebhook(secret: string, event: {
|
|
50
|
+
id?: string;
|
|
51
|
+
type: string;
|
|
52
|
+
data?: Record<string, unknown>;
|
|
53
|
+
}): Promise<{
|
|
54
|
+
body: string;
|
|
55
|
+
headers: Record<string, string>;
|
|
56
|
+
}>;
|
|
57
|
+
private require;
|
|
58
|
+
}
|