@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,68 @@
|
|
|
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
|
+
export function billingMigration(billableTable = "users") {
|
|
13
|
+
return {
|
|
14
|
+
name: "billing_00_create_subscriptions",
|
|
15
|
+
async up(schema) {
|
|
16
|
+
await schema.createTable("subscriptions", (t) => {
|
|
17
|
+
t.id();
|
|
18
|
+
t.integer("billable_id");
|
|
19
|
+
t.string("billable_type").default("User");
|
|
20
|
+
t.string("type", 64).default("default");
|
|
21
|
+
t.string("gateway", 32);
|
|
22
|
+
t.string("provider_id").unique();
|
|
23
|
+
t.string("provider_status", 64);
|
|
24
|
+
t.json("provider_price").nullable();
|
|
25
|
+
t.integer("quantity").nullable();
|
|
26
|
+
t.timestamp("trial_ends_at").nullable();
|
|
27
|
+
t.timestamp("starts_at").nullable();
|
|
28
|
+
t.timestamp("ends_at").nullable();
|
|
29
|
+
t.timestamp("paused_at").nullable();
|
|
30
|
+
t.timestamps();
|
|
31
|
+
});
|
|
32
|
+
await schema.createTable("subscription_items", (t) => {
|
|
33
|
+
t.id();
|
|
34
|
+
t.integer("subscription_id");
|
|
35
|
+
t.string("provider_id").unique();
|
|
36
|
+
t.string("provider_product");
|
|
37
|
+
t.string("provider_price");
|
|
38
|
+
t.integer("quantity").nullable();
|
|
39
|
+
t.timestamps();
|
|
40
|
+
});
|
|
41
|
+
await schema.raw("CREATE INDEX idx_subscriptions_billable ON subscriptions (billable_id, billable_type)");
|
|
42
|
+
await schema.raw("CREATE INDEX idx_subscription_items_subscription ON subscription_items (subscription_id)");
|
|
43
|
+
// Billing columns on the billable (customer) table.
|
|
44
|
+
for (const col of [
|
|
45
|
+
"billing_gateway VARCHAR(32)",
|
|
46
|
+
"billing_customer_id VARCHAR(255)",
|
|
47
|
+
"pm_type VARCHAR(32)",
|
|
48
|
+
"pm_last_four VARCHAR(8)",
|
|
49
|
+
"trial_ends_at TIMESTAMP",
|
|
50
|
+
]) {
|
|
51
|
+
await schema.raw(`ALTER TABLE ${billableTable} ADD COLUMN ${col}`);
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
async down(schema) {
|
|
55
|
+
await schema.dropTable("subscription_items");
|
|
56
|
+
await schema.dropTable("subscriptions");
|
|
57
|
+
for (const col of [
|
|
58
|
+
"billing_gateway",
|
|
59
|
+
"billing_customer_id",
|
|
60
|
+
"pm_type",
|
|
61
|
+
"pm_last_four",
|
|
62
|
+
"trial_ends_at",
|
|
63
|
+
]) {
|
|
64
|
+
await schema.raw(`ALTER TABLE ${billableTable} DROP COLUMN ${col}`).catch(() => { });
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keel Billing — a Cashier-style port covering Stripe and Paddle, shipped as a
|
|
3
|
+
* Keel package. One line in `bootstrap/providers.ts` turns it on:
|
|
4
|
+
*
|
|
5
|
+
* app.register(BillingServiceProvider)
|
|
6
|
+
*
|
|
7
|
+
* `register()` merges config, wires the gateway drivers onto a manager, installs
|
|
8
|
+
* that manager as the module singleton (so `Billable` models can reach it
|
|
9
|
+
* without DI), contributes the schema migration, and declares the publishable
|
|
10
|
+
* config stub. `boot()` mounts the webhook route.
|
|
11
|
+
*/
|
|
12
|
+
import { PackageProvider } from "../core/package.js";
|
|
13
|
+
export declare class BillingServiceProvider extends PackageProvider {
|
|
14
|
+
readonly name = "billing";
|
|
15
|
+
private config;
|
|
16
|
+
private manager;
|
|
17
|
+
register(): void;
|
|
18
|
+
boot(): void;
|
|
19
|
+
shutdown(): void;
|
|
20
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keel Billing — a Cashier-style port covering Stripe and Paddle, shipped as a
|
|
3
|
+
* Keel package. One line in `bootstrap/providers.ts` turns it on:
|
|
4
|
+
*
|
|
5
|
+
* app.register(BillingServiceProvider)
|
|
6
|
+
*
|
|
7
|
+
* `register()` merges config, wires the gateway drivers onto a manager, installs
|
|
8
|
+
* that manager as the module singleton (so `Billable` models can reach it
|
|
9
|
+
* without DI), contributes the schema migration, and declares the publishable
|
|
10
|
+
* config stub. `boot()` mounts the webhook route.
|
|
11
|
+
*/
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
13
|
+
import { dirname, join } from "node:path";
|
|
14
|
+
import { PackageProvider } from "../core/package.js";
|
|
15
|
+
import { resolveConfig, defaultConfig } from "./config.js";
|
|
16
|
+
import { BillingManager, setBilling } from "./manager.js";
|
|
17
|
+
import { registerDefaultGateways } from "./drivers/index.js";
|
|
18
|
+
import { billingMigration } from "./migration.js";
|
|
19
|
+
import { registerBillingRoutes } from "./routes.js";
|
|
20
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
21
|
+
export class BillingServiceProvider extends PackageProvider {
|
|
22
|
+
name = "billing";
|
|
23
|
+
config;
|
|
24
|
+
manager;
|
|
25
|
+
register() {
|
|
26
|
+
this.mergeConfig("billing", defaultConfig);
|
|
27
|
+
this.config = resolveConfig();
|
|
28
|
+
this.manager = new BillingManager(this.config);
|
|
29
|
+
registerDefaultGateways(this.manager);
|
|
30
|
+
setBilling(this.manager);
|
|
31
|
+
this.app.instance(BillingManager, this.manager);
|
|
32
|
+
// Cashier targets the standard `users` billable table.
|
|
33
|
+
this.migrations([billingMigration("users")]);
|
|
34
|
+
this.publishes({ [join(here, "billing.config.stub")]: "config/billing.ts" }, "billing-config");
|
|
35
|
+
}
|
|
36
|
+
boot() {
|
|
37
|
+
this.routes((r) => registerBillingRoutes(r, this.config.webhook.path));
|
|
38
|
+
}
|
|
39
|
+
shutdown() {
|
|
40
|
+
setBilling(undefined);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The billing HTTP surface: one webhook endpoint per gateway. The raw request
|
|
3
|
+
* body is read with `c.req.text()` (never `c.req.json()` first — the body stream
|
|
4
|
+
* is single-use and signature verification needs the exact bytes), and the
|
|
5
|
+
* `:gateway` segment selects which driver verifies it.
|
|
6
|
+
*
|
|
7
|
+
* Mounted by the provider at the configured `webhook.path` — e.g.
|
|
8
|
+
* `POST /billing/webhook/stripe`.
|
|
9
|
+
*/
|
|
10
|
+
import type { Router } from "../core/http/router.js";
|
|
11
|
+
export declare function registerBillingRoutes(r: Router, webhookPath: string): void;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The billing HTTP surface: one webhook endpoint per gateway. The raw request
|
|
3
|
+
* body is read with `c.req.text()` (never `c.req.json()` first — the body stream
|
|
4
|
+
* is single-use and signature verification needs the exact bytes), and the
|
|
5
|
+
* `:gateway` segment selects which driver verifies it.
|
|
6
|
+
*
|
|
7
|
+
* Mounted by the provider at the configured `webhook.path` — e.g.
|
|
8
|
+
* `POST /billing/webhook/stripe`.
|
|
9
|
+
*/
|
|
10
|
+
import { handleWebhook } from "./webhooks.js";
|
|
11
|
+
export function registerBillingRoutes(r, webhookPath) {
|
|
12
|
+
const base = "/" + webhookPath.replace(/^\/|\/$/g, "");
|
|
13
|
+
r.post(`${base}/:gateway`, async (c) => {
|
|
14
|
+
const gateway = c.req.param("gateway") ?? "";
|
|
15
|
+
const rawBody = await c.req.text();
|
|
16
|
+
const result = await handleWebhook(gateway, rawBody, (name) => c.req.header(name));
|
|
17
|
+
if (!result.ok)
|
|
18
|
+
return c.json({ received: false }, 400);
|
|
19
|
+
return c.json({ received: true });
|
|
20
|
+
}).name("webhook");
|
|
21
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One line of a subscription — a single price and its quantity. A single-price
|
|
3
|
+
* subscription has exactly one item; a multi-product subscription has several.
|
|
4
|
+
* Mirrors Cashier's `subscription_items` table.
|
|
5
|
+
*/
|
|
6
|
+
import { Model } from "../core/model.js";
|
|
7
|
+
import type { Casts } from "../core/casts.js";
|
|
8
|
+
export declare class SubscriptionItem extends Model {
|
|
9
|
+
static table: string;
|
|
10
|
+
static timestamps: boolean;
|
|
11
|
+
static casts: Casts;
|
|
12
|
+
id: number;
|
|
13
|
+
subscription_id: number;
|
|
14
|
+
provider_id: string;
|
|
15
|
+
provider_product: string;
|
|
16
|
+
provider_price: string;
|
|
17
|
+
quantity: number | null;
|
|
18
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One line of a subscription — a single price and its quantity. A single-price
|
|
3
|
+
* subscription has exactly one item; a multi-product subscription has several.
|
|
4
|
+
* Mirrors Cashier's `subscription_items` table.
|
|
5
|
+
*/
|
|
6
|
+
import { Model } from "../core/model.js";
|
|
7
|
+
export class SubscriptionItem extends Model {
|
|
8
|
+
static table = "subscription_items";
|
|
9
|
+
static timestamps = true;
|
|
10
|
+
static casts = { quantity: "int" };
|
|
11
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A subscription — the local mirror of a gateway subscription, and the object
|
|
3
|
+
* you act on. Status questions (`active`, `onTrial`, `onGracePeriod`, …) are
|
|
4
|
+
* answered from local columns; mutations (`swap`, `updateQuantity`, `cancel`,
|
|
5
|
+
* `resume`) call the active gateway and then sync the result back.
|
|
6
|
+
*
|
|
7
|
+
* if (subscription.onGracePeriod()) await subscription.resume();
|
|
8
|
+
* await subscription.swap("price_pro");
|
|
9
|
+
* await subscription.incrementQuantity(2);
|
|
10
|
+
*/
|
|
11
|
+
import { Model } from "../core/model.js";
|
|
12
|
+
import type { Casts } from "../core/casts.js";
|
|
13
|
+
import { SubscriptionItem } from "./subscription-item.js";
|
|
14
|
+
import type { GatewaySubscription, PriceRef } from "./gateway.js";
|
|
15
|
+
/** A price argument: a bare id, or `{ price, quantity }`. */
|
|
16
|
+
export type PriceArg = string | PriceRef;
|
|
17
|
+
export declare function toRefs(prices: PriceArg | PriceArg[]): PriceRef[];
|
|
18
|
+
export declare class Subscription extends Model {
|
|
19
|
+
static table: string;
|
|
20
|
+
static timestamps: boolean;
|
|
21
|
+
static casts: Casts;
|
|
22
|
+
id: number;
|
|
23
|
+
billable_id: number;
|
|
24
|
+
billable_type: string;
|
|
25
|
+
type: string;
|
|
26
|
+
gateway: string;
|
|
27
|
+
provider_id: string;
|
|
28
|
+
provider_status: string;
|
|
29
|
+
provider_price: string[] | null;
|
|
30
|
+
quantity: number | null;
|
|
31
|
+
trial_ends_at: Date | null;
|
|
32
|
+
starts_at: Date | null;
|
|
33
|
+
ends_at: Date | null;
|
|
34
|
+
paused_at: Date | null;
|
|
35
|
+
/** The subscription's line items. */
|
|
36
|
+
items(): import("../core/relations.js").HasMany<SubscriptionItem>;
|
|
37
|
+
/** On an unexpired trial. */
|
|
38
|
+
onTrial(): boolean;
|
|
39
|
+
hasExpiredTrial(): boolean;
|
|
40
|
+
/** Cancellation has been initiated (whether or not the grace period is over). */
|
|
41
|
+
canceled(): boolean;
|
|
42
|
+
/** Canceled, but the paid period hasn't elapsed yet — still usable. */
|
|
43
|
+
onGracePeriod(): boolean;
|
|
44
|
+
/** Cancellation is complete and the period has elapsed. */
|
|
45
|
+
ended(): boolean;
|
|
46
|
+
paused(): boolean;
|
|
47
|
+
/** The gateway reports a valid, live status and it hasn't ended. */
|
|
48
|
+
active(): boolean;
|
|
49
|
+
/** Active and past any trial. */
|
|
50
|
+
recurring(): boolean;
|
|
51
|
+
/** Usable right now: active, on trial, or within the grace period. */
|
|
52
|
+
valid(): boolean;
|
|
53
|
+
hasIncompletePayment(): boolean;
|
|
54
|
+
/** Whether this subscription includes the given price. */
|
|
55
|
+
hasPrice(price: string): boolean;
|
|
56
|
+
/** Swap to a new price (or set of prices). */
|
|
57
|
+
swap(prices: PriceArg | PriceArg[], opts?: {
|
|
58
|
+
prorate?: boolean;
|
|
59
|
+
}): Promise<this>;
|
|
60
|
+
incrementQuantity(count?: number): Promise<this>;
|
|
61
|
+
decrementQuantity(count?: number): Promise<this>;
|
|
62
|
+
updateQuantity(quantity: number, opts?: {
|
|
63
|
+
prorate?: boolean;
|
|
64
|
+
}): Promise<this>;
|
|
65
|
+
/** Cancel at period end — keeps access through the grace period. */
|
|
66
|
+
cancel(): Promise<this>;
|
|
67
|
+
/** Cancel immediately. */
|
|
68
|
+
cancelNow(): Promise<this>;
|
|
69
|
+
/** Resume a subscription still within its grace period. */
|
|
70
|
+
resume(): Promise<this>;
|
|
71
|
+
/** End the trial immediately (local state; the next invoice bills right away). */
|
|
72
|
+
endTrial(): Promise<this>;
|
|
73
|
+
/** Extend (or set) the trial end. */
|
|
74
|
+
extendTrial(until: Date): Promise<this>;
|
|
75
|
+
/** The gateway this subscription lives on. */
|
|
76
|
+
private gw;
|
|
77
|
+
/**
|
|
78
|
+
* Fold a gateway subscription back into local columns + items, then persist.
|
|
79
|
+
* The single source of truth for status flows through here (also used by the
|
|
80
|
+
* webhook handler).
|
|
81
|
+
*/
|
|
82
|
+
syncFromGateway(remote: GatewaySubscription): Promise<this>;
|
|
83
|
+
/** Replace this subscription's items with the gateway's current set. */
|
|
84
|
+
private syncItems;
|
|
85
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A subscription — the local mirror of a gateway subscription, and the object
|
|
3
|
+
* you act on. Status questions (`active`, `onTrial`, `onGracePeriod`, …) are
|
|
4
|
+
* answered from local columns; mutations (`swap`, `updateQuantity`, `cancel`,
|
|
5
|
+
* `resume`) call the active gateway and then sync the result back.
|
|
6
|
+
*
|
|
7
|
+
* if (subscription.onGracePeriod()) await subscription.resume();
|
|
8
|
+
* await subscription.swap("price_pro");
|
|
9
|
+
* await subscription.incrementQuantity(2);
|
|
10
|
+
*/
|
|
11
|
+
import { Model } from "../core/model.js";
|
|
12
|
+
import { billing } from "./manager.js";
|
|
13
|
+
import { SubscriptionItem } from "./subscription-item.js";
|
|
14
|
+
/** Provider statuses we treat as "the subscription is live". */
|
|
15
|
+
const ACTIVE_STATUSES = new Set(["active", "trialing"]);
|
|
16
|
+
/** Provider statuses that need the customer to finish a payment. */
|
|
17
|
+
const INCOMPLETE_STATUSES = new Set(["incomplete", "past_due", "unpaid"]);
|
|
18
|
+
export function toRefs(prices) {
|
|
19
|
+
const list = Array.isArray(prices) ? prices : [prices];
|
|
20
|
+
return list.map((p) => (typeof p === "string" ? { price: p } : p));
|
|
21
|
+
}
|
|
22
|
+
export class Subscription extends Model {
|
|
23
|
+
static table = "subscriptions";
|
|
24
|
+
static timestamps = true;
|
|
25
|
+
static casts = {
|
|
26
|
+
provider_price: "json",
|
|
27
|
+
quantity: "int",
|
|
28
|
+
trial_ends_at: "date",
|
|
29
|
+
starts_at: "date",
|
|
30
|
+
ends_at: "date",
|
|
31
|
+
paused_at: "date",
|
|
32
|
+
};
|
|
33
|
+
/** The subscription's line items. */
|
|
34
|
+
items() {
|
|
35
|
+
return this.hasMany(SubscriptionItem);
|
|
36
|
+
}
|
|
37
|
+
/* ------------------------------- status ------------------------------- */
|
|
38
|
+
/** On an unexpired trial. */
|
|
39
|
+
onTrial() {
|
|
40
|
+
return this.trial_ends_at != null && this.trial_ends_at.getTime() > Date.now();
|
|
41
|
+
}
|
|
42
|
+
hasExpiredTrial() {
|
|
43
|
+
return this.trial_ends_at != null && this.trial_ends_at.getTime() <= Date.now();
|
|
44
|
+
}
|
|
45
|
+
/** Cancellation has been initiated (whether or not the grace period is over). */
|
|
46
|
+
canceled() {
|
|
47
|
+
return this.ends_at != null;
|
|
48
|
+
}
|
|
49
|
+
/** Canceled, but the paid period hasn't elapsed yet — still usable. */
|
|
50
|
+
onGracePeriod() {
|
|
51
|
+
return this.ends_at != null && this.ends_at.getTime() > Date.now();
|
|
52
|
+
}
|
|
53
|
+
/** Cancellation is complete and the period has elapsed. */
|
|
54
|
+
ended() {
|
|
55
|
+
return this.canceled() && !this.onGracePeriod();
|
|
56
|
+
}
|
|
57
|
+
paused() {
|
|
58
|
+
return this.provider_status === "paused" || this.paused_at != null;
|
|
59
|
+
}
|
|
60
|
+
/** The gateway reports a valid, live status and it hasn't ended. */
|
|
61
|
+
active() {
|
|
62
|
+
return ACTIVE_STATUSES.has(this.provider_status) && !this.ended() && !this.paused();
|
|
63
|
+
}
|
|
64
|
+
/** Active and past any trial. */
|
|
65
|
+
recurring() {
|
|
66
|
+
return this.active() && !this.onTrial();
|
|
67
|
+
}
|
|
68
|
+
/** Usable right now: active, on trial, or within the grace period. */
|
|
69
|
+
valid() {
|
|
70
|
+
return this.active() || this.onTrial() || this.onGracePeriod();
|
|
71
|
+
}
|
|
72
|
+
hasIncompletePayment() {
|
|
73
|
+
return INCOMPLETE_STATUSES.has(this.provider_status);
|
|
74
|
+
}
|
|
75
|
+
/** Whether this subscription includes the given price. */
|
|
76
|
+
hasPrice(price) {
|
|
77
|
+
return (this.provider_price ?? []).includes(price);
|
|
78
|
+
}
|
|
79
|
+
/* ------------------------------- actions ------------------------------ */
|
|
80
|
+
/** Swap to a new price (or set of prices). */
|
|
81
|
+
async swap(prices, opts) {
|
|
82
|
+
const result = await this.gw().swapSubscription(this.provider_id, {
|
|
83
|
+
items: toRefs(prices),
|
|
84
|
+
...(opts?.prorate != null ? { prorate: opts.prorate } : {}),
|
|
85
|
+
});
|
|
86
|
+
return this.syncFromGateway(result);
|
|
87
|
+
}
|
|
88
|
+
async incrementQuantity(count = 1) {
|
|
89
|
+
return this.updateQuantity((this.quantity ?? 1) + count);
|
|
90
|
+
}
|
|
91
|
+
async decrementQuantity(count = 1) {
|
|
92
|
+
return this.updateQuantity(Math.max(0, (this.quantity ?? 1) - count));
|
|
93
|
+
}
|
|
94
|
+
async updateQuantity(quantity, opts) {
|
|
95
|
+
const result = await this.gw().updateQuantity(this.provider_id, quantity, opts);
|
|
96
|
+
return this.syncFromGateway(result);
|
|
97
|
+
}
|
|
98
|
+
/** Cancel at period end — keeps access through the grace period. */
|
|
99
|
+
async cancel() {
|
|
100
|
+
return this.syncFromGateway(await this.gw().cancelSubscription(this.provider_id));
|
|
101
|
+
}
|
|
102
|
+
/** Cancel immediately. */
|
|
103
|
+
async cancelNow() {
|
|
104
|
+
return this.syncFromGateway(await this.gw().cancelSubscription(this.provider_id, { now: true }));
|
|
105
|
+
}
|
|
106
|
+
/** Resume a subscription still within its grace period. */
|
|
107
|
+
async resume() {
|
|
108
|
+
return this.syncFromGateway(await this.gw().resumeSubscription(this.provider_id));
|
|
109
|
+
}
|
|
110
|
+
/** End the trial immediately (local state; the next invoice bills right away). */
|
|
111
|
+
async endTrial() {
|
|
112
|
+
this.trial_ends_at = null;
|
|
113
|
+
if (this.provider_status === "trialing")
|
|
114
|
+
this.provider_status = "active";
|
|
115
|
+
return this.save();
|
|
116
|
+
}
|
|
117
|
+
/** Extend (or set) the trial end. */
|
|
118
|
+
async extendTrial(until) {
|
|
119
|
+
this.trial_ends_at = until;
|
|
120
|
+
return this.save();
|
|
121
|
+
}
|
|
122
|
+
/* ------------------------------ internals ----------------------------- */
|
|
123
|
+
/** The gateway this subscription lives on. */
|
|
124
|
+
gw() {
|
|
125
|
+
return billing().gateway(this.gateway);
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Fold a gateway subscription back into local columns + items, then persist.
|
|
129
|
+
* The single source of truth for status flows through here (also used by the
|
|
130
|
+
* webhook handler).
|
|
131
|
+
*/
|
|
132
|
+
async syncFromGateway(remote) {
|
|
133
|
+
this.provider_status = remote.status;
|
|
134
|
+
this.provider_price = remote.items.map((i) => i.price);
|
|
135
|
+
this.quantity = remote.quantity ?? remote.items[0]?.quantity ?? this.quantity ?? null;
|
|
136
|
+
this.trial_ends_at = remote.trialEndsAt ?? null;
|
|
137
|
+
this.ends_at = remote.endsAt ?? null;
|
|
138
|
+
await this.save();
|
|
139
|
+
await this.syncItems(remote);
|
|
140
|
+
return this;
|
|
141
|
+
}
|
|
142
|
+
/** Replace this subscription's items with the gateway's current set. */
|
|
143
|
+
async syncItems(remote) {
|
|
144
|
+
if (this.id == null)
|
|
145
|
+
return;
|
|
146
|
+
await SubscriptionItem.query().where("subscription_id", this.id).delete();
|
|
147
|
+
for (const item of remote.items) {
|
|
148
|
+
await SubscriptionItem.create({
|
|
149
|
+
subscription_id: this.id,
|
|
150
|
+
provider_id: item.id,
|
|
151
|
+
provider_product: item.product,
|
|
152
|
+
provider_price: item.price,
|
|
153
|
+
quantity: item.quantity ?? 1,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The webhook handler. It verifies the raw body against the gateway's signing
|
|
3
|
+
* secret, emits `billing.webhook.received`, and — when the event concerns a
|
|
4
|
+
* subscription — folds the gateway's state into the local `Subscription` row and
|
|
5
|
+
* emits the matching `billing.subscription.*` event.
|
|
6
|
+
*
|
|
7
|
+
* A known subscription (already in the DB) is always synced. An unknown one
|
|
8
|
+
* (e.g. Paddle's create-on-checkout) is created only if the app registered a
|
|
9
|
+
* billable resolver with `resolveBillableUsing()` — otherwise it's left to the
|
|
10
|
+
* app's own `listen("billing.webhook.received", …)`.
|
|
11
|
+
*/
|
|
12
|
+
import type { HeaderBag, WebhookEvent } from "./gateway.js";
|
|
13
|
+
import "./events.js";
|
|
14
|
+
export interface WebhookResult {
|
|
15
|
+
ok: boolean;
|
|
16
|
+
status: 200 | 400;
|
|
17
|
+
event?: WebhookEvent;
|
|
18
|
+
}
|
|
19
|
+
/** Maps a gateway customer id to a local billable, so unknown subs can be created. */
|
|
20
|
+
export type BillableResolver = (customerId: string, gateway: string) => Promise<{
|
|
21
|
+
id: number | string;
|
|
22
|
+
type: string;
|
|
23
|
+
} | null>;
|
|
24
|
+
/** Register how to find the billable for a gateway customer (optional). */
|
|
25
|
+
export declare function resolveBillableUsing(fn: BillableResolver | undefined): void;
|
|
26
|
+
export declare function handleWebhook(gatewayName: string, rawBody: string, headers: HeaderBag): Promise<WebhookResult>;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The webhook handler. It verifies the raw body against the gateway's signing
|
|
3
|
+
* secret, emits `billing.webhook.received`, and — when the event concerns a
|
|
4
|
+
* subscription — folds the gateway's state into the local `Subscription` row and
|
|
5
|
+
* emits the matching `billing.subscription.*` event.
|
|
6
|
+
*
|
|
7
|
+
* A known subscription (already in the DB) is always synced. An unknown one
|
|
8
|
+
* (e.g. Paddle's create-on-checkout) is created only if the app registered a
|
|
9
|
+
* billable resolver with `resolveBillableUsing()` — otherwise it's left to the
|
|
10
|
+
* app's own `listen("billing.webhook.received", …)`.
|
|
11
|
+
*/
|
|
12
|
+
import { emit } from "../core/helpers.js";
|
|
13
|
+
import { billing } from "./manager.js";
|
|
14
|
+
import { Subscription } from "./subscription.js";
|
|
15
|
+
import "./events.js";
|
|
16
|
+
let resolver;
|
|
17
|
+
/** Register how to find the billable for a gateway customer (optional). */
|
|
18
|
+
export function resolveBillableUsing(fn) {
|
|
19
|
+
resolver = fn;
|
|
20
|
+
}
|
|
21
|
+
export async function handleWebhook(gatewayName, rawBody, headers) {
|
|
22
|
+
const manager = billing();
|
|
23
|
+
const gateway = manager.gateway(gatewayName);
|
|
24
|
+
const secret = manager.webhookSecret(gatewayName);
|
|
25
|
+
const event = await gateway.verifyWebhook(rawBody, headers, secret);
|
|
26
|
+
if (!event)
|
|
27
|
+
return { ok: false, status: 400 };
|
|
28
|
+
await emit("billing.webhook.received", {
|
|
29
|
+
gateway: gatewayName,
|
|
30
|
+
type: event.type,
|
|
31
|
+
id: event.id,
|
|
32
|
+
});
|
|
33
|
+
if (event.subscription)
|
|
34
|
+
await syncSubscription(gatewayName, event);
|
|
35
|
+
return { ok: true, status: 200, event };
|
|
36
|
+
}
|
|
37
|
+
async function syncSubscription(gatewayName, event) {
|
|
38
|
+
const remote = event.subscription;
|
|
39
|
+
const existing = (await Subscription.query()
|
|
40
|
+
.where("provider_id", remote.id)
|
|
41
|
+
.first());
|
|
42
|
+
if (existing) {
|
|
43
|
+
const subscription = new Subscription(existing);
|
|
44
|
+
await subscription.syncFromGateway(remote);
|
|
45
|
+
const deleted = event.type.includes("deleted") || subscription.ended();
|
|
46
|
+
await emit(deleted ? "billing.subscription.deleted" : "billing.subscription.updated", {
|
|
47
|
+
gateway: gatewayName,
|
|
48
|
+
subscriptionId: subscription.id,
|
|
49
|
+
providerId: subscription.provider_id,
|
|
50
|
+
status: subscription.provider_status,
|
|
51
|
+
});
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
if (!resolver || !event.customer)
|
|
55
|
+
return;
|
|
56
|
+
const target = await resolver(event.customer, gatewayName);
|
|
57
|
+
if (!target)
|
|
58
|
+
return;
|
|
59
|
+
const subscription = await Subscription.create({
|
|
60
|
+
billable_id: target.id,
|
|
61
|
+
billable_type: target.type,
|
|
62
|
+
type: "default",
|
|
63
|
+
gateway: gatewayName,
|
|
64
|
+
provider_id: remote.id,
|
|
65
|
+
provider_status: remote.status,
|
|
66
|
+
starts_at: new Date(),
|
|
67
|
+
});
|
|
68
|
+
await subscription.syncFromGateway(remote);
|
|
69
|
+
await emit("billing.subscription.created", {
|
|
70
|
+
gateway: gatewayName,
|
|
71
|
+
subscriptionId: subscription.id,
|
|
72
|
+
providerId: subscription.provider_id,
|
|
73
|
+
status: subscription.provider_status,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Route model binding — a `:user` in the path arrives as a `User`, not a string.
|
|
3
|
+
*
|
|
4
|
+
* bindModel("user", User);
|
|
5
|
+
*
|
|
6
|
+
* router.get("/users/:user", (c) => {
|
|
7
|
+
* const user = boundModel(User); // already fetched. Not a string, not null.
|
|
8
|
+
* return c.json(user);
|
|
9
|
+
* });
|
|
10
|
+
*
|
|
11
|
+
* The row is looked up **before the handler runs**, and a miss is a 404 there and
|
|
12
|
+
* then. That's the whole value: the handler never sees a null, so it never has to
|
|
13
|
+
* remember to check for one — and "forgot the 404" stops being a bug you can write.
|
|
14
|
+
*
|
|
15
|
+
* Bind by another column when the URL isn't the id:
|
|
16
|
+
*
|
|
17
|
+
* bindModel("post", Post, { key: "slug" }); // /posts/hello-world
|
|
18
|
+
*
|
|
19
|
+
* Constrain what's reachable (row-level security — a row outside the scope 404s,
|
|
20
|
+
* so it can't be reached by guessing an id):
|
|
21
|
+
*
|
|
22
|
+
* bindModel("post", Post, { scope: (q, c) => q.where("authorId", currentUserId(c)) });
|
|
23
|
+
*
|
|
24
|
+
* Or resolve it yourself, for anything that isn't a model:
|
|
25
|
+
*
|
|
26
|
+
* bindRoute("tenant", (slug) => tenants.get(slug));
|
|
27
|
+
*/
|
|
28
|
+
import type { MiddlewareHandler } from "hono";
|
|
29
|
+
import type { Ctx } from "./http/router.js";
|
|
30
|
+
import type { QueryBuilder, Row } from "./database.js";
|
|
31
|
+
import type { Model } from "./model.js";
|
|
32
|
+
/** A `Model` subclass — the class itself, with its statics. */
|
|
33
|
+
export type ModelClass<T extends Model = Model> = {
|
|
34
|
+
new (row?: Row): T;
|
|
35
|
+
table: string;
|
|
36
|
+
primaryKey: string;
|
|
37
|
+
query(): QueryBuilder;
|
|
38
|
+
};
|
|
39
|
+
export interface BindingOptions<T extends Model = Model> {
|
|
40
|
+
/** The column the URL segment matches. Default: the model's `primaryKey`. */
|
|
41
|
+
key?: string;
|
|
42
|
+
/**
|
|
43
|
+
* Constrain what's findable. A row outside the scope is a 404 — so it can't be
|
|
44
|
+
* reached by guessing an id, which is what makes this security rather than a
|
|
45
|
+
* filter.
|
|
46
|
+
*/
|
|
47
|
+
scope?: (query: QueryBuilder, c: Ctx) => QueryBuilder | void;
|
|
48
|
+
/**
|
|
49
|
+
* What to do when nothing matches. Default: throw a 404. Return a value to
|
|
50
|
+
* substitute one instead.
|
|
51
|
+
*/
|
|
52
|
+
missing?: (value: string, c: Ctx) => T | never;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Bind a route parameter to a model. `:user` becomes a `User`; a row that doesn't
|
|
56
|
+
* exist (or is outside `scope`) is a 404 before your handler runs.
|
|
57
|
+
*/
|
|
58
|
+
export declare function bindModel<T extends Model>(param: string, model: ModelClass<T>, options?: BindingOptions<T>): void;
|
|
59
|
+
/**
|
|
60
|
+
* Bind a route parameter to anything at all — a tenant from a map, a feature flag,
|
|
61
|
+
* a value from an API. Returning `undefined` or `null` is a 404.
|
|
62
|
+
*/
|
|
63
|
+
export declare function bindRoute(param: string, resolve: (value: string, c: Ctx) => unknown | Promise<unknown>): void;
|
|
64
|
+
/** Whether a parameter has a binding registered. */
|
|
65
|
+
export declare function hasBinding(param: string): boolean;
|
|
66
|
+
/** Drop every binding — a clean slate between tests. */
|
|
67
|
+
export declare function clearBindings(): void;
|
|
68
|
+
/** The parameter names in a route pattern: `/users/:user/posts/:post` → both. */
|
|
69
|
+
export declare function paramNames(pattern: string): string[];
|
|
70
|
+
/**
|
|
71
|
+
* Resolve this route's bound parameters and stash them on the request. Installed
|
|
72
|
+
* by the HTTP kernel for any route whose pattern has a bound parameter — so the
|
|
73
|
+
* work is skipped entirely for routes that don't.
|
|
74
|
+
*
|
|
75
|
+
* @internal
|
|
76
|
+
*/
|
|
77
|
+
export declare function resolveBindings(params: string[], c: Ctx): Promise<void> | void;
|
|
78
|
+
/** Middleware that resolves the bound params of a route pattern. @internal */
|
|
79
|
+
export declare function bindingMiddleware(pattern: string): MiddlewareHandler | undefined;
|
|
80
|
+
/** The raw bound value for a parameter, if any. */
|
|
81
|
+
export declare function boundValue<T = unknown>(param: string, c?: Ctx): T | undefined;
|
|
82
|
+
/**
|
|
83
|
+
* The model bound to this route — already fetched, never null.
|
|
84
|
+
*
|
|
85
|
+
* router.get("/posts/:post", () => {
|
|
86
|
+
* const post = boundModel(Post); // a Post, guaranteed
|
|
87
|
+
* });
|
|
88
|
+
*
|
|
89
|
+
* Pass the parameter name when one model is bound to several (`/users/:user/friends/:friend`).
|
|
90
|
+
*/
|
|
91
|
+
export declare function boundModel<T extends Model>(model: ModelClass<T>, param?: string, c?: Ctx): T;
|