@copilotz/billing 0.0.0-stage → 1.0.0-rc.35
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/LICENSE +21 -0
- package/README.md +48 -2
- package/contract.d.ts +7 -0
- package/contract.js +7 -0
- package/credits/actions/check.d.ts +5 -0
- package/credits/actions/check.js +97 -0
- package/credits/actions/manage.d.ts +9 -0
- package/credits/actions/manage.js +266 -0
- package/credits/actions/state.d.ts +2 -0
- package/credits/actions/state.js +172 -0
- package/credits/collections/credit.d.ts +110 -0
- package/credits/collections/credit.js +31 -0
- package/credits/collections/payment.d.ts +139 -0
- package/credits/collections/payment.js +32 -0
- package/credits/collections/plan.d.ts +428 -0
- package/credits/collections/plan.js +97 -0
- package/credits/collections/room.d.ts +85 -0
- package/credits/collections/room.js +24 -0
- package/credits/contract.d.ts +19 -0
- package/credits/contract.js +12 -0
- package/credits/mod.d.ts +38 -0
- package/credits/mod.js +72 -0
- package/credits/shared/apply.d.ts +66 -0
- package/credits/shared/apply.js +106 -0
- package/credits/shared/settings.d.ts +70 -0
- package/credits/shared/settings.js +28 -0
- package/credits/shared/standing.d.ts +71 -0
- package/credits/shared/standing.js +235 -0
- package/credits/shared/walk.d.ts +50 -0
- package/credits/shared/walk.js +127 -0
- package/credits/views/web/add.d.ts +25 -0
- package/credits/views/web/add.js +92 -0
- package/credits/views/web/budget.d.ts +16 -0
- package/credits/views/web/budget.js +62 -0
- package/credits/views/web/composer.d.ts +13 -0
- package/credits/views/web/composer.js +65 -0
- package/credits/views/web/history.d.ts +12 -0
- package/credits/views/web/history.js +55 -0
- package/credits/views/web/numbers.d.ts +256 -0
- package/credits/views/web/numbers.js +291 -0
- package/credits/views/web/org.d.ts +31 -0
- package/credits/views/web/org.js +238 -0
- package/credits/views/web/plan.d.ts +28 -0
- package/credits/views/web/plan.js +84 -0
- package/credits/views/web/staff.d.ts +20 -0
- package/credits/views/web/staff.js +265 -0
- package/credits/views/web/style.d.ts +9 -0
- package/credits/views/web/style.js +190 -0
- package/credits/views/web/types.d.ts +17 -0
- package/credits/views/web/types.js +32 -0
- package/credits/web.d.ts +12 -0
- package/credits/web.js +33 -0
- package/mod.d.ts +26 -0
- package/mod.js +25 -0
- package/package.json +61 -4
- package/stripe/actions/checkout.d.ts +4 -0
- package/stripe/actions/checkout.js +188 -0
- package/stripe/actions/fact.d.ts +1 -0
- package/stripe/actions/fact.js +39 -0
- package/stripe/mod.d.ts +30 -0
- package/stripe/mod.js +52 -0
- package/stripe/routes/webhook.d.ts +16 -0
- package/stripe/routes/webhook.js +56 -0
- package/stripe/shared/api.d.ts +14 -0
- package/stripe/shared/api.js +42 -0
- package/stripe/shared/events.d.ts +42 -0
- package/stripe/shared/events.js +226 -0
- package/stripe/shared/settings.d.ts +39 -0
- package/stripe/shared/settings.js +10 -0
- package/web.d.ts +13 -0
- package/web.js +13 -0
package/mod.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Billing (`@copilotz/billing`): what an org has to spend, and paying for it,
|
|
3
|
+
* built on Copilotz's usage ledger (`usage`, in `copilotz/plugins/billing`),
|
|
4
|
+
* whose prices it counts.
|
|
5
|
+
*
|
|
6
|
+
* - `credits`: an org's plan, credits and payments, its balance and budget,
|
|
7
|
+
* and the check that stops it at its limit (the `spending` resource
|
|
8
|
+
* whatever spends reads); staff change plans and give credits by hand.
|
|
9
|
+
* - `stripe`: one way an org pays, by Stripe's Checkout and portal; what was
|
|
10
|
+
* paid lands by a signed webhook (`stripeWebhook`, in the runtime of the
|
|
11
|
+
* app's one address: the directory's, with many orgs).
|
|
12
|
+
*
|
|
13
|
+
* ```ts
|
|
14
|
+
* import { usage } from "copilotz/plugins/billing";
|
|
15
|
+
* import { credits, stripe, stripeWebhook } from "@copilotz/billing";
|
|
16
|
+
*
|
|
17
|
+
* plugins: [models, chat, usage, credits, stripe, stripeWebhook, …]
|
|
18
|
+
* ```
|
|
19
|
+
*
|
|
20
|
+
* Their front end is `@copilotz/billing/web` (`creditsWeb`: the org page's
|
|
21
|
+
* Billing, the note above where you type); their shapes,
|
|
22
|
+
* `@copilotz/billing/contract`.
|
|
23
|
+
*/
|
|
24
|
+
export * from "./credits/mod.js";
|
|
25
|
+
export { API_VERSION, EVENTS, stripe, stripeWebhook } from "./stripe/mod.js";
|
package/package.json
CHANGED
|
@@ -1,6 +1,63 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@copilotz/billing",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.0.0-rc.35",
|
|
4
|
+
"description": "What an org has to spend (credits, plans, budgets) and paying for it with Stripe, on Copilotz's usage ledger. A Copilotz plugin.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"copilotz",
|
|
7
|
+
"agents",
|
|
8
|
+
"ai",
|
|
9
|
+
"plugin"
|
|
10
|
+
],
|
|
11
|
+
"homepage": "https://github.com/copilotzhq/copilotz/tree/main/packages/billing#readme",
|
|
12
|
+
"bugs": "https://github.com/copilotzhq/copilotz/issues",
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "git+https://github.com/copilotzhq/copilotz.git",
|
|
16
|
+
"directory": "packages/billing"
|
|
17
|
+
},
|
|
18
|
+
"license": "MIT",
|
|
19
|
+
"type": "module",
|
|
20
|
+
"engines": {
|
|
21
|
+
"node": ">=24"
|
|
22
|
+
},
|
|
23
|
+
"sideEffects": false,
|
|
24
|
+
"types": "./mod.d.ts",
|
|
25
|
+
"exports": {
|
|
26
|
+
".": {
|
|
27
|
+
"types": "./mod.d.ts",
|
|
28
|
+
"default": "./mod.js"
|
|
29
|
+
},
|
|
30
|
+
"./web": {
|
|
31
|
+
"types": "./web.d.ts",
|
|
32
|
+
"default": "./web.js"
|
|
33
|
+
},
|
|
34
|
+
"./contract": {
|
|
35
|
+
"types": "./contract.d.ts",
|
|
36
|
+
"default": "./contract.js"
|
|
37
|
+
},
|
|
38
|
+
"./package.json": "./package.json"
|
|
39
|
+
},
|
|
40
|
+
"files": [
|
|
41
|
+
"LICENSE",
|
|
42
|
+
"README.md",
|
|
43
|
+
"contract.d.ts",
|
|
44
|
+
"contract.js",
|
|
45
|
+
"credits",
|
|
46
|
+
"mod.d.ts",
|
|
47
|
+
"mod.js",
|
|
48
|
+
"stripe",
|
|
49
|
+
"web.d.ts",
|
|
50
|
+
"web.js"
|
|
51
|
+
],
|
|
52
|
+
"dependencies": {},
|
|
53
|
+
"peerDependencies": {
|
|
54
|
+
"@types/react": "^19.3.0",
|
|
55
|
+
"copilotz": "1.0.0-rc.35",
|
|
56
|
+
"react": "^19.3.1"
|
|
57
|
+
},
|
|
58
|
+
"peerDependenciesMeta": {
|
|
59
|
+
"@types/react": {
|
|
60
|
+
"optional": true
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Paying, as owners and admins of an org do, in person. Each answers `{ url }`,
|
|
3
|
+
* a page of Stripe's: card details never reach the app.
|
|
4
|
+
*
|
|
5
|
+
* - `billing.checkout` `{ credits, country? }`: buy credits, once (USD);
|
|
6
|
+
* - `billing.checkout` `{ plan, country? }`: subscribe to a plan, a seat each;
|
|
7
|
+
* - `billing.portal` `{}`: manage what's being paid: card, invoices, cancelling.
|
|
8
|
+
* - `billing.sellable` `{}`: the plans the org's seller has a price for, so
|
|
9
|
+
* the Billing page offers only those (credits' `pay.sellable`).
|
|
10
|
+
*
|
|
11
|
+
* What was bought lands by the webhook, not here: a person can close the page,
|
|
12
|
+
* and only the webhook is signed.
|
|
13
|
+
*/
|
|
14
|
+
import { defineAction, failure, forbidden, inPerson, invalid } from "copilotz";
|
|
15
|
+
import { runsOrg } from "copilotz/plugins/access/contract";
|
|
16
|
+
import { billableSeats, orgKind, unfit, } from "../../credits/contract.js";
|
|
17
|
+
import { stripeCall } from "../shared/api.js";
|
|
18
|
+
import { sellerFor, settingsOf } from "../shared/settings.js";
|
|
19
|
+
async function runsIt(ctx) {
|
|
20
|
+
if (ctx.actor?.staff === true)
|
|
21
|
+
return;
|
|
22
|
+
const me = await ctx.call("org.me", {});
|
|
23
|
+
if (!runsOrg(me.role))
|
|
24
|
+
throw forbidden("Only the org's owners and admins do this.");
|
|
25
|
+
}
|
|
26
|
+
const planOf = (ctx) => ctx.as().call("billing.plan", {});
|
|
27
|
+
/** A plan as going live leaves it: a test mode's customer and subscription mean nothing there. */
|
|
28
|
+
function fresh(set, plan) {
|
|
29
|
+
if (!set.live || !plan.sandbox)
|
|
30
|
+
return plan;
|
|
31
|
+
const { customer: _c, subscription: _s, seller: _l, status: _t, ...rest } = plan;
|
|
32
|
+
return rest;
|
|
33
|
+
}
|
|
34
|
+
const orgOf = (ctx) => {
|
|
35
|
+
const org = ctx.resources.org;
|
|
36
|
+
if (!org?.id)
|
|
37
|
+
throw invalid("This app doesn't sell by org.");
|
|
38
|
+
return org.id;
|
|
39
|
+
};
|
|
40
|
+
export const checkoutAction = defineAction({
|
|
41
|
+
description: "A page where the org buys credits, or subscribes to a plan",
|
|
42
|
+
record: false,
|
|
43
|
+
input: {
|
|
44
|
+
type: "object",
|
|
45
|
+
properties: {
|
|
46
|
+
credits: { type: "number", exclusiveMinimum: 0 },
|
|
47
|
+
plan: { type: "string" },
|
|
48
|
+
country: { type: "string", minLength: 2, maxLength: 2 },
|
|
49
|
+
},
|
|
50
|
+
additionalProperties: false,
|
|
51
|
+
},
|
|
52
|
+
allow: inPerson,
|
|
53
|
+
run: async (ctx, input) => {
|
|
54
|
+
await runsIt(ctx);
|
|
55
|
+
const org = orgOf(ctx);
|
|
56
|
+
const set = settingsOf(ctx);
|
|
57
|
+
const billing = ctx.resources.billing;
|
|
58
|
+
// With limits off nothing's for sale yet; staff can try it.
|
|
59
|
+
if (billing.enforce === false && ctx.actor?.staff !== true) {
|
|
60
|
+
throw failure("not_yet", "Nothing is charged for here yet.");
|
|
61
|
+
}
|
|
62
|
+
const plan = fresh(set, await planOf(ctx));
|
|
63
|
+
const chosen = sellerFor(set, plan.seller, input.country);
|
|
64
|
+
if (!chosen)
|
|
65
|
+
throw failure("no_seller", "Paying isn't set up here yet.");
|
|
66
|
+
const [name, seller] = chosen;
|
|
67
|
+
const back = (paid) => `${set.url}/org?tab=billing&paid=${paid}`;
|
|
68
|
+
const common = {
|
|
69
|
+
...(plan.customer ? { customer: plan.customer } : {}),
|
|
70
|
+
client_reference_id: org,
|
|
71
|
+
cancel_url: `${set.url}/org?tab=billing`,
|
|
72
|
+
};
|
|
73
|
+
const call = (params) => stripeCall({ key: seller.key, fetch: ctx.fetch }, "POST", "/v1/checkout/sessions", {
|
|
74
|
+
...common,
|
|
75
|
+
...params,
|
|
76
|
+
});
|
|
77
|
+
if (input.plan) {
|
|
78
|
+
const terms = billing.plans[input.plan];
|
|
79
|
+
const price = seller.prices[input.plan];
|
|
80
|
+
if (!terms || !price)
|
|
81
|
+
throw invalid(`The plan "${input.plan}" can't be bought here.`);
|
|
82
|
+
const why = unfit(terms, orgKind(ctx));
|
|
83
|
+
if (why)
|
|
84
|
+
throw invalid(why);
|
|
85
|
+
if (plan.subscription && plan.status !== "canceled") {
|
|
86
|
+
throw failure("subscribed", "This org already has a subscription: manage it instead.");
|
|
87
|
+
}
|
|
88
|
+
// What the Billing page said it would come to: those not waived.
|
|
89
|
+
const people = await ctx.as().call("org.seats", {}).catch(() => 1);
|
|
90
|
+
const seats = billableSeats(plan, Math.max(people, 1), new Date().toISOString());
|
|
91
|
+
if (!seats)
|
|
92
|
+
throw failure("waived", "This org's seats are waived: there's nothing to pay.");
|
|
93
|
+
const metadata = { org, plan: input.plan, seller: name };
|
|
94
|
+
const { url } = await call({
|
|
95
|
+
mode: "subscription",
|
|
96
|
+
integration_identifier: "compass_plan_qwhtzkvb",
|
|
97
|
+
line_items: [{ price, quantity: seats }],
|
|
98
|
+
allow_promotion_codes: true,
|
|
99
|
+
metadata,
|
|
100
|
+
subscription_data: { metadata },
|
|
101
|
+
success_url: back("plan"),
|
|
102
|
+
});
|
|
103
|
+
return { url };
|
|
104
|
+
}
|
|
105
|
+
if (!input.credits)
|
|
106
|
+
throw invalid("Say how many credits.");
|
|
107
|
+
const credits = Math.round(input.credits * 100) / 100;
|
|
108
|
+
if (credits < billing.topUpMin || credits > billing.topUpMax) {
|
|
109
|
+
throw invalid(`Choose between $${billing.topUpMin} and $${billing.topUpMax}.`);
|
|
110
|
+
}
|
|
111
|
+
if (seller.currency !== "usd" && !seller.rate) {
|
|
112
|
+
throw failure("no_seller", "Paying isn't set up here yet.");
|
|
113
|
+
}
|
|
114
|
+
const unit = Math.ceil(credits / (seller.rate ?? 1) * 100);
|
|
115
|
+
const metadata = { org, purpose: "credits", credits: String(credits), seller: name };
|
|
116
|
+
const { url } = await call({
|
|
117
|
+
mode: "payment",
|
|
118
|
+
integration_identifier: "compass_credits_nmrplsjd",
|
|
119
|
+
line_items: [{
|
|
120
|
+
quantity: 1,
|
|
121
|
+
price_data: { currency: seller.currency, unit_amount: unit, product: seller.credits },
|
|
122
|
+
}],
|
|
123
|
+
...(plan.customer ? {} : { customer_creation: "always" }),
|
|
124
|
+
metadata,
|
|
125
|
+
payment_intent_data: { metadata },
|
|
126
|
+
success_url: back("credits"),
|
|
127
|
+
});
|
|
128
|
+
return { url };
|
|
129
|
+
},
|
|
130
|
+
});
|
|
131
|
+
export const sellableAction = defineAction({
|
|
132
|
+
description: "The plans the org's seller can sell it",
|
|
133
|
+
record: false,
|
|
134
|
+
input: { type: "object", additionalProperties: false },
|
|
135
|
+
run: async (ctx) => {
|
|
136
|
+
const set = settingsOf(ctx);
|
|
137
|
+
// As checkout would choose: the seller it pays already, else the app's.
|
|
138
|
+
const had = await ctx.as().db.collection("billingPlan").findOne({ _id: "plan" });
|
|
139
|
+
const chosen = sellerFor(set, had ? fresh(set, had).seller : undefined);
|
|
140
|
+
return chosen ? Object.keys(chosen[1].prices) : [];
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
export const portalAction = defineAction({
|
|
144
|
+
description: "A page where the org manages what it pays: card, invoices, cancelling",
|
|
145
|
+
record: false,
|
|
146
|
+
input: { type: "object", additionalProperties: false },
|
|
147
|
+
allow: inPerson,
|
|
148
|
+
run: async (ctx) => {
|
|
149
|
+
await runsIt(ctx);
|
|
150
|
+
const set = settingsOf(ctx);
|
|
151
|
+
const plan = fresh(set, await planOf(ctx));
|
|
152
|
+
const chosen = sellerFor(set, plan.seller);
|
|
153
|
+
if (!chosen || !plan.customer) {
|
|
154
|
+
throw failure("not_paying", "There's nothing to manage yet: nothing has been paid.");
|
|
155
|
+
}
|
|
156
|
+
const { url } = await stripeCall({ key: chosen[1].key, fetch: ctx.fetch }, "POST", "/v1/billing_portal/sessions", {
|
|
157
|
+
customer: plan.customer,
|
|
158
|
+
return_url: `${set.url}/org?tab=billing`,
|
|
159
|
+
...(chosen[1].portal ? { configuration: chosen[1].portal } : {}),
|
|
160
|
+
});
|
|
161
|
+
return { url };
|
|
162
|
+
},
|
|
163
|
+
});
|
|
164
|
+
export const seatsAction = defineAction({
|
|
165
|
+
description: "Brings a subscription's seats to the org's people, each day",
|
|
166
|
+
input: { type: "object", additionalProperties: false },
|
|
167
|
+
run: async (ctx) => {
|
|
168
|
+
const set = settingsOf(ctx);
|
|
169
|
+
const plan = await ctx.as().db.collection("billingPlan").findOne({ _id: "plan" });
|
|
170
|
+
if (!plan?.subscription || plan.status !== "active" || !plan.seller)
|
|
171
|
+
return {};
|
|
172
|
+
const seller = set.sellers[plan.seller];
|
|
173
|
+
if (!seller)
|
|
174
|
+
return {};
|
|
175
|
+
const people = await ctx.as().call("org.seats", {}).catch(() => 1);
|
|
176
|
+
const seats = billableSeats(plan, people, new Date().toISOString());
|
|
177
|
+
// Every seat waived: staff decide what happens to its subscription.
|
|
178
|
+
if (!seats)
|
|
179
|
+
return {};
|
|
180
|
+
const key = { key: seller.key, fetch: ctx.fetch };
|
|
181
|
+
const subscription = await stripeCall(key, "GET", `/v1/subscriptions/${plan.subscription}`);
|
|
182
|
+
const item = subscription.items.data[0];
|
|
183
|
+
if (!item || item.quantity === seats)
|
|
184
|
+
return {};
|
|
185
|
+
await stripeCall(key, "POST", `/v1/subscription_items/${item.id}`, { quantity: seats });
|
|
186
|
+
return { seats };
|
|
187
|
+
},
|
|
188
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const factAction: import("copilotz").ActionDef;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `stripe.fact` `{ seller, event }`: what an event Stripe sent (its
|
|
3
|
+
* signature already checked, by the webhook's route) comes to for an org, as
|
|
4
|
+
* facts for `billing.apply`: `{ fact }`, or `{ fact: null }` when it isn't
|
|
5
|
+
* ours (another product on the account) or keeps nothing. A dispute's
|
|
6
|
+
* charge is asked of Stripe (through `ctx.fetch`). The plan an org goes back
|
|
7
|
+
* to when its subscription ends is `billing.start` ("free" unless the app
|
|
8
|
+
* says). The system's, and unrecorded: its input is Stripe's event, and what
|
|
9
|
+
* it comes to lands in the org, as `billing.apply`, under the event's id.
|
|
10
|
+
*/
|
|
11
|
+
import { defineAction, invalid } from "copilotz";
|
|
12
|
+
import { stripeCall } from "../shared/api.js";
|
|
13
|
+
import { factOf, factOfDispute } from "../shared/events.js";
|
|
14
|
+
import { settingsOf } from "../shared/settings.js";
|
|
15
|
+
export const factAction = defineAction({
|
|
16
|
+
description: "What a Stripe event comes to for an org: facts for billing.apply, or none",
|
|
17
|
+
record: false,
|
|
18
|
+
input: {
|
|
19
|
+
type: "object",
|
|
20
|
+
properties: { seller: { type: "string" }, event: { type: "object" } },
|
|
21
|
+
required: ["seller", "event"],
|
|
22
|
+
additionalProperties: false,
|
|
23
|
+
},
|
|
24
|
+
run: async (ctx, input) => {
|
|
25
|
+
const seller = settingsOf(ctx).sellers[input.seller];
|
|
26
|
+
if (!seller)
|
|
27
|
+
throw invalid(`No seller "${input.seller}"`);
|
|
28
|
+
// deno-lint-ignore no-explicit-any
|
|
29
|
+
const event = input.event;
|
|
30
|
+
const named = { name: input.seller, ...(seller.rate ? { rate: seller.rate } : {}) };
|
|
31
|
+
const start = ctx.resources.billing?.start ?? "free";
|
|
32
|
+
if (event.type === "charge.dispute.created" || event.type === "charge.dispute.closed") {
|
|
33
|
+
const dispute = event.data.object;
|
|
34
|
+
const charge = await stripeCall({ key: seller.key, fetch: ctx.fetch }, "GET", `/v1/charges/${typeof dispute.charge === "string" ? dispute.charge : dispute.charge.id}`);
|
|
35
|
+
return { fact: factOfDispute(event.type, dispute, charge, named) };
|
|
36
|
+
}
|
|
37
|
+
return { fact: factOf(event, named, start) };
|
|
38
|
+
},
|
|
39
|
+
});
|
package/stripe/mod.d.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stripe: one way an org pays, for `credits`, which never asks it anything.
|
|
3
|
+
* Checkout and the customer portal are Stripe's pages, so no card reaches the
|
|
4
|
+
* app; what was paid lands by a signed webhook (`stripeWebhook`, its route
|
|
5
|
+
* `POST /__stripe/<seller>`: routes/webhook.ts), as `billing.apply`.
|
|
6
|
+
* One or more accounts sell (`sellers`): an org stays with whichever first took
|
|
7
|
+
* its money, chosen by its billing country. A plan is offered to an org only
|
|
8
|
+
* once its seller has a price for it (`billing.sellable`).
|
|
9
|
+
*
|
|
10
|
+
* plugins: [usage, credits, stripe, stripeWebhook, …]
|
|
11
|
+
* resources: { stripe: { url, seller: "main", sellers: { main: { key, … } } } }
|
|
12
|
+
*
|
|
13
|
+
* With many orgs, the webhook is the directory's (one address per account),
|
|
14
|
+
* and finds each org's runtime in the app's orgs:
|
|
15
|
+
*
|
|
16
|
+
* directory's plugins: [identity, directory, stripeWebhook, …]
|
|
17
|
+
* resources: { stripe: { ...stripeSettings, orgs } }
|
|
18
|
+
*/
|
|
19
|
+
import { type Plugin } from "copilotz";
|
|
20
|
+
export { EVENTS } from "./shared/events.js";
|
|
21
|
+
export { API_VERSION } from "./shared/api.js";
|
|
22
|
+
export type { Seller, StripeSettings } from "./shared/settings.js";
|
|
23
|
+
export declare const stripe: Plugin;
|
|
24
|
+
/**
|
|
25
|
+
* Stripe's webhook, `POST /__stripe/<seller>`, where the app's accounts send
|
|
26
|
+
* their events (see routes/webhook.ts): in the runtime of the app's one
|
|
27
|
+
* address (the directory's, with many orgs), beside the `stripe` resource.
|
|
28
|
+
*/
|
|
29
|
+
export declare const stripeWebhook: Plugin;
|
|
30
|
+
export default stripe;
|
package/stripe/mod.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stripe: one way an org pays, for `credits`, which never asks it anything.
|
|
3
|
+
* Checkout and the customer portal are Stripe's pages, so no card reaches the
|
|
4
|
+
* app; what was paid lands by a signed webhook (`stripeWebhook`, its route
|
|
5
|
+
* `POST /__stripe/<seller>`: routes/webhook.ts), as `billing.apply`.
|
|
6
|
+
* One or more accounts sell (`sellers`): an org stays with whichever first took
|
|
7
|
+
* its money, chosen by its billing country. A plan is offered to an org only
|
|
8
|
+
* once its seller has a price for it (`billing.sellable`).
|
|
9
|
+
*
|
|
10
|
+
* plugins: [usage, credits, stripe, stripeWebhook, …]
|
|
11
|
+
* resources: { stripe: { url, seller: "main", sellers: { main: { key, … } } } }
|
|
12
|
+
*
|
|
13
|
+
* With many orgs, the webhook is the directory's (one address per account),
|
|
14
|
+
* and finds each org's runtime in the app's orgs:
|
|
15
|
+
*
|
|
16
|
+
* directory's plugins: [identity, directory, stripeWebhook, …]
|
|
17
|
+
* resources: { stripe: { ...stripeSettings, orgs } }
|
|
18
|
+
*/
|
|
19
|
+
import { definePlugin } from "copilotz";
|
|
20
|
+
import { checkoutAction, portalAction, seatsAction, sellableAction } from "./actions/checkout.js";
|
|
21
|
+
import { factAction } from "./actions/fact.js";
|
|
22
|
+
import { webhookRoute } from "./routes/webhook.js";
|
|
23
|
+
export { EVENTS } from "./shared/events.js";
|
|
24
|
+
export { API_VERSION } from "./shared/api.js";
|
|
25
|
+
export const stripe = definePlugin({
|
|
26
|
+
name: "stripe",
|
|
27
|
+
requires: ["credits"],
|
|
28
|
+
actions: {
|
|
29
|
+
"billing.checkout": checkoutAction,
|
|
30
|
+
"billing.portal": portalAction,
|
|
31
|
+
"billing.sellable": sellableAction,
|
|
32
|
+
"stripe.seats": seatsAction,
|
|
33
|
+
},
|
|
34
|
+
schedules: { "stripe.seats": { cron: "40 0 * * *", call: "stripe.seats", input: {} } },
|
|
35
|
+
resources: {
|
|
36
|
+
billing: {
|
|
37
|
+
pay: { checkout: "billing.checkout", portal: "billing.portal", sellable: "billing.sellable" },
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
/**
|
|
42
|
+
* Stripe's webhook, `POST /__stripe/<seller>`, where the app's accounts send
|
|
43
|
+
* their events (see routes/webhook.ts): in the runtime of the app's one
|
|
44
|
+
* address (the directory's, with many orgs), beside the `stripe` resource.
|
|
45
|
+
*/
|
|
46
|
+
export const stripeWebhook = definePlugin({
|
|
47
|
+
name: "stripe-webhook",
|
|
48
|
+
actions: { "stripe.fact": factAction },
|
|
49
|
+
routes: { "/__stripe/:seller": webhookRoute },
|
|
50
|
+
resources: { stripe: {} },
|
|
51
|
+
});
|
|
52
|
+
export default stripe;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stripe's webhook, `POST /__stripe/<seller>`: the address each account's
|
|
3
|
+
* dashboard sends its events to, one for every org, so it's the app's (in
|
|
4
|
+
* an app of many orgs, the directory's runtime). The signature is checked
|
|
5
|
+
* against that seller's secret, on the raw text; the event read as facts
|
|
6
|
+
* (`stripe.fact`); and handed to its org's runtime as `billing.apply`, under
|
|
7
|
+
* the event's own id, so an event sent twice answers from the org's log.
|
|
8
|
+
*
|
|
9
|
+
* Which runtime is the org's: the app's orgs (`stripe.orgs`, the directory's
|
|
10
|
+
* `runtimes`), opened if it has to be; without them, this runtime, when the
|
|
11
|
+
* event is for its org (`resources.org`). Anything that can be tried again
|
|
12
|
+
* answers 500, and Stripe does; what never can (no such org, an event not
|
|
13
|
+
* ours, a plan the app no longer has) answers 200.
|
|
14
|
+
*/
|
|
15
|
+
import type { Route } from "copilotz";
|
|
16
|
+
export declare const webhookRoute: Route;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { verified } from "../shared/events.js";
|
|
2
|
+
import { settingsOf } from "../shared/settings.js";
|
|
3
|
+
const answer = (status, body = {}) => new Response(JSON.stringify(body), {
|
|
4
|
+
status,
|
|
5
|
+
headers: { "content-type": "application/json", "cache-control": "no-store" },
|
|
6
|
+
});
|
|
7
|
+
export const webhookRoute = async (request, { runtime, params }) => {
|
|
8
|
+
if (request.method !== "POST")
|
|
9
|
+
return answer(405, { error: "Only POST" });
|
|
10
|
+
const resources = runtime.describe().resources;
|
|
11
|
+
const settings = settingsOf({ resources });
|
|
12
|
+
const name = decodeURIComponent(params.seller);
|
|
13
|
+
const seller = Object.hasOwn(settings.sellers, name) ? settings.sellers[name] : undefined;
|
|
14
|
+
if (!seller)
|
|
15
|
+
return answer(404, { error: "No such seller" });
|
|
16
|
+
const payload = await request.text();
|
|
17
|
+
if (!await verified(seller.webhookSecret, payload, request.headers.get("stripe-signature"))) {
|
|
18
|
+
return answer(400, { error: "Bad signature" });
|
|
19
|
+
}
|
|
20
|
+
let event;
|
|
21
|
+
try {
|
|
22
|
+
event = JSON.parse(payload);
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return answer(400, { error: "Not JSON" });
|
|
26
|
+
}
|
|
27
|
+
try {
|
|
28
|
+
const { fact } = await runtime.call("stripe.fact", {
|
|
29
|
+
seller: name,
|
|
30
|
+
event,
|
|
31
|
+
});
|
|
32
|
+
// Not ours (another product on the account), or nothing to keep: received all the same.
|
|
33
|
+
if (!fact)
|
|
34
|
+
return answer(200, { ignored: event.type });
|
|
35
|
+
const own = resources.org?.id;
|
|
36
|
+
const target = settings.orgs
|
|
37
|
+
? await settings.orgs.get(fact.org)
|
|
38
|
+
: own === fact.org
|
|
39
|
+
? runtime
|
|
40
|
+
: undefined;
|
|
41
|
+
// An org that's gone won't come back, so there's nothing to try again.
|
|
42
|
+
if (!target)
|
|
43
|
+
return answer(200, { ignored: "no such org" });
|
|
44
|
+
const { org: _, ...facts } = fact;
|
|
45
|
+
await target.call("billing.apply", facts, { id: `stripe:${event.id}` });
|
|
46
|
+
return answer(200);
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
console.error(`stripe: ${event.type} (${event.id}):`, error instanceof Error ? error.message : error);
|
|
50
|
+
// What can never land (a plan the app no longer has) isn't worth trying again.
|
|
51
|
+
if (error.code === "invalid") {
|
|
52
|
+
return answer(200, { ignored: "invalid" });
|
|
53
|
+
}
|
|
54
|
+
return answer(500, { error: "Try again" });
|
|
55
|
+
}
|
|
56
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Calling Stripe: its REST API over `fetch`, with no package to carry, on one
|
|
3
|
+
* API version. Parameters go form-encoded, nested as `a[b][0]=c`.
|
|
4
|
+
*/
|
|
5
|
+
export declare const API_VERSION = "2026-10-28.endive";
|
|
6
|
+
type Params = Record<string, unknown>;
|
|
7
|
+
/** `{ a: { b: [1] } }` as `a[b][0]=1`; what's undefined is left out. */
|
|
8
|
+
export declare function encode(params: Params, prefix?: string): [string, string][];
|
|
9
|
+
export declare function stripeCall<R = Record<string, unknown>>(options: {
|
|
10
|
+
key: string;
|
|
11
|
+
fetch: typeof fetch;
|
|
12
|
+
idempotency?: string;
|
|
13
|
+
}, method: "GET" | "POST", path: string, params?: Params): Promise<R>;
|
|
14
|
+
export {};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Calling Stripe: its REST API over `fetch`, with no package to carry, on one
|
|
3
|
+
* API version. Parameters go form-encoded, nested as `a[b][0]=c`.
|
|
4
|
+
*/
|
|
5
|
+
export const API_VERSION = "2026-10-28.endive";
|
|
6
|
+
const BASE = "https://api.stripe.com";
|
|
7
|
+
/** `{ a: { b: [1] } }` as `a[b][0]=1`; what's undefined is left out. */
|
|
8
|
+
export function encode(params, prefix = "") {
|
|
9
|
+
return Object.entries(params).flatMap(([key, value]) => {
|
|
10
|
+
if (value === undefined || value === null)
|
|
11
|
+
return [];
|
|
12
|
+
const name = prefix ? `${prefix}[${key}]` : key;
|
|
13
|
+
if (Array.isArray(value)) {
|
|
14
|
+
return value.flatMap((item, i) => typeof item === "object"
|
|
15
|
+
? encode(item, `${name}[${i}]`)
|
|
16
|
+
: [[`${name}[${i}]`, String(item)]]);
|
|
17
|
+
}
|
|
18
|
+
return typeof value === "object"
|
|
19
|
+
? encode(value, name)
|
|
20
|
+
: [[name, String(value)]];
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
export async function stripeCall(options, method, path, params = {}) {
|
|
24
|
+
const pairs = encode(params);
|
|
25
|
+
const body = new URLSearchParams(pairs).toString();
|
|
26
|
+
const url = method === "GET" && body ? `${BASE}${path}?${body}` : `${BASE}${path}`;
|
|
27
|
+
const response = await options.fetch(url, {
|
|
28
|
+
method,
|
|
29
|
+
headers: {
|
|
30
|
+
authorization: `Bearer ${options.key}`,
|
|
31
|
+
"stripe-version": API_VERSION,
|
|
32
|
+
...(method === "POST" ? { "content-type": "application/x-www-form-urlencoded" } : {}),
|
|
33
|
+
...(options.idempotency ? { "idempotency-key": options.idempotency } : {}),
|
|
34
|
+
},
|
|
35
|
+
...(method === "POST" ? { body } : {}),
|
|
36
|
+
});
|
|
37
|
+
const answer = await response.json().catch(() => ({}));
|
|
38
|
+
if (!response.ok) {
|
|
39
|
+
throw Object.assign(new Error(answer.error?.message ?? `Stripe answered ${response.status}.`), { code: answer.error?.code ?? "stripe", status: response.status });
|
|
40
|
+
}
|
|
41
|
+
return answer;
|
|
42
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What Stripe says, as what the app keeps. Whichever account it comes from:
|
|
3
|
+
* a signature checked, then an event read as a fact for `billing.apply`, which
|
|
4
|
+
* knows nothing of Stripe. Pure, apart from the clock and the crypto.
|
|
5
|
+
*/
|
|
6
|
+
import type { PaymentFact, PeriodFact, PlanFact } from "../../credits/contract.js";
|
|
7
|
+
/** `Stripe-Signature`, checked against the raw body: from this account, and recent. */
|
|
8
|
+
export declare function verified(secret: string, payload: string, header: string | null, now?: number): Promise<boolean>;
|
|
9
|
+
/** The events acted on: subscribe a webhook to these. */
|
|
10
|
+
export declare const EVENTS: string[];
|
|
11
|
+
/** What an event comes to for an org: facts for `billing.apply`; nothing when it isn't ours. */
|
|
12
|
+
export type Fact = {
|
|
13
|
+
org: string;
|
|
14
|
+
plan?: PlanFact;
|
|
15
|
+
payment?: PaymentFact;
|
|
16
|
+
period?: PeriodFact;
|
|
17
|
+
};
|
|
18
|
+
type Any = Record<string, any>;
|
|
19
|
+
export declare function factOf(event: {
|
|
20
|
+
id: string;
|
|
21
|
+
type: string;
|
|
22
|
+
created?: number;
|
|
23
|
+
livemode?: boolean;
|
|
24
|
+
data: {
|
|
25
|
+
object: Any;
|
|
26
|
+
previous_attributes?: Any;
|
|
27
|
+
};
|
|
28
|
+
}, seller: {
|
|
29
|
+
name: string;
|
|
30
|
+
rate?: number;
|
|
31
|
+
},
|
|
32
|
+
/** The plan an org goes back to when its subscription ends. */
|
|
33
|
+
start: string): Fact | null;
|
|
34
|
+
/**
|
|
35
|
+
* A dispute's credits, once its charge is known: opened, they go, as it holds the
|
|
36
|
+
* money until it's decided; won, they come back.
|
|
37
|
+
*/
|
|
38
|
+
export declare function factOfDispute(type: string, dispute: Any, charge: Any, seller: {
|
|
39
|
+
name: string;
|
|
40
|
+
rate?: number;
|
|
41
|
+
}): Fact | null;
|
|
42
|
+
export {};
|