@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.
Files changed (71) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +48 -2
  3. package/contract.d.ts +7 -0
  4. package/contract.js +7 -0
  5. package/credits/actions/check.d.ts +5 -0
  6. package/credits/actions/check.js +97 -0
  7. package/credits/actions/manage.d.ts +9 -0
  8. package/credits/actions/manage.js +266 -0
  9. package/credits/actions/state.d.ts +2 -0
  10. package/credits/actions/state.js +172 -0
  11. package/credits/collections/credit.d.ts +110 -0
  12. package/credits/collections/credit.js +31 -0
  13. package/credits/collections/payment.d.ts +139 -0
  14. package/credits/collections/payment.js +32 -0
  15. package/credits/collections/plan.d.ts +428 -0
  16. package/credits/collections/plan.js +97 -0
  17. package/credits/collections/room.d.ts +85 -0
  18. package/credits/collections/room.js +24 -0
  19. package/credits/contract.d.ts +19 -0
  20. package/credits/contract.js +12 -0
  21. package/credits/mod.d.ts +38 -0
  22. package/credits/mod.js +72 -0
  23. package/credits/shared/apply.d.ts +66 -0
  24. package/credits/shared/apply.js +106 -0
  25. package/credits/shared/settings.d.ts +70 -0
  26. package/credits/shared/settings.js +28 -0
  27. package/credits/shared/standing.d.ts +71 -0
  28. package/credits/shared/standing.js +235 -0
  29. package/credits/shared/walk.d.ts +50 -0
  30. package/credits/shared/walk.js +127 -0
  31. package/credits/views/web/add.d.ts +25 -0
  32. package/credits/views/web/add.js +92 -0
  33. package/credits/views/web/budget.d.ts +16 -0
  34. package/credits/views/web/budget.js +62 -0
  35. package/credits/views/web/composer.d.ts +13 -0
  36. package/credits/views/web/composer.js +65 -0
  37. package/credits/views/web/history.d.ts +12 -0
  38. package/credits/views/web/history.js +55 -0
  39. package/credits/views/web/numbers.d.ts +256 -0
  40. package/credits/views/web/numbers.js +291 -0
  41. package/credits/views/web/org.d.ts +31 -0
  42. package/credits/views/web/org.js +238 -0
  43. package/credits/views/web/plan.d.ts +28 -0
  44. package/credits/views/web/plan.js +84 -0
  45. package/credits/views/web/staff.d.ts +20 -0
  46. package/credits/views/web/staff.js +265 -0
  47. package/credits/views/web/style.d.ts +9 -0
  48. package/credits/views/web/style.js +190 -0
  49. package/credits/views/web/types.d.ts +17 -0
  50. package/credits/views/web/types.js +32 -0
  51. package/credits/web.d.ts +12 -0
  52. package/credits/web.js +33 -0
  53. package/mod.d.ts +26 -0
  54. package/mod.js +25 -0
  55. package/package.json +61 -4
  56. package/stripe/actions/checkout.d.ts +4 -0
  57. package/stripe/actions/checkout.js +188 -0
  58. package/stripe/actions/fact.d.ts +1 -0
  59. package/stripe/actions/fact.js +39 -0
  60. package/stripe/mod.d.ts +30 -0
  61. package/stripe/mod.js +52 -0
  62. package/stripe/routes/webhook.d.ts +16 -0
  63. package/stripe/routes/webhook.js +56 -0
  64. package/stripe/shared/api.d.ts +14 -0
  65. package/stripe/shared/api.js +42 -0
  66. package/stripe/shared/events.d.ts +42 -0
  67. package/stripe/shared/events.js +226 -0
  68. package/stripe/shared/settings.d.ts +39 -0
  69. package/stripe/shared/settings.js +10 -0
  70. package/web.d.ts +13 -0
  71. package/web.js +13 -0
@@ -0,0 +1,226 @@
1
+ const TOLERANCE_SECONDS = 300;
2
+ const hex = (bytes) => [...new Uint8Array(bytes)].map((b) => b.toString(16).padStart(2, "0")).join("");
3
+ const same = (a, b) => {
4
+ if (a.length !== b.length)
5
+ return false;
6
+ let differs = 0;
7
+ for (let i = 0; i < a.length; i++)
8
+ differs |= a.charCodeAt(i) ^ b.charCodeAt(i);
9
+ return differs === 0;
10
+ };
11
+ /** `Stripe-Signature`, checked against the raw body: from this account, and recent. */
12
+ export async function verified(secret, payload, header, now = Date.now()) {
13
+ if (!header)
14
+ return false;
15
+ const parts = header.split(",").map((part) => part.split("="));
16
+ const at = parts.find(([k]) => k === "t")?.[1];
17
+ const signatures = parts.filter(([k]) => k === "v1").map(([, v]) => v);
18
+ if (!at || !signatures.length)
19
+ return false;
20
+ if (Math.abs(now / 1000 - Number(at)) > TOLERANCE_SECONDS)
21
+ return false;
22
+ const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
23
+ const signed = hex(await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(`${at}.${payload}`)));
24
+ return signatures.some((signature) => same(signature, signed));
25
+ }
26
+ /** The events acted on: subscribe a webhook to these. */
27
+ export const EVENTS = [
28
+ "checkout.session.completed",
29
+ "checkout.session.async_payment_succeeded",
30
+ "invoice.paid",
31
+ "invoice.payment_failed",
32
+ "customer.subscription.updated",
33
+ "customer.subscription.deleted",
34
+ "charge.refunded",
35
+ "charge.dispute.created",
36
+ "charge.dispute.closed",
37
+ ];
38
+ const idOf = (value) => typeof value === "string" ? value : value?.id;
39
+ const main = (minor, currency) => // Currencies without cents are few, and none of this app's sellers charges in one.
40
+ (void currency, minor / 100);
41
+ /** An invoice's subscription and its metadata, wherever this API version puts them. */
42
+ const ofInvoice = (invoice) => {
43
+ const details = invoice.parent?.subscription_details ?? invoice.subscription_details ?? {};
44
+ return {
45
+ subscription: idOf(details.subscription ?? invoice.subscription),
46
+ metadata: (details.metadata ?? {}),
47
+ };
48
+ };
49
+ const day = (seconds) => new Date(seconds * 1000).toISOString().slice(0, 10);
50
+ /** A period's credits last a day past its end: its renewal's invoice comes hours into that day. */
51
+ const dayAfter = (seconds) => day(seconds + 86400);
52
+ /**
53
+ * What an invoice paid for, when it's a period of a plan: as many seats as it
54
+ * charged for. Those added midway pay a part, and get their credits from the next.
55
+ */
56
+ function periodOfInvoice(invoice, plan) {
57
+ if (!plan)
58
+ return undefined;
59
+ if (invoice.billing_reason !== "subscription_create" &&
60
+ invoice.billing_reason !== "subscription_cycle")
61
+ return undefined;
62
+ const line = (invoice.lines?.data ?? []).find((l) => l.quantity && l.period);
63
+ if (!line)
64
+ return undefined;
65
+ return {
66
+ plan,
67
+ start: day(line.period.start),
68
+ expires: dayAfter(line.period.end),
69
+ seats: line.quantity,
70
+ ...(invoice.livemode === false ? { sandbox: true } : {}),
71
+ };
72
+ }
73
+ /**
74
+ * Money going back or coming back on a charge that bought credits, by its own
75
+ * metadata: its credits in proportion. Charges for seats carry no such metadata,
76
+ * and are for staff to put right.
77
+ */
78
+ function creditsMoved(charge, amount, seller, ref, note) {
79
+ const org = charge.metadata?.org;
80
+ if (!org || !amount)
81
+ return null;
82
+ if (charge.metadata?.seller && charge.metadata.seller !== seller.name)
83
+ return null;
84
+ const credits = Number(charge.metadata?.credits ?? 0);
85
+ return {
86
+ org,
87
+ payment: {
88
+ channel: "stripe",
89
+ seller: seller.name,
90
+ ref,
91
+ amount: main(amount, charge.currency),
92
+ currency: charge.currency,
93
+ ...(seller.rate ? { rate: seller.rate } : {}),
94
+ purpose: "refund",
95
+ ...(credits && charge.amount
96
+ ? { credits: Math.round(credits * amount / charge.amount * 1e8) / 1e8 }
97
+ : {}),
98
+ note,
99
+ ...(charge.livemode === false ? { sandbox: true } : {}),
100
+ },
101
+ };
102
+ }
103
+ export function factOf(event, seller,
104
+ /** The plan an org goes back to when its subscription ends. */
105
+ start) {
106
+ const object = event.data.object;
107
+ const at = event.created ? new Date(event.created * 1000).toISOString() : undefined;
108
+ const when = at ? { at } : {};
109
+ const test = event.livemode === false ? { sandbox: true } : {};
110
+ const ours = (metadata) => !!metadata?.org && (!metadata.seller || metadata.seller === seller.name);
111
+ switch (event.type) {
112
+ case "checkout.session.completed":
113
+ case "checkout.session.async_payment_succeeded": {
114
+ // An async method (Pix, a transfer) can complete unpaid: its money comes with the second.
115
+ if (object.payment_status !== "paid" && object.payment_status !== "no_payment_required") {
116
+ return null;
117
+ }
118
+ if (!ours(object.metadata))
119
+ return null;
120
+ const org = object.metadata.org;
121
+ const customer = idOf(object.customer);
122
+ if (object.mode === "subscription") {
123
+ return {
124
+ org,
125
+ plan: {
126
+ name: object.metadata.plan,
127
+ channel: "stripe",
128
+ seller: seller.name,
129
+ ...(customer ? { customer } : {}),
130
+ ...(idOf(object.subscription) ? { subscription: idOf(object.subscription) } : {}),
131
+ status: "active",
132
+ ...when,
133
+ sandbox: event.livemode === false,
134
+ },
135
+ };
136
+ }
137
+ return {
138
+ org,
139
+ ...(customer
140
+ ? { plan: { customer, seller: seller.name, sandbox: event.livemode === false } }
141
+ : {}),
142
+ payment: {
143
+ channel: "stripe",
144
+ seller: seller.name,
145
+ ref: object.id,
146
+ amount: main(object.amount_subtotal ?? object.amount_total ?? 0, object.currency),
147
+ currency: object.currency,
148
+ ...(seller.rate ? { rate: seller.rate } : {}),
149
+ purpose: "credits",
150
+ credits: Number(object.metadata.credits ?? 0),
151
+ ...test,
152
+ },
153
+ };
154
+ }
155
+ case "invoice.paid": {
156
+ const { metadata, subscription } = ofInvoice(object);
157
+ if (!ours(metadata))
158
+ return null;
159
+ const period = periodOfInvoice(object, metadata.plan);
160
+ return {
161
+ org: metadata.org,
162
+ ...(subscription ? { plan: { of: subscription, status: "active", ...when } } : {}),
163
+ ...(period ? { period } : {}),
164
+ ...(object.amount_paid
165
+ ? {
166
+ payment: {
167
+ channel: "stripe",
168
+ seller: seller.name,
169
+ ref: object.id,
170
+ amount: main(object.subtotal ?? object.amount_paid, object.currency),
171
+ currency: object.currency,
172
+ ...(seller.rate ? { rate: seller.rate } : {}),
173
+ purpose: "seats",
174
+ ...test,
175
+ },
176
+ }
177
+ : {}),
178
+ };
179
+ }
180
+ case "invoice.payment_failed": {
181
+ const { metadata, subscription } = ofInvoice(object);
182
+ return ours(metadata) && subscription
183
+ ? { org: metadata.org, plan: { of: subscription, status: "past_due", ...when } }
184
+ : null;
185
+ }
186
+ case "customer.subscription.updated":
187
+ return ours(object.metadata)
188
+ ? { org: object.metadata.org, plan: { of: object.id, status: object.status, ...when } }
189
+ : null;
190
+ case "customer.subscription.deleted":
191
+ return ours(object.metadata)
192
+ ? {
193
+ org: object.metadata.org,
194
+ plan: {
195
+ of: object.id,
196
+ ended: object.id,
197
+ name: start,
198
+ status: "canceled",
199
+ subscription: null,
200
+ ...when,
201
+ },
202
+ }
203
+ : null;
204
+ case "charge.refunded": {
205
+ // Disputed, the dispute took it back already.
206
+ if (object.disputed)
207
+ return null;
208
+ const before = event.data.previous_attributes?.amount_refunded ?? 0;
209
+ return creditsMoved(object, -(object.amount_refunded - before), seller, `refund:${object.id}:${object.amount_refunded}`, "Refunded");
210
+ }
211
+ default:
212
+ return null;
213
+ }
214
+ }
215
+ /**
216
+ * A dispute's credits, once its charge is known: opened, they go, as it holds the
217
+ * money until it's decided; won, they come back.
218
+ */
219
+ export function factOfDispute(type, dispute, charge, seller) {
220
+ if (type === "charge.dispute.created") {
221
+ return creditsMoved(charge, -dispute.amount, seller, `dispute:${dispute.id}`, "Disputed");
222
+ }
223
+ return dispute.status === "won"
224
+ ? creditsMoved(charge, dispute.amount, seller, `dispute-won:${dispute.id}`, "Dispute won")
225
+ : null;
226
+ }
@@ -0,0 +1,39 @@
1
+ /** Stripe's settings: the `stripe` resource, which an app patches. */
2
+ import type { Resources, Runtime } from "copilotz";
3
+ /** A Stripe account that sells: an org pays one of them. */
4
+ export type Seller = {
5
+ /** The account's key (a restricted one), and what signs its webhooks. */
6
+ key: string;
7
+ webhookSecret: string;
8
+ /** What it charges in, lowercase, and USD per unit of it, when it isn't USD. */
9
+ currency: string;
10
+ rate?: number;
11
+ /** The billing countries it sells to (ISO, `BR`); none: anyone a seller doesn't name. */
12
+ countries?: string[];
13
+ /** The product credits are sold as, and each plan's price for a seat. */
14
+ credits: string;
15
+ prices: Record<string, string>;
16
+ /** The customer portal's configuration; none: the account's default. */
17
+ portal?: string;
18
+ };
19
+ export type StripeSettings = {
20
+ /** The accounts, by name, and the one that sells unless a country says otherwise. */
21
+ sellers: Record<string, Seller>;
22
+ seller: string;
23
+ /** The app's address, for coming back from checkout. */
24
+ url: string;
25
+ /** Real money: a test mode's customers and subscriptions don't carry over. */
26
+ live?: boolean;
27
+ /**
28
+ * Where the webhook finds an org's runtime, in an app of many (the
29
+ * directory's: its `runtimes`); without it, the webhook's own runtime.
30
+ */
31
+ orgs?: {
32
+ get(org: string): Promise<Runtime | undefined>;
33
+ };
34
+ };
35
+ export declare const settingsOf: (ctx: {
36
+ resources: Resources;
37
+ }) => StripeSettings;
38
+ /** Who sells to an org: who it already pays; else who sells to its country; else the default. */
39
+ export declare function sellerFor(set: StripeSettings, had: string | undefined, country?: string): [string, Seller] | undefined;
@@ -0,0 +1,10 @@
1
+ export const settingsOf = (ctx) => (ctx.resources.stripe ?? { sellers: {}, seller: "", url: "" });
2
+ /** Who sells to an org: who it already pays; else who sells to its country; else the default. */
3
+ export function sellerFor(set, had, country) {
4
+ const named = (name) => set.sellers[name] ? [name, set.sellers[name]] : undefined;
5
+ if (had)
6
+ return named(had);
7
+ const upper = country?.toUpperCase();
8
+ const found = Object.entries(set.sellers).find(([, s]) => upper && s.countries?.includes(upper));
9
+ return found ?? named(set.seller);
10
+ }
package/web.d.ts ADDED
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Billing's front end, for the browser (`@copilotz/billing/web`): `creditsWeb`,
3
+ * the org page's Billing section (balance, plan, budget, history; paying, for
4
+ * those who run the org) and the note above where you type when the org is
5
+ * low or stopped. Add it beside the web shell's:
6
+ *
7
+ * ```ts
8
+ * import { creditsWeb } from "@copilotz/billing/web";
9
+ *
10
+ * await web({ plugins: [webShell, chatWeb, spacesWeb, usageWeb, creditsWeb], root });
11
+ * ```
12
+ */
13
+ export { creditsWeb } from "./credits/web.js";
package/web.js ADDED
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Billing's front end, for the browser (`@copilotz/billing/web`): `creditsWeb`,
3
+ * the org page's Billing section (balance, plan, budget, history; paying, for
4
+ * those who run the org) and the note above where you type when the org is
5
+ * low or stopped. Add it beside the web shell's:
6
+ *
7
+ * ```ts
8
+ * import { creditsWeb } from "@copilotz/billing/web";
9
+ *
10
+ * await web({ plugins: [webShell, chatWeb, spacesWeb, usageWeb, creditsWeb], root });
11
+ * ```
12
+ */
13
+ export { creditsWeb } from "./credits/web.js";