@shaferllc/keel 0.78.2 → 0.80.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -1
- package/dist/billing/billable.d.ts +83 -0
- package/dist/billing/billable.js +177 -0
- package/dist/billing/billing.config.stub +33 -0
- package/dist/billing/builder.d.ts +54 -0
- package/dist/billing/builder.js +104 -0
- package/dist/billing/config.d.ts +43 -0
- package/dist/billing/config.js +35 -0
- package/dist/billing/crypto.d.ts +11 -0
- package/dist/billing/crypto.js +27 -0
- package/dist/billing/drivers/fake.d.ts +58 -0
- package/dist/billing/drivers/fake.js +190 -0
- package/dist/billing/drivers/index.d.ts +11 -0
- package/dist/billing/drivers/index.js +22 -0
- package/dist/billing/drivers/paddle.d.ts +39 -0
- package/dist/billing/drivers/paddle.js +197 -0
- package/dist/billing/drivers/stripe.d.ts +33 -0
- package/dist/billing/drivers/stripe.js +278 -0
- package/dist/billing/events.d.ts +25 -0
- package/dist/billing/events.js +7 -0
- package/dist/billing/gateway.d.ts +170 -0
- package/dist/billing/gateway.js +24 -0
- package/dist/billing/index.d.ts +28 -0
- package/dist/billing/index.js +19 -0
- package/dist/billing/manager.d.ts +34 -0
- package/dist/billing/manager.js +61 -0
- package/dist/billing/migration.d.ts +13 -0
- package/dist/billing/migration.js +68 -0
- package/dist/billing/provider.d.ts +20 -0
- package/dist/billing/provider.js +42 -0
- package/dist/billing/routes.d.ts +11 -0
- package/dist/billing/routes.js +21 -0
- package/dist/billing/subscription-item.d.ts +18 -0
- package/dist/billing/subscription-item.js +11 -0
- package/dist/billing/subscription.d.ts +85 -0
- package/dist/billing/subscription.js +157 -0
- package/dist/billing/webhooks.d.ts +26 -0
- package/dist/billing/webhooks.js +75 -0
- package/dist/core/binding.d.ts +91 -0
- package/dist/core/binding.js +159 -0
- package/dist/core/http/kernel.js +10 -1
- package/dist/core/index.d.ts +2 -0
- package/dist/core/index.js +1 -0
- package/docs/ai-manifest.json +48 -1
- package/docs/billing.md +242 -0
- package/docs/examples/binding.ts +83 -0
- package/docs/packages.md +3 -1
- package/docs/routing.md +84 -0
- package/llms-full.txt +336 -1
- package/llms.txt +1 -0
- package/package.json +6 -2
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Stripe gateway. Talks to the Stripe REST API directly with `fetch` —
|
|
3
|
+
* form-encoded bodies, Bearer auth, no SDK — matching how the rest of Keel makes
|
|
4
|
+
* outbound calls. Everything is mapped to/from the gateway-neutral DTOs so the
|
|
5
|
+
* layers above never see a Stripe object.
|
|
6
|
+
*
|
|
7
|
+
* Amounts are already in the smallest currency unit; Stripe's `trial_end` is a
|
|
8
|
+
* Unix timestamp; webhook signatures are the `t`/`v1` scheme over the raw body.
|
|
9
|
+
*/
|
|
10
|
+
import { hmacSha256Hex, constantTimeEqual } from "../crypto.js";
|
|
11
|
+
import { BillingError } from "../gateway.js";
|
|
12
|
+
const API = "https://api.stripe.com/v1";
|
|
13
|
+
/** Flatten nested objects/arrays into Stripe's `a[b][0][c]=v` form encoding. */
|
|
14
|
+
function encodeForm(params, form = new URLSearchParams(), prefix = "") {
|
|
15
|
+
for (const [k, value] of Object.entries(params)) {
|
|
16
|
+
if (value === undefined || value === null)
|
|
17
|
+
continue;
|
|
18
|
+
const key = prefix ? `${prefix}[${k}]` : k;
|
|
19
|
+
if (Array.isArray(value)) {
|
|
20
|
+
value.forEach((item, i) => {
|
|
21
|
+
if (item !== null && typeof item === "object") {
|
|
22
|
+
encodeForm(item, form, `${key}[${i}]`);
|
|
23
|
+
}
|
|
24
|
+
else {
|
|
25
|
+
form.append(`${key}[${i}]`, String(item));
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
else if (typeof value === "object") {
|
|
30
|
+
encodeForm(value, form, key);
|
|
31
|
+
}
|
|
32
|
+
else {
|
|
33
|
+
form.append(key, String(value));
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return form;
|
|
37
|
+
}
|
|
38
|
+
function unix(date) {
|
|
39
|
+
return Math.floor(date.getTime() / 1000);
|
|
40
|
+
}
|
|
41
|
+
export class StripeGateway {
|
|
42
|
+
key;
|
|
43
|
+
name = "stripe";
|
|
44
|
+
constructor(key) {
|
|
45
|
+
this.key = key;
|
|
46
|
+
}
|
|
47
|
+
/* ------------------------------- transport ----------------------------- */
|
|
48
|
+
async call(method, path, params) {
|
|
49
|
+
if (!this.key)
|
|
50
|
+
throw new BillingError("Stripe secret key is not configured.", "stripe");
|
|
51
|
+
let url = `${API}${path}`;
|
|
52
|
+
const init = {
|
|
53
|
+
method,
|
|
54
|
+
headers: {
|
|
55
|
+
authorization: `Bearer ${this.key}`,
|
|
56
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
if (params && method === "GET") {
|
|
60
|
+
url += `?${encodeForm(params).toString()}`;
|
|
61
|
+
}
|
|
62
|
+
else if (params) {
|
|
63
|
+
init.body = encodeForm(params);
|
|
64
|
+
}
|
|
65
|
+
const res = await fetch(url, init);
|
|
66
|
+
const data = (await res.json().catch(() => ({})));
|
|
67
|
+
if (!res.ok) {
|
|
68
|
+
const err = (data.error ?? {});
|
|
69
|
+
throw new BillingError(err.message ?? `Stripe request failed (${res.status})`, "stripe");
|
|
70
|
+
}
|
|
71
|
+
return data;
|
|
72
|
+
}
|
|
73
|
+
/* ------------------------------- customers ----------------------------- */
|
|
74
|
+
async createCustomer(details) {
|
|
75
|
+
const c = await this.call("POST", "/customers", {
|
|
76
|
+
name: details.name,
|
|
77
|
+
email: details.email,
|
|
78
|
+
metadata: details.metadata,
|
|
79
|
+
});
|
|
80
|
+
return { id: String(c.id), email: c.email, name: c.name };
|
|
81
|
+
}
|
|
82
|
+
async updateCustomer(id, details) {
|
|
83
|
+
const c = await this.call("POST", `/customers/${id}`, {
|
|
84
|
+
name: details.name,
|
|
85
|
+
email: details.email,
|
|
86
|
+
metadata: details.metadata,
|
|
87
|
+
});
|
|
88
|
+
return { id: String(c.id), email: c.email, name: c.name };
|
|
89
|
+
}
|
|
90
|
+
/* ----------------------------- subscriptions --------------------------- */
|
|
91
|
+
async createSubscription(params) {
|
|
92
|
+
const sub = await this.call("POST", "/subscriptions", {
|
|
93
|
+
customer: params.customer,
|
|
94
|
+
items: params.items.map((i) => ({ price: i.price, quantity: i.quantity })),
|
|
95
|
+
trial_end: params.trialEnd ? unix(params.trialEnd) : undefined,
|
|
96
|
+
default_payment_method: params.paymentMethod,
|
|
97
|
+
metadata: params.metadata,
|
|
98
|
+
expand: ["items.data.price"],
|
|
99
|
+
});
|
|
100
|
+
return this.normalize(sub);
|
|
101
|
+
}
|
|
102
|
+
async swapSubscription(id, params) {
|
|
103
|
+
const current = await this.call("GET", `/subscriptions/${id}`, {
|
|
104
|
+
"expand": ["items.data.price"],
|
|
105
|
+
});
|
|
106
|
+
const currentItems = (current.items?.data ?? []).map((i) => String(i.id));
|
|
107
|
+
// Map new prices onto existing item slots; delete any leftover items.
|
|
108
|
+
const items = params.items.map((ref, i) => i < currentItems.length
|
|
109
|
+
? { id: currentItems[i], price: ref.price, quantity: ref.quantity }
|
|
110
|
+
: { price: ref.price, quantity: ref.quantity });
|
|
111
|
+
for (let i = params.items.length; i < currentItems.length; i++) {
|
|
112
|
+
items.push({ id: currentItems[i], deleted: true });
|
|
113
|
+
}
|
|
114
|
+
const sub = await this.call("POST", `/subscriptions/${id}`, {
|
|
115
|
+
items,
|
|
116
|
+
proration_behavior: params.prorate === false ? "none" : "create_prorations",
|
|
117
|
+
expand: ["items.data.price"],
|
|
118
|
+
});
|
|
119
|
+
return this.normalize(sub);
|
|
120
|
+
}
|
|
121
|
+
async updateQuantity(id, quantity, opts) {
|
|
122
|
+
const current = await this.call("GET", `/subscriptions/${id}`);
|
|
123
|
+
const firstItem = current.items?.data?.[0];
|
|
124
|
+
if (!firstItem)
|
|
125
|
+
throw new BillingError("Subscription has no items to update.", "stripe");
|
|
126
|
+
const sub = await this.call("POST", `/subscriptions/${id}`, {
|
|
127
|
+
items: [{ id: String(firstItem.id), quantity }],
|
|
128
|
+
proration_behavior: opts?.prorate === false ? "none" : "create_prorations",
|
|
129
|
+
expand: ["items.data.price"],
|
|
130
|
+
});
|
|
131
|
+
return this.normalize(sub);
|
|
132
|
+
}
|
|
133
|
+
async cancelSubscription(id, params) {
|
|
134
|
+
if (params?.now) {
|
|
135
|
+
return this.normalize(await this.call("DELETE", `/subscriptions/${id}`));
|
|
136
|
+
}
|
|
137
|
+
return this.normalize(await this.call("POST", `/subscriptions/${id}`, {
|
|
138
|
+
cancel_at_period_end: true,
|
|
139
|
+
expand: ["items.data.price"],
|
|
140
|
+
}));
|
|
141
|
+
}
|
|
142
|
+
async resumeSubscription(id) {
|
|
143
|
+
return this.normalize(await this.call("POST", `/subscriptions/${id}`, {
|
|
144
|
+
cancel_at_period_end: false,
|
|
145
|
+
expand: ["items.data.price"],
|
|
146
|
+
}));
|
|
147
|
+
}
|
|
148
|
+
/* ------------------------------- charges ------------------------------- */
|
|
149
|
+
async charge(params) {
|
|
150
|
+
const intent = await this.call("POST", "/payment_intents", {
|
|
151
|
+
amount: params.amount,
|
|
152
|
+
currency: params.currency ?? "usd",
|
|
153
|
+
customer: params.customer,
|
|
154
|
+
payment_method: params.paymentMethod,
|
|
155
|
+
description: params.description,
|
|
156
|
+
metadata: params.metadata,
|
|
157
|
+
confirm: true,
|
|
158
|
+
off_session: true,
|
|
159
|
+
});
|
|
160
|
+
return {
|
|
161
|
+
id: String(intent.id),
|
|
162
|
+
status: String(intent.status),
|
|
163
|
+
amount: Number(intent.amount),
|
|
164
|
+
currency: String(intent.currency),
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
async refund(params) {
|
|
168
|
+
const key = params.charge.startsWith("pi_") ? "payment_intent" : "charge";
|
|
169
|
+
const refund = await this.call("POST", "/refunds", {
|
|
170
|
+
[key]: params.charge,
|
|
171
|
+
amount: params.amount,
|
|
172
|
+
});
|
|
173
|
+
return { id: String(refund.id), amount: Number(refund.amount ?? 0), status: String(refund.status) };
|
|
174
|
+
}
|
|
175
|
+
async listInvoices(customer) {
|
|
176
|
+
const res = await this.call("GET", "/invoices", { customer, limit: 100 });
|
|
177
|
+
const data = res.data ?? [];
|
|
178
|
+
return data.map((inv) => ({
|
|
179
|
+
id: String(inv.id),
|
|
180
|
+
number: inv.number ?? null,
|
|
181
|
+
total: Number(inv.total ?? 0),
|
|
182
|
+
currency: String(inv.currency ?? "usd"),
|
|
183
|
+
status: String(inv.status ?? ""),
|
|
184
|
+
date: inv.created ? new Date(Number(inv.created) * 1000) : null,
|
|
185
|
+
url: inv.hosted_invoice_url ?? null,
|
|
186
|
+
}));
|
|
187
|
+
}
|
|
188
|
+
/* ------------------------------ checkout ------------------------------- */
|
|
189
|
+
async createCheckoutSession(params) {
|
|
190
|
+
const session = await this.call("POST", "/checkout/sessions", {
|
|
191
|
+
mode: params.mode,
|
|
192
|
+
customer: params.customer,
|
|
193
|
+
line_items: params.items.map((i) => ({ price: i.price, quantity: i.quantity ?? 1 })),
|
|
194
|
+
success_url: params.successUrl,
|
|
195
|
+
cancel_url: params.cancelUrl,
|
|
196
|
+
allow_promotion_codes: params.allowPromotionCodes,
|
|
197
|
+
metadata: params.metadata,
|
|
198
|
+
subscription_data: params.mode === "subscription" && params.trialEnd
|
|
199
|
+
? { trial_end: unix(params.trialEnd) }
|
|
200
|
+
: undefined,
|
|
201
|
+
});
|
|
202
|
+
return { id: String(session.id), url: session.url ?? null, raw: session };
|
|
203
|
+
}
|
|
204
|
+
/* ------------------- optional gateway capabilities -------------------- */
|
|
205
|
+
async createSetupIntent(customer) {
|
|
206
|
+
const intent = await this.call("POST", "/setup_intents", { customer });
|
|
207
|
+
return { id: String(intent.id), clientSecret: intent.client_secret ?? null };
|
|
208
|
+
}
|
|
209
|
+
async paymentMethods(customer) {
|
|
210
|
+
const res = await this.call("GET", "/payment_methods", { customer, type: "card" });
|
|
211
|
+
const data = res.data ?? [];
|
|
212
|
+
return data.map((pm) => {
|
|
213
|
+
const card = pm.card ?? {};
|
|
214
|
+
return {
|
|
215
|
+
id: String(pm.id),
|
|
216
|
+
type: String(pm.type ?? "card"),
|
|
217
|
+
last4: card.last4 ?? null,
|
|
218
|
+
brand: card.brand ?? null,
|
|
219
|
+
};
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
/* ------------------------------ webhooks ------------------------------- */
|
|
223
|
+
async verifyWebhook(rawBody, headers, secret) {
|
|
224
|
+
const header = headers("stripe-signature");
|
|
225
|
+
if (!header || !secret)
|
|
226
|
+
return null;
|
|
227
|
+
const parts = Object.fromEntries(header.split(",").map((p) => {
|
|
228
|
+
const [k, v] = p.split("=");
|
|
229
|
+
return [k?.trim(), v?.trim()];
|
|
230
|
+
}));
|
|
231
|
+
if (!parts.t || !parts.v1)
|
|
232
|
+
return null;
|
|
233
|
+
const expected = await hmacSha256Hex(secret, `${parts.t}.${rawBody}`);
|
|
234
|
+
if (!constantTimeEqual(parts.v1, expected))
|
|
235
|
+
return null;
|
|
236
|
+
const event = JSON.parse(rawBody);
|
|
237
|
+
const object = event.data?.object ?? {};
|
|
238
|
+
const subscription = event.type.startsWith("customer.subscription.")
|
|
239
|
+
? this.normalize(object)
|
|
240
|
+
: undefined;
|
|
241
|
+
return {
|
|
242
|
+
id: event.id,
|
|
243
|
+
type: event.type,
|
|
244
|
+
payload: event,
|
|
245
|
+
subscription,
|
|
246
|
+
customer: subscription?.customer ?? object.customer,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
/* ------------------------------ mapping -------------------------------- */
|
|
250
|
+
normalize(sub) {
|
|
251
|
+
const items = (sub.items?.data ?? []).map((i) => {
|
|
252
|
+
const price = i.price ?? {};
|
|
253
|
+
return {
|
|
254
|
+
id: String(i.id),
|
|
255
|
+
product: String(price.product ?? ""),
|
|
256
|
+
price: String(price.id ?? ""),
|
|
257
|
+
quantity: i.quantity != null ? Number(i.quantity) : undefined,
|
|
258
|
+
};
|
|
259
|
+
});
|
|
260
|
+
const cancelAtPeriodEnd = Boolean(sub.cancel_at_period_end);
|
|
261
|
+
const endsAt = cancelAtPeriodEnd
|
|
262
|
+
? sub.current_period_end
|
|
263
|
+
? new Date(Number(sub.current_period_end) * 1000)
|
|
264
|
+
: null
|
|
265
|
+
: sub.canceled_at
|
|
266
|
+
? new Date(Number(sub.canceled_at) * 1000)
|
|
267
|
+
: null;
|
|
268
|
+
return {
|
|
269
|
+
id: String(sub.id),
|
|
270
|
+
status: String(sub.status ?? ""),
|
|
271
|
+
items,
|
|
272
|
+
quantity: sub.quantity != null ? Number(sub.quantity) : (items[0]?.quantity ?? null),
|
|
273
|
+
trialEndsAt: sub.trial_end ? new Date(Number(sub.trial_end) * 1000) : null,
|
|
274
|
+
endsAt,
|
|
275
|
+
customer: sub.customer ? String(sub.customer) : undefined,
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Billing events. Augmenting Keel's `EventsList` makes these payloads typed
|
|
3
|
+
* wherever `emit`/`listen` are used — `listen("billing.subscription.updated",
|
|
4
|
+
* (e) => …)` gets a checked `e`. Apps subscribe to drive their own side effects
|
|
5
|
+
* (grant access, send a receipt) off a verified webhook.
|
|
6
|
+
*/
|
|
7
|
+
export interface SubscriptionEvent {
|
|
8
|
+
gateway: string;
|
|
9
|
+
subscriptionId: number | string;
|
|
10
|
+
providerId: string;
|
|
11
|
+
status: string;
|
|
12
|
+
}
|
|
13
|
+
export interface WebhookReceivedEvent {
|
|
14
|
+
gateway: string;
|
|
15
|
+
type: string;
|
|
16
|
+
id: string;
|
|
17
|
+
}
|
|
18
|
+
declare module "../core/events.js" {
|
|
19
|
+
interface EventsList {
|
|
20
|
+
"billing.webhook.received": WebhookReceivedEvent;
|
|
21
|
+
"billing.subscription.created": SubscriptionEvent;
|
|
22
|
+
"billing.subscription.updated": SubscriptionEvent;
|
|
23
|
+
"billing.subscription.deleted": SubscriptionEvent;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Billing events. Augmenting Keel's `EventsList` makes these payloads typed
|
|
3
|
+
* wherever `emit`/`listen` are used — `listen("billing.subscription.updated",
|
|
4
|
+
* (e) => …)` gets a checked `e`. Apps subscribe to drive their own side effects
|
|
5
|
+
* (grant access, send a receipt) off a verified webhook.
|
|
6
|
+
*/
|
|
7
|
+
export {};
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The gateway seam. `BillingGateway` is the one interface every payment
|
|
3
|
+
* provider implements — Stripe, Paddle, or the in-memory Fake used in tests.
|
|
4
|
+
* Everything above it (the `Billable` mixin, the `Subscription` model, the
|
|
5
|
+
* webhook handler) is written against these gateway-neutral DTOs and never
|
|
6
|
+
* touches a provider SDK.
|
|
7
|
+
*
|
|
8
|
+
* Money is always an integer in the smallest currency unit (cents), matching
|
|
9
|
+
* both Stripe and Paddle. Dates are real `Date`s. Provider-specific identifiers
|
|
10
|
+
* are opaque strings.
|
|
11
|
+
*
|
|
12
|
+
* Optional methods (`createSetupIntent`, `paymentMethods`) are capabilities not
|
|
13
|
+
* every gateway has — Paddle is hosted-checkout and never sees a raw card. The
|
|
14
|
+
* `Billable` mixin throws a `BillingError` when the active gateway lacks one.
|
|
15
|
+
*/
|
|
16
|
+
/** A lookup over request headers (Hono's `c.req.header` satisfies this). */
|
|
17
|
+
export type HeaderBag = (name: string) => string | undefined | null;
|
|
18
|
+
/** Customer details we sync up to the gateway. */
|
|
19
|
+
export interface CustomerDetails {
|
|
20
|
+
name?: string;
|
|
21
|
+
email?: string;
|
|
22
|
+
metadata?: Record<string, string>;
|
|
23
|
+
}
|
|
24
|
+
export interface GatewayCustomer {
|
|
25
|
+
id: string;
|
|
26
|
+
email?: string;
|
|
27
|
+
name?: string;
|
|
28
|
+
}
|
|
29
|
+
/** A price and how many of it — one line of a subscription. */
|
|
30
|
+
export interface PriceRef {
|
|
31
|
+
price: string;
|
|
32
|
+
quantity?: number;
|
|
33
|
+
}
|
|
34
|
+
export interface CreateSubscriptionParams {
|
|
35
|
+
customer: string;
|
|
36
|
+
items: PriceRef[];
|
|
37
|
+
/** Absolute trial end; omit for no trial. */
|
|
38
|
+
trialEnd?: Date;
|
|
39
|
+
/** A payment method to bill (Stripe pm id). */
|
|
40
|
+
paymentMethod?: string;
|
|
41
|
+
metadata?: Record<string, string>;
|
|
42
|
+
}
|
|
43
|
+
export interface GatewaySubscriptionItem {
|
|
44
|
+
id: string;
|
|
45
|
+
product: string;
|
|
46
|
+
price: string;
|
|
47
|
+
quantity?: number;
|
|
48
|
+
}
|
|
49
|
+
/** The normalized shape a gateway returns for any subscription read/write. */
|
|
50
|
+
export interface GatewaySubscription {
|
|
51
|
+
id: string;
|
|
52
|
+
/** Provider status verbatim (`active`, `trialing`, `past_due`, `paused`, …). */
|
|
53
|
+
status: string;
|
|
54
|
+
items: GatewaySubscriptionItem[];
|
|
55
|
+
quantity?: number | null;
|
|
56
|
+
trialEndsAt?: Date | null;
|
|
57
|
+
/** When a cancel-at-period-end subscription actually ends (the grace period). */
|
|
58
|
+
endsAt?: Date | null;
|
|
59
|
+
/** The gateway customer this subscription belongs to, when known. */
|
|
60
|
+
customer?: string;
|
|
61
|
+
}
|
|
62
|
+
export interface SwapParams {
|
|
63
|
+
items: PriceRef[];
|
|
64
|
+
/** Prorate the change; gateways default to true. */
|
|
65
|
+
prorate?: boolean;
|
|
66
|
+
}
|
|
67
|
+
export interface CancelParams {
|
|
68
|
+
/** Cancel immediately instead of at period end. */
|
|
69
|
+
now?: boolean;
|
|
70
|
+
}
|
|
71
|
+
export interface ChargeParams {
|
|
72
|
+
customer: string;
|
|
73
|
+
amount: number;
|
|
74
|
+
currency?: string;
|
|
75
|
+
paymentMethod?: string;
|
|
76
|
+
description?: string;
|
|
77
|
+
metadata?: Record<string, string>;
|
|
78
|
+
}
|
|
79
|
+
export interface GatewayCharge {
|
|
80
|
+
id: string;
|
|
81
|
+
status: string;
|
|
82
|
+
amount: number;
|
|
83
|
+
currency: string;
|
|
84
|
+
}
|
|
85
|
+
export interface RefundParams {
|
|
86
|
+
/** The charge or payment-intent id to refund. */
|
|
87
|
+
charge: string;
|
|
88
|
+
/** Partial amount; omit to refund in full. */
|
|
89
|
+
amount?: number;
|
|
90
|
+
}
|
|
91
|
+
export interface GatewayRefund {
|
|
92
|
+
id: string;
|
|
93
|
+
amount: number;
|
|
94
|
+
status: string;
|
|
95
|
+
}
|
|
96
|
+
export interface GatewayInvoice {
|
|
97
|
+
id: string;
|
|
98
|
+
number?: string | null;
|
|
99
|
+
total: number;
|
|
100
|
+
currency: string;
|
|
101
|
+
status: string;
|
|
102
|
+
date?: Date | null;
|
|
103
|
+
/** Hosted invoice / PDF url when the gateway exposes one. */
|
|
104
|
+
url?: string | null;
|
|
105
|
+
}
|
|
106
|
+
export interface CheckoutParams {
|
|
107
|
+
mode: "subscription" | "payment";
|
|
108
|
+
customer?: string;
|
|
109
|
+
items: PriceRef[];
|
|
110
|
+
successUrl?: string;
|
|
111
|
+
cancelUrl?: string;
|
|
112
|
+
trialEnd?: Date;
|
|
113
|
+
metadata?: Record<string, string>;
|
|
114
|
+
allowPromotionCodes?: boolean;
|
|
115
|
+
}
|
|
116
|
+
/** A hosted checkout handle: a redirect `url` (Stripe) or a `clientToken` (Paddle). */
|
|
117
|
+
export interface CheckoutSession {
|
|
118
|
+
id: string;
|
|
119
|
+
url?: string | null;
|
|
120
|
+
clientToken?: string | null;
|
|
121
|
+
raw?: unknown;
|
|
122
|
+
}
|
|
123
|
+
export interface SetupIntent {
|
|
124
|
+
id: string;
|
|
125
|
+
clientSecret: string | null;
|
|
126
|
+
}
|
|
127
|
+
export interface PaymentMethod {
|
|
128
|
+
id: string;
|
|
129
|
+
type: string;
|
|
130
|
+
last4?: string | null;
|
|
131
|
+
brand?: string | null;
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* A verified inbound webhook, normalized. When the event concerns a
|
|
135
|
+
* subscription the gateway extracts it into `subscription` so the webhook
|
|
136
|
+
* handler can upsert local state without any provider-specific parsing.
|
|
137
|
+
*/
|
|
138
|
+
export interface WebhookEvent {
|
|
139
|
+
id: string;
|
|
140
|
+
type: string;
|
|
141
|
+
payload: Record<string, unknown>;
|
|
142
|
+
subscription?: GatewaySubscription;
|
|
143
|
+
customer?: string;
|
|
144
|
+
}
|
|
145
|
+
export interface BillingGateway {
|
|
146
|
+
/** The registry name — "stripe", "paddle", "fake". */
|
|
147
|
+
readonly name: string;
|
|
148
|
+
createCustomer(details: CustomerDetails): Promise<GatewayCustomer>;
|
|
149
|
+
updateCustomer(id: string, details: CustomerDetails): Promise<GatewayCustomer>;
|
|
150
|
+
createSubscription(params: CreateSubscriptionParams): Promise<GatewaySubscription>;
|
|
151
|
+
swapSubscription(id: string, params: SwapParams): Promise<GatewaySubscription>;
|
|
152
|
+
updateQuantity(id: string, quantity: number, opts?: {
|
|
153
|
+
prorate?: boolean;
|
|
154
|
+
}): Promise<GatewaySubscription>;
|
|
155
|
+
cancelSubscription(id: string, params?: CancelParams): Promise<GatewaySubscription>;
|
|
156
|
+
resumeSubscription(id: string): Promise<GatewaySubscription>;
|
|
157
|
+
charge(params: ChargeParams): Promise<GatewayCharge>;
|
|
158
|
+
refund(params: RefundParams): Promise<GatewayRefund>;
|
|
159
|
+
listInvoices(customer: string): Promise<GatewayInvoice[]>;
|
|
160
|
+
createCheckoutSession(params: CheckoutParams): Promise<CheckoutSession>;
|
|
161
|
+
/** Verify a raw webhook body + headers against `secret`; null if unrecognized. */
|
|
162
|
+
verifyWebhook(rawBody: string, headers: HeaderBag, secret: string): Promise<WebhookEvent | null>;
|
|
163
|
+
createSetupIntent?(customer: string): Promise<SetupIntent>;
|
|
164
|
+
paymentMethods?(customer: string): Promise<PaymentMethod[]>;
|
|
165
|
+
}
|
|
166
|
+
/** Raised for gateway/config problems and unsupported-capability calls. */
|
|
167
|
+
export declare class BillingError extends Error {
|
|
168
|
+
readonly gateway?: string | undefined;
|
|
169
|
+
constructor(message: string, gateway?: string | undefined);
|
|
170
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The gateway seam. `BillingGateway` is the one interface every payment
|
|
3
|
+
* provider implements — Stripe, Paddle, or the in-memory Fake used in tests.
|
|
4
|
+
* Everything above it (the `Billable` mixin, the `Subscription` model, the
|
|
5
|
+
* webhook handler) is written against these gateway-neutral DTOs and never
|
|
6
|
+
* touches a provider SDK.
|
|
7
|
+
*
|
|
8
|
+
* Money is always an integer in the smallest currency unit (cents), matching
|
|
9
|
+
* both Stripe and Paddle. Dates are real `Date`s. Provider-specific identifiers
|
|
10
|
+
* are opaque strings.
|
|
11
|
+
*
|
|
12
|
+
* Optional methods (`createSetupIntent`, `paymentMethods`) are capabilities not
|
|
13
|
+
* every gateway has — Paddle is hosted-checkout and never sees a raw card. The
|
|
14
|
+
* `Billable` mixin throws a `BillingError` when the active gateway lacks one.
|
|
15
|
+
*/
|
|
16
|
+
/** Raised for gateway/config problems and unsupported-capability calls. */
|
|
17
|
+
export class BillingError extends Error {
|
|
18
|
+
gateway;
|
|
19
|
+
constructor(message, gateway) {
|
|
20
|
+
super(message);
|
|
21
|
+
this.gateway = gateway;
|
|
22
|
+
this.name = "BillingError";
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keel Billing — public surface, imported from `@shaferllc/keel/billing`.
|
|
3
|
+
*
|
|
4
|
+
* import { BillingServiceProvider, Billable } from "@shaferllc/keel/billing";
|
|
5
|
+
* class User extends Billable(Model) { static table = "users"; }
|
|
6
|
+
*/
|
|
7
|
+
import "./events.js";
|
|
8
|
+
export { BillingServiceProvider } from "./provider.js";
|
|
9
|
+
export { Billable } from "./billable.js";
|
|
10
|
+
export type { ChargeOptions, ProductCheckoutOptions } from "./billable.js";
|
|
11
|
+
export { Subscription, toRefs } from "./subscription.js";
|
|
12
|
+
export type { PriceArg } from "./subscription.js";
|
|
13
|
+
export { SubscriptionItem } from "./subscription-item.js";
|
|
14
|
+
export { SubscriptionBuilder } from "./builder.js";
|
|
15
|
+
export type { BillableTarget, CheckoutOptions } from "./builder.js";
|
|
16
|
+
export { BillingManager, billing, setBilling } from "./manager.js";
|
|
17
|
+
export type { GatewayFactory } from "./manager.js";
|
|
18
|
+
export { BillingError } from "./gateway.js";
|
|
19
|
+
export type { BillingGateway, HeaderBag, CustomerDetails, GatewayCustomer, PriceRef, CreateSubscriptionParams, GatewaySubscription, GatewaySubscriptionItem, SwapParams, CancelParams, ChargeParams, GatewayCharge, RefundParams, GatewayRefund, GatewayInvoice, CheckoutParams, CheckoutSession, SetupIntent, PaymentMethod, WebhookEvent, } from "./gateway.js";
|
|
20
|
+
export { registerDefaultGateways, StripeGateway, PaddleGateway, FakeGateway } from "./drivers/index.js";
|
|
21
|
+
export type { FakeCall } from "./drivers/index.js";
|
|
22
|
+
export { handleWebhook, resolveBillableUsing } from "./webhooks.js";
|
|
23
|
+
export type { WebhookResult, BillableResolver } from "./webhooks.js";
|
|
24
|
+
export { registerBillingRoutes } from "./routes.js";
|
|
25
|
+
export { billingMigration } from "./migration.js";
|
|
26
|
+
export { defaultConfig, resolveConfig } from "./config.js";
|
|
27
|
+
export type { BillingConfig, StripeGatewayConfig, PaddleGatewayConfig } from "./config.js";
|
|
28
|
+
export type { SubscriptionEvent, WebhookReceivedEvent } from "./events.js";
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keel Billing — public surface, imported from `@shaferllc/keel/billing`.
|
|
3
|
+
*
|
|
4
|
+
* import { BillingServiceProvider, Billable } from "@shaferllc/keel/billing";
|
|
5
|
+
* class User extends Billable(Model) { static table = "users"; }
|
|
6
|
+
*/
|
|
7
|
+
import "./events.js"; // load the EventsList augmentation
|
|
8
|
+
export { BillingServiceProvider } from "./provider.js";
|
|
9
|
+
export { Billable } from "./billable.js";
|
|
10
|
+
export { Subscription, toRefs } from "./subscription.js";
|
|
11
|
+
export { SubscriptionItem } from "./subscription-item.js";
|
|
12
|
+
export { SubscriptionBuilder } from "./builder.js";
|
|
13
|
+
export { BillingManager, billing, setBilling } from "./manager.js";
|
|
14
|
+
export { BillingError } from "./gateway.js";
|
|
15
|
+
export { registerDefaultGateways, StripeGateway, PaddleGateway, FakeGateway } from "./drivers/index.js";
|
|
16
|
+
export { handleWebhook, resolveBillableUsing } from "./webhooks.js";
|
|
17
|
+
export { registerBillingRoutes } from "./routes.js";
|
|
18
|
+
export { billingMigration } from "./migration.js";
|
|
19
|
+
export { defaultConfig, resolveConfig } from "./config.js";
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The billing manager — a small driver registry, exactly like Keel's mail and
|
|
3
|
+
* queue managers. It holds the configured `default` gateway and lazily builds
|
|
4
|
+
* each driver from a registered factory. Drivers register themselves via
|
|
5
|
+
* `registerDefaultGateways` (see `drivers/index.ts`) so this module stays free
|
|
6
|
+
* of any provider SDK.
|
|
7
|
+
*
|
|
8
|
+
* A model instance has no DI container, so — following the framework's
|
|
9
|
+
* `setConnection`/`setLogger`/`setScheduler` pattern — the active manager also
|
|
10
|
+
* lives in a module-level singleton. The `Billable` mixin and `Subscription`
|
|
11
|
+
* model reach it with `billing()`.
|
|
12
|
+
*/
|
|
13
|
+
import type { BillingConfig } from "./config.js";
|
|
14
|
+
import { type BillingGateway } from "./gateway.js";
|
|
15
|
+
/** Builds a gateway from its slice of config. */
|
|
16
|
+
export type GatewayFactory = (gatewayConfig: Record<string, unknown>, billing: BillingConfig) => BillingGateway;
|
|
17
|
+
export declare class BillingManager {
|
|
18
|
+
private cfg;
|
|
19
|
+
private factories;
|
|
20
|
+
private resolved;
|
|
21
|
+
constructor(cfg: BillingConfig);
|
|
22
|
+
/** Register (or replace) a gateway factory under `name`. */
|
|
23
|
+
register(name: string, factory: GatewayFactory): this;
|
|
24
|
+
/** The effective billing config. */
|
|
25
|
+
config(): BillingConfig;
|
|
26
|
+
/** The webhook signing secret for a gateway (empty string if unset). */
|
|
27
|
+
webhookSecret(name?: string): string;
|
|
28
|
+
/** Resolve a gateway driver, building and caching it on first use. */
|
|
29
|
+
gateway(name?: string): BillingGateway;
|
|
30
|
+
}
|
|
31
|
+
/** Install the active billing manager (called by the provider's `register`). */
|
|
32
|
+
export declare function setBilling(manager: BillingManager | undefined): void;
|
|
33
|
+
/** The active billing manager. Throws if the billing provider isn't registered. */
|
|
34
|
+
export declare function billing(): BillingManager;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The billing manager — a small driver registry, exactly like Keel's mail and
|
|
3
|
+
* queue managers. It holds the configured `default` gateway and lazily builds
|
|
4
|
+
* each driver from a registered factory. Drivers register themselves via
|
|
5
|
+
* `registerDefaultGateways` (see `drivers/index.ts`) so this module stays free
|
|
6
|
+
* of any provider SDK.
|
|
7
|
+
*
|
|
8
|
+
* A model instance has no DI container, so — following the framework's
|
|
9
|
+
* `setConnection`/`setLogger`/`setScheduler` pattern — the active manager also
|
|
10
|
+
* lives in a module-level singleton. The `Billable` mixin and `Subscription`
|
|
11
|
+
* model reach it with `billing()`.
|
|
12
|
+
*/
|
|
13
|
+
import { BillingError } from "./gateway.js";
|
|
14
|
+
export class BillingManager {
|
|
15
|
+
cfg;
|
|
16
|
+
factories = new Map();
|
|
17
|
+
resolved = new Map();
|
|
18
|
+
constructor(cfg) {
|
|
19
|
+
this.cfg = cfg;
|
|
20
|
+
}
|
|
21
|
+
/** Register (or replace) a gateway factory under `name`. */
|
|
22
|
+
register(name, factory) {
|
|
23
|
+
this.factories.set(name, factory);
|
|
24
|
+
this.resolved.delete(name);
|
|
25
|
+
return this;
|
|
26
|
+
}
|
|
27
|
+
/** The effective billing config. */
|
|
28
|
+
config() {
|
|
29
|
+
return this.cfg;
|
|
30
|
+
}
|
|
31
|
+
/** The webhook signing secret for a gateway (empty string if unset). */
|
|
32
|
+
webhookSecret(name = this.cfg.default) {
|
|
33
|
+
const gw = this.cfg.gateways[name];
|
|
34
|
+
return gw?.webhookSecret ?? "";
|
|
35
|
+
}
|
|
36
|
+
/** Resolve a gateway driver, building and caching it on first use. */
|
|
37
|
+
gateway(name = this.cfg.default) {
|
|
38
|
+
const cached = this.resolved.get(name);
|
|
39
|
+
if (cached)
|
|
40
|
+
return cached;
|
|
41
|
+
const factory = this.factories.get(name);
|
|
42
|
+
if (!factory) {
|
|
43
|
+
throw new BillingError(`No billing gateway registered for "${name}". Registered: ${[...this.factories.keys()].join(", ") || "none"}.`, name);
|
|
44
|
+
}
|
|
45
|
+
const gateway = factory(this.cfg.gateways[name] ?? {}, this.cfg);
|
|
46
|
+
this.resolved.set(name, gateway);
|
|
47
|
+
return gateway;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
let current;
|
|
51
|
+
/** Install the active billing manager (called by the provider's `register`). */
|
|
52
|
+
export function setBilling(manager) {
|
|
53
|
+
current = manager;
|
|
54
|
+
}
|
|
55
|
+
/** The active billing manager. Throws if the billing provider isn't registered. */
|
|
56
|
+
export function billing() {
|
|
57
|
+
if (!current) {
|
|
58
|
+
throw new BillingError("Billing is not configured. Register BillingServiceProvider (or call setBilling()) first.");
|
|
59
|
+
}
|
|
60
|
+
return current;
|
|
61
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The billing schema — gateway-neutral so one set of tables serves Stripe and
|
|
3
|
+
* Paddle. `subscriptions` and `subscription_items` are created through the
|
|
4
|
+
* fluent builder; the columns added to the existing billable table (`users`)
|
|
5
|
+
* and the secondary indexes go through `schema.raw()` because the builder has
|
|
6
|
+
* no `alterTable`/`index` (see src/core/migrations.ts).
|
|
7
|
+
*
|
|
8
|
+
* The `ADD COLUMN` / `CREATE INDEX` SQL is kept to the intersection that sqlite,
|
|
9
|
+
* mysql, and postgres all accept, and each runs exactly once (the migrator
|
|
10
|
+
* tracks applied migrations by name).
|
|
11
|
+
*/
|
|
12
|
+
import type { Migration } from "../core/migrations.js";
|
|
13
|
+
export declare function billingMigration(billableTable?: string): Migration;
|