@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,190 @@
|
|
|
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 { hmacSha256Hex, constantTimeEqual } from "../crypto.js";
|
|
18
|
+
const DAY = 86_400_000;
|
|
19
|
+
export class FakeGateway {
|
|
20
|
+
name = "fake";
|
|
21
|
+
calls = [];
|
|
22
|
+
customers = new Map();
|
|
23
|
+
subscriptions = new Map();
|
|
24
|
+
invoices = new Map();
|
|
25
|
+
seq = 0;
|
|
26
|
+
id(prefix) {
|
|
27
|
+
return `${prefix}_${++this.seq}`;
|
|
28
|
+
}
|
|
29
|
+
record(method, ...args) {
|
|
30
|
+
this.calls.push({ method, args });
|
|
31
|
+
}
|
|
32
|
+
items(refs) {
|
|
33
|
+
return refs.map((r) => ({
|
|
34
|
+
id: this.id("si"),
|
|
35
|
+
product: `prod_${r.price}`,
|
|
36
|
+
price: r.price,
|
|
37
|
+
quantity: r.quantity ?? 1,
|
|
38
|
+
}));
|
|
39
|
+
}
|
|
40
|
+
async createCustomer(details) {
|
|
41
|
+
this.record("createCustomer", details);
|
|
42
|
+
const customer = {
|
|
43
|
+
id: this.id("cus"),
|
|
44
|
+
email: details.email,
|
|
45
|
+
name: details.name,
|
|
46
|
+
};
|
|
47
|
+
this.customers.set(customer.id, customer);
|
|
48
|
+
return customer;
|
|
49
|
+
}
|
|
50
|
+
async updateCustomer(id, details) {
|
|
51
|
+
this.record("updateCustomer", id, details);
|
|
52
|
+
const customer = { ...this.customers.get(id), id, ...details };
|
|
53
|
+
this.customers.set(id, customer);
|
|
54
|
+
return customer;
|
|
55
|
+
}
|
|
56
|
+
async createSubscription(params) {
|
|
57
|
+
this.record("createSubscription", params);
|
|
58
|
+
const trialing = params.trialEnd ? params.trialEnd.getTime() > Date.now() : false;
|
|
59
|
+
const items = this.items(params.items);
|
|
60
|
+
const sub = {
|
|
61
|
+
id: this.id("sub"),
|
|
62
|
+
status: trialing ? "trialing" : "active",
|
|
63
|
+
items,
|
|
64
|
+
quantity: items[0]?.quantity ?? 1,
|
|
65
|
+
trialEndsAt: params.trialEnd ?? null,
|
|
66
|
+
endsAt: null,
|
|
67
|
+
customer: params.customer,
|
|
68
|
+
};
|
|
69
|
+
this.subscriptions.set(sub.id, sub);
|
|
70
|
+
return sub;
|
|
71
|
+
}
|
|
72
|
+
async swapSubscription(id, params) {
|
|
73
|
+
this.record("swapSubscription", id, params);
|
|
74
|
+
const sub = this.require(id);
|
|
75
|
+
const items = this.items(params.items);
|
|
76
|
+
const next = { ...sub, items, quantity: items[0]?.quantity ?? 1 };
|
|
77
|
+
this.subscriptions.set(id, next);
|
|
78
|
+
return next;
|
|
79
|
+
}
|
|
80
|
+
async updateQuantity(id, quantity, opts) {
|
|
81
|
+
this.record("updateQuantity", id, quantity, opts);
|
|
82
|
+
const sub = this.require(id);
|
|
83
|
+
const items = sub.items.map((it, i) => (i === 0 ? { ...it, quantity } : it));
|
|
84
|
+
const next = { ...sub, items, quantity };
|
|
85
|
+
this.subscriptions.set(id, next);
|
|
86
|
+
return next;
|
|
87
|
+
}
|
|
88
|
+
async cancelSubscription(id, params) {
|
|
89
|
+
this.record("cancelSubscription", id, params);
|
|
90
|
+
const sub = this.require(id);
|
|
91
|
+
const now = new Date();
|
|
92
|
+
const next = params?.now
|
|
93
|
+
? { ...sub, status: "canceled", endsAt: now }
|
|
94
|
+
: { ...sub, endsAt: new Date(now.getTime() + 30 * DAY) };
|
|
95
|
+
this.subscriptions.set(id, next);
|
|
96
|
+
return next;
|
|
97
|
+
}
|
|
98
|
+
async resumeSubscription(id) {
|
|
99
|
+
this.record("resumeSubscription", id);
|
|
100
|
+
const sub = this.require(id);
|
|
101
|
+
const trialing = sub.trialEndsAt ? sub.trialEndsAt.getTime() > Date.now() : false;
|
|
102
|
+
const next = {
|
|
103
|
+
...sub,
|
|
104
|
+
status: trialing ? "trialing" : "active",
|
|
105
|
+
endsAt: null,
|
|
106
|
+
};
|
|
107
|
+
this.subscriptions.set(id, next);
|
|
108
|
+
return next;
|
|
109
|
+
}
|
|
110
|
+
async charge(params) {
|
|
111
|
+
this.record("charge", params);
|
|
112
|
+
return {
|
|
113
|
+
id: this.id("ch"),
|
|
114
|
+
status: "succeeded",
|
|
115
|
+
amount: params.amount,
|
|
116
|
+
currency: params.currency ?? "usd",
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
async refund(params) {
|
|
120
|
+
this.record("refund", params);
|
|
121
|
+
return { id: this.id("re"), amount: params.amount ?? 0, status: "succeeded" };
|
|
122
|
+
}
|
|
123
|
+
async listInvoices(customer) {
|
|
124
|
+
this.record("listInvoices", customer);
|
|
125
|
+
return this.invoices.get(customer) ?? [];
|
|
126
|
+
}
|
|
127
|
+
async createCheckoutSession(params) {
|
|
128
|
+
this.record("createCheckoutSession", params);
|
|
129
|
+
const id = this.id("cs");
|
|
130
|
+
return { id, url: `https://fake.checkout/${id}`, clientToken: `ctok_${id}` };
|
|
131
|
+
}
|
|
132
|
+
async createSetupIntent(customer) {
|
|
133
|
+
this.record("createSetupIntent", customer);
|
|
134
|
+
const id = this.id("seti");
|
|
135
|
+
return { id, clientSecret: `${id}_secret` };
|
|
136
|
+
}
|
|
137
|
+
async paymentMethods(customer) {
|
|
138
|
+
this.record("paymentMethods", customer);
|
|
139
|
+
return [{ id: this.id("pm"), type: "card", last4: "4242", brand: "visa" }];
|
|
140
|
+
}
|
|
141
|
+
async verifyWebhook(rawBody, headers, secret) {
|
|
142
|
+
const provided = headers("fake-signature") ?? "";
|
|
143
|
+
const expected = await hmacSha256Hex(secret, rawBody);
|
|
144
|
+
if (!constantTimeEqual(provided, expected))
|
|
145
|
+
return null;
|
|
146
|
+
const body = JSON.parse(rawBody);
|
|
147
|
+
const data = body.data;
|
|
148
|
+
const subscription = data && data.id
|
|
149
|
+
? {
|
|
150
|
+
id: data.id,
|
|
151
|
+
status: data.status ?? "active",
|
|
152
|
+
items: data.items ?? [],
|
|
153
|
+
quantity: data.quantity ?? null,
|
|
154
|
+
trialEndsAt: data.trialEndsAt ? new Date(data.trialEndsAt) : null,
|
|
155
|
+
endsAt: data.endsAt ? new Date(data.endsAt) : null,
|
|
156
|
+
customer: data.customer,
|
|
157
|
+
}
|
|
158
|
+
: undefined;
|
|
159
|
+
return {
|
|
160
|
+
id: body.id ?? this.id("evt"),
|
|
161
|
+
type: body.type,
|
|
162
|
+
payload: body,
|
|
163
|
+
subscription,
|
|
164
|
+
customer: data?.customer,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
/** Build a signed webhook body + headers pair for tests. */
|
|
168
|
+
async signWebhook(secret, event) {
|
|
169
|
+
const body = JSON.stringify(event);
|
|
170
|
+
const signature = await hmacSha256Hex(secret, body);
|
|
171
|
+
return { body, headers: { "fake-signature": signature } };
|
|
172
|
+
}
|
|
173
|
+
require(id) {
|
|
174
|
+
const sub = this.subscriptions.get(id);
|
|
175
|
+
if (sub)
|
|
176
|
+
return sub;
|
|
177
|
+
// Not created through this instance (e.g. hydrated from a fixture) — mint a
|
|
178
|
+
// minimal record so lifecycle calls still resolve.
|
|
179
|
+
const seed = {
|
|
180
|
+
id,
|
|
181
|
+
status: "active",
|
|
182
|
+
items: [],
|
|
183
|
+
quantity: 1,
|
|
184
|
+
trialEndsAt: null,
|
|
185
|
+
endsAt: null,
|
|
186
|
+
};
|
|
187
|
+
this.subscriptions.set(id, seed);
|
|
188
|
+
return seed;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Registers the built-in gateways on a manager. Kept out of `manager.ts` so the
|
|
3
|
+
* manager module carries no provider SDK; the provider (and tests) call this to
|
|
4
|
+
* wire Stripe, Paddle, and the Fake gateway from config.
|
|
5
|
+
*/
|
|
6
|
+
import type { BillingManager } from "../manager.js";
|
|
7
|
+
export { StripeGateway } from "./stripe.js";
|
|
8
|
+
export { PaddleGateway } from "./paddle.js";
|
|
9
|
+
export { FakeGateway } from "./fake.js";
|
|
10
|
+
export type { FakeCall } from "./fake.js";
|
|
11
|
+
export declare function registerDefaultGateways(manager: BillingManager): void;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Registers the built-in gateways on a manager. Kept out of `manager.ts` so the
|
|
3
|
+
* manager module carries no provider SDK; the provider (and tests) call this to
|
|
4
|
+
* wire Stripe, Paddle, and the Fake gateway from config.
|
|
5
|
+
*/
|
|
6
|
+
import { StripeGateway } from "./stripe.js";
|
|
7
|
+
import { PaddleGateway } from "./paddle.js";
|
|
8
|
+
import { FakeGateway } from "./fake.js";
|
|
9
|
+
export { StripeGateway } from "./stripe.js";
|
|
10
|
+
export { PaddleGateway } from "./paddle.js";
|
|
11
|
+
export { FakeGateway } from "./fake.js";
|
|
12
|
+
export function registerDefaultGateways(manager) {
|
|
13
|
+
manager.register("stripe", (cfg) => new StripeGateway(cfg.key ?? ""));
|
|
14
|
+
manager.register("paddle", (cfg) => {
|
|
15
|
+
const c = cfg;
|
|
16
|
+
return new PaddleGateway(c.key ?? "", {
|
|
17
|
+
...(c.sandbox != null ? { sandbox: c.sandbox } : {}),
|
|
18
|
+
...(c.clientToken ? { clientToken: c.clientToken } : {}),
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
manager.register("fake", () => new FakeGateway());
|
|
22
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Paddle (Billing) gateway. Paddle is a merchant-of-record with a
|
|
3
|
+
* client-driven checkout, so its model differs from Stripe in two honest ways:
|
|
4
|
+
* new subscriptions are born from a completed checkout/transaction (not created
|
|
5
|
+
* server-side), and one-off charges go through transactions. Those methods throw
|
|
6
|
+
* a clear `BillingError` pointing at `checkout()`; everything else maps onto the
|
|
7
|
+
* Paddle REST API with JSON + Bearer auth.
|
|
8
|
+
*
|
|
9
|
+
* Webhook signatures use Paddle's `ts;h1` scheme: HMAC-SHA256 hex of
|
|
10
|
+
* `${ts}:${rawBody}` under the notification secret.
|
|
11
|
+
*/
|
|
12
|
+
import type { BillingGateway, CustomerDetails, GatewayCustomer, CreateSubscriptionParams, GatewaySubscription, SwapParams, CancelParams, ChargeParams, GatewayCharge, RefundParams, GatewayRefund, GatewayInvoice, CheckoutParams, CheckoutSession, HeaderBag, WebhookEvent } from "../gateway.js";
|
|
13
|
+
export interface PaddleOptions {
|
|
14
|
+
sandbox?: boolean;
|
|
15
|
+
clientToken?: string;
|
|
16
|
+
}
|
|
17
|
+
export declare class PaddleGateway implements BillingGateway {
|
|
18
|
+
private key;
|
|
19
|
+
private options;
|
|
20
|
+
readonly name = "paddle";
|
|
21
|
+
private base;
|
|
22
|
+
constructor(key: string, options?: PaddleOptions);
|
|
23
|
+
private call;
|
|
24
|
+
createCustomer(details: CustomerDetails): Promise<GatewayCustomer>;
|
|
25
|
+
updateCustomer(id: string, details: CustomerDetails): Promise<GatewayCustomer>;
|
|
26
|
+
createSubscription(_params: CreateSubscriptionParams): Promise<GatewaySubscription>;
|
|
27
|
+
swapSubscription(id: string, params: SwapParams): Promise<GatewaySubscription>;
|
|
28
|
+
updateQuantity(id: string, quantity: number, opts?: {
|
|
29
|
+
prorate?: boolean;
|
|
30
|
+
}): Promise<GatewaySubscription>;
|
|
31
|
+
cancelSubscription(id: string, params?: CancelParams): Promise<GatewaySubscription>;
|
|
32
|
+
resumeSubscription(id: string): Promise<GatewaySubscription>;
|
|
33
|
+
charge(_params: ChargeParams): Promise<GatewayCharge>;
|
|
34
|
+
refund(params: RefundParams): Promise<GatewayRefund>;
|
|
35
|
+
listInvoices(customer: string): Promise<GatewayInvoice[]>;
|
|
36
|
+
createCheckoutSession(params: CheckoutParams): Promise<CheckoutSession>;
|
|
37
|
+
verifyWebhook(rawBody: string, headers: HeaderBag, secret: string): Promise<WebhookEvent | null>;
|
|
38
|
+
private normalize;
|
|
39
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Paddle (Billing) gateway. Paddle is a merchant-of-record with a
|
|
3
|
+
* client-driven checkout, so its model differs from Stripe in two honest ways:
|
|
4
|
+
* new subscriptions are born from a completed checkout/transaction (not created
|
|
5
|
+
* server-side), and one-off charges go through transactions. Those methods throw
|
|
6
|
+
* a clear `BillingError` pointing at `checkout()`; everything else maps onto the
|
|
7
|
+
* Paddle REST API with JSON + Bearer auth.
|
|
8
|
+
*
|
|
9
|
+
* Webhook signatures use Paddle's `ts;h1` scheme: HMAC-SHA256 hex of
|
|
10
|
+
* `${ts}:${rawBody}` under the notification secret.
|
|
11
|
+
*/
|
|
12
|
+
import { hmacSha256Hex, constantTimeEqual } from "../crypto.js";
|
|
13
|
+
import { BillingError } from "../gateway.js";
|
|
14
|
+
export class PaddleGateway {
|
|
15
|
+
key;
|
|
16
|
+
options;
|
|
17
|
+
name = "paddle";
|
|
18
|
+
base;
|
|
19
|
+
constructor(key, options = {}) {
|
|
20
|
+
this.key = key;
|
|
21
|
+
this.options = options;
|
|
22
|
+
this.base = options.sandbox ? "https://sandbox-api.paddle.com" : "https://api.paddle.com";
|
|
23
|
+
}
|
|
24
|
+
/* ------------------------------- transport ----------------------------- */
|
|
25
|
+
async call(method, path, body) {
|
|
26
|
+
if (!this.key)
|
|
27
|
+
throw new BillingError("Paddle API key is not configured.", "paddle");
|
|
28
|
+
const res = await fetch(`${this.base}${path}`, {
|
|
29
|
+
method,
|
|
30
|
+
headers: {
|
|
31
|
+
authorization: `Bearer ${this.key}`,
|
|
32
|
+
"content-type": "application/json",
|
|
33
|
+
},
|
|
34
|
+
...(body ? { body: JSON.stringify(body) } : {}),
|
|
35
|
+
});
|
|
36
|
+
const data = (await res.json().catch(() => ({})));
|
|
37
|
+
if (!res.ok) {
|
|
38
|
+
const err = (data.error ?? {});
|
|
39
|
+
throw new BillingError(err.detail ?? `Paddle request failed (${res.status})`, "paddle");
|
|
40
|
+
}
|
|
41
|
+
return (data.data ?? data);
|
|
42
|
+
}
|
|
43
|
+
/* ------------------------------- customers ----------------------------- */
|
|
44
|
+
async createCustomer(details) {
|
|
45
|
+
const c = await this.call("POST", "/customers", {
|
|
46
|
+
email: details.email,
|
|
47
|
+
name: details.name,
|
|
48
|
+
});
|
|
49
|
+
return { id: String(c.id), email: c.email, name: c.name };
|
|
50
|
+
}
|
|
51
|
+
async updateCustomer(id, details) {
|
|
52
|
+
const c = await this.call("PATCH", `/customers/${id}`, {
|
|
53
|
+
email: details.email,
|
|
54
|
+
name: details.name,
|
|
55
|
+
});
|
|
56
|
+
return { id: String(c.id), email: c.email, name: c.name };
|
|
57
|
+
}
|
|
58
|
+
/* ----------------------------- subscriptions --------------------------- */
|
|
59
|
+
createSubscription(_params) {
|
|
60
|
+
throw new BillingError("Paddle subscriptions are created from a completed checkout. Use newSubscription(...).checkout() and let the webhook create the local record.", "paddle");
|
|
61
|
+
}
|
|
62
|
+
async swapSubscription(id, params) {
|
|
63
|
+
const sub = await this.call("PATCH", `/subscriptions/${id}`, {
|
|
64
|
+
items: params.items.map((i) => ({ price_id: i.price, quantity: i.quantity ?? 1 })),
|
|
65
|
+
proration_billing_mode: params.prorate === false ? "do_not_bill" : "prorated_immediately",
|
|
66
|
+
});
|
|
67
|
+
return this.normalize(sub);
|
|
68
|
+
}
|
|
69
|
+
async updateQuantity(id, quantity, opts) {
|
|
70
|
+
const current = await this.call("GET", `/subscriptions/${id}`);
|
|
71
|
+
const items = (current.items ?? []).map((it, i) => ({
|
|
72
|
+
price_id: String(it.price?.id ?? ""),
|
|
73
|
+
quantity: i === 0 ? quantity : Number(it.quantity ?? 1),
|
|
74
|
+
}));
|
|
75
|
+
const sub = await this.call("PATCH", `/subscriptions/${id}`, {
|
|
76
|
+
items,
|
|
77
|
+
proration_billing_mode: opts?.prorate === false ? "do_not_bill" : "prorated_immediately",
|
|
78
|
+
});
|
|
79
|
+
return this.normalize(sub);
|
|
80
|
+
}
|
|
81
|
+
async cancelSubscription(id, params) {
|
|
82
|
+
const sub = await this.call("POST", `/subscriptions/${id}/cancel`, {
|
|
83
|
+
effective_from: params?.now ? "immediately" : "next_billing_period",
|
|
84
|
+
});
|
|
85
|
+
return this.normalize(sub);
|
|
86
|
+
}
|
|
87
|
+
async resumeSubscription(id) {
|
|
88
|
+
// Removing the scheduled cancellation resumes the subscription.
|
|
89
|
+
const sub = await this.call("PATCH", `/subscriptions/${id}`, { scheduled_change: null });
|
|
90
|
+
return this.normalize(sub);
|
|
91
|
+
}
|
|
92
|
+
/* ------------------------------- charges ------------------------------- */
|
|
93
|
+
charge(_params) {
|
|
94
|
+
throw new BillingError("Paddle one-off charges are collected via a transaction/checkout. Use checkout() with mode 'payment'.", "paddle");
|
|
95
|
+
}
|
|
96
|
+
async refund(params) {
|
|
97
|
+
const adj = await this.call("POST", "/adjustments", {
|
|
98
|
+
action: "refund",
|
|
99
|
+
transaction_id: params.charge,
|
|
100
|
+
reason: "requested_by_customer",
|
|
101
|
+
type: params.amount != null ? "partial" : "full",
|
|
102
|
+
});
|
|
103
|
+
return {
|
|
104
|
+
id: String(adj.id),
|
|
105
|
+
amount: Number(adj.totals?.total ?? params.amount ?? 0),
|
|
106
|
+
status: String(adj.status ?? "pending"),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
async listInvoices(customer) {
|
|
110
|
+
const res = await this.call("GET", `/transactions?customer_id=${customer}&per_page=100`);
|
|
111
|
+
const data = Array.isArray(res) ? res : [];
|
|
112
|
+
return data.map((tx) => {
|
|
113
|
+
const totals = tx.details?.totals;
|
|
114
|
+
return {
|
|
115
|
+
id: String(tx.id),
|
|
116
|
+
number: tx.invoice_number ?? null,
|
|
117
|
+
total: Number(totals?.grand_total ?? 0),
|
|
118
|
+
currency: String(tx.currency_code ?? "usd").toLowerCase(),
|
|
119
|
+
status: String(tx.status ?? ""),
|
|
120
|
+
date: tx.created_at ? new Date(String(tx.created_at)) : null,
|
|
121
|
+
url: null,
|
|
122
|
+
};
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
/* ------------------------------ checkout ------------------------------- */
|
|
126
|
+
async createCheckoutSession(params) {
|
|
127
|
+
// A transaction is Paddle's server-side checkout handle; the overlay/inline
|
|
128
|
+
// widget completes it client-side with the client token.
|
|
129
|
+
const tx = await this.call("POST", "/transactions", {
|
|
130
|
+
items: params.items.map((i) => ({ price_id: i.price, quantity: i.quantity ?? 1 })),
|
|
131
|
+
customer_id: params.customer,
|
|
132
|
+
collection_mode: "automatic",
|
|
133
|
+
custom_data: params.metadata,
|
|
134
|
+
});
|
|
135
|
+
const checkout = tx.checkout ?? {};
|
|
136
|
+
return {
|
|
137
|
+
id: String(tx.id),
|
|
138
|
+
url: checkout.url ?? null,
|
|
139
|
+
clientToken: this.options.clientToken ?? null,
|
|
140
|
+
raw: tx,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
/* ------------------------------ webhooks ------------------------------- */
|
|
144
|
+
async verifyWebhook(rawBody, headers, secret) {
|
|
145
|
+
const header = headers("paddle-signature");
|
|
146
|
+
if (!header || !secret)
|
|
147
|
+
return null;
|
|
148
|
+
const parts = Object.fromEntries(header.split(";").map((p) => {
|
|
149
|
+
const [k, v] = p.split("=");
|
|
150
|
+
return [k?.trim(), v?.trim()];
|
|
151
|
+
}));
|
|
152
|
+
if (!parts.ts || !parts.h1)
|
|
153
|
+
return null;
|
|
154
|
+
const expected = await hmacSha256Hex(secret, `${parts.ts}:${rawBody}`);
|
|
155
|
+
if (!constantTimeEqual(parts.h1, expected))
|
|
156
|
+
return null;
|
|
157
|
+
const event = JSON.parse(rawBody);
|
|
158
|
+
const subscription = event.event_type.startsWith("subscription.")
|
|
159
|
+
? this.normalize(event.data)
|
|
160
|
+
: undefined;
|
|
161
|
+
return {
|
|
162
|
+
id: event.event_id ?? "",
|
|
163
|
+
type: event.event_type,
|
|
164
|
+
payload: event,
|
|
165
|
+
subscription,
|
|
166
|
+
customer: subscription?.customer ?? event.data.customer_id,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
/* ------------------------------ mapping -------------------------------- */
|
|
170
|
+
normalize(sub) {
|
|
171
|
+
const items = (sub.items ?? []).map((it) => {
|
|
172
|
+
const price = it.price ?? {};
|
|
173
|
+
return {
|
|
174
|
+
id: String(price.id ?? ""),
|
|
175
|
+
product: String(price.product_id ?? ""),
|
|
176
|
+
price: String(price.id ?? ""),
|
|
177
|
+
quantity: it.quantity != null ? Number(it.quantity) : undefined,
|
|
178
|
+
};
|
|
179
|
+
});
|
|
180
|
+
const scheduled = sub.scheduled_change ?? null;
|
|
181
|
+
const endsAt = scheduled && scheduled.action === "cancel" && scheduled.effective_at
|
|
182
|
+
? new Date(String(scheduled.effective_at))
|
|
183
|
+
: sub.canceled_at
|
|
184
|
+
? new Date(String(sub.canceled_at))
|
|
185
|
+
: null;
|
|
186
|
+
const trialEnd = sub.items?.[0]?.trial_dates;
|
|
187
|
+
return {
|
|
188
|
+
id: String(sub.id),
|
|
189
|
+
status: String(sub.status ?? ""),
|
|
190
|
+
items,
|
|
191
|
+
quantity: items[0]?.quantity ?? null,
|
|
192
|
+
trialEndsAt: trialEnd?.ends_at ? new Date(String(trialEnd.ends_at)) : null,
|
|
193
|
+
endsAt,
|
|
194
|
+
customer: sub.customer_id ? String(sub.customer_id) : undefined,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
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 type { BillingGateway, CustomerDetails, GatewayCustomer, CreateSubscriptionParams, GatewaySubscription, SwapParams, CancelParams, ChargeParams, GatewayCharge, RefundParams, GatewayRefund, GatewayInvoice, CheckoutParams, CheckoutSession, SetupIntent, PaymentMethod, HeaderBag, WebhookEvent } from "../gateway.js";
|
|
11
|
+
export declare class StripeGateway implements BillingGateway {
|
|
12
|
+
private key;
|
|
13
|
+
readonly name = "stripe";
|
|
14
|
+
constructor(key: string);
|
|
15
|
+
private call;
|
|
16
|
+
createCustomer(details: CustomerDetails): Promise<GatewayCustomer>;
|
|
17
|
+
updateCustomer(id: string, details: CustomerDetails): Promise<GatewayCustomer>;
|
|
18
|
+
createSubscription(params: CreateSubscriptionParams): Promise<GatewaySubscription>;
|
|
19
|
+
swapSubscription(id: string, params: SwapParams): Promise<GatewaySubscription>;
|
|
20
|
+
updateQuantity(id: string, quantity: number, opts?: {
|
|
21
|
+
prorate?: boolean;
|
|
22
|
+
}): Promise<GatewaySubscription>;
|
|
23
|
+
cancelSubscription(id: string, params?: CancelParams): Promise<GatewaySubscription>;
|
|
24
|
+
resumeSubscription(id: string): Promise<GatewaySubscription>;
|
|
25
|
+
charge(params: ChargeParams): Promise<GatewayCharge>;
|
|
26
|
+
refund(params: RefundParams): Promise<GatewayRefund>;
|
|
27
|
+
listInvoices(customer: string): Promise<GatewayInvoice[]>;
|
|
28
|
+
createCheckoutSession(params: CheckoutParams): Promise<CheckoutSession>;
|
|
29
|
+
createSetupIntent(customer: string): Promise<SetupIntent>;
|
|
30
|
+
paymentMethods(customer: string): Promise<PaymentMethod[]>;
|
|
31
|
+
verifyWebhook(rawBody: string, headers: HeaderBag, secret: string): Promise<WebhookEvent | null>;
|
|
32
|
+
private normalize;
|
|
33
|
+
}
|