@copilotz/billing 0.0.0-stage → 1.0.0-rc.34

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,235 @@
1
+ import { settingsOf } from "./settings.js";
2
+ import { monthOf, periodOf, walk } from "./walk.js";
3
+ /** What stands for "no limit" in `room`: taking prices off it never gets near zero. */
4
+ export const NO_LIMIT = 1e15;
5
+ const HOUR = 3_600_000;
6
+ const PLAN = "plan";
7
+ const ROOM = "room";
8
+ const plans = (ctx) => ctx.as().db.collection("billingPlan");
9
+ const credits = (ctx) => ctx.as().db.collection("credit");
10
+ export const rooms = (ctx) => ctx.as().db.collection("billingRoom");
11
+ export const planOf = (ctx) => plans(ctx).findOne({ _id: PLAN });
12
+ /**
13
+ * A plan's terms for this org. A trial is one a person: the personal org they
14
+ * get once. A team's starts without it, and doesn't gain it by falling back.
15
+ */
16
+ export function termsFor(ctx, name) {
17
+ const terms = settingsOf(ctx).plans[name];
18
+ const team = ctx.resources.org?.kind === "team";
19
+ if (!terms?.credits?.once || !team)
20
+ return terms;
21
+ const { once: _once, ...credits } = terms.credits;
22
+ return { ...terms, credits };
23
+ }
24
+ /**
25
+ * The org's plan, started on the app's first one the first time it's needed.
26
+ * While limits are off, its clock doesn't start (`counting`).
27
+ */
28
+ export async function ensurePlan(ctx, now) {
29
+ const had = await planOf(ctx);
30
+ if (had)
31
+ return had;
32
+ const set = settingsOf(ctx);
33
+ const plan = {
34
+ _id: PLAN,
35
+ name: set.start,
36
+ terms: termsFor(ctx, set.start) ?? { title: set.start },
37
+ since: now,
38
+ anchor: now,
39
+ channel: "manual",
40
+ ...(set.enforce ? { counting: true } : {}),
41
+ at: now,
42
+ };
43
+ try {
44
+ await plans(ctx).insertOne(plan);
45
+ }
46
+ catch (error) {
47
+ // Another executor made it just now; else it's that it can't be written from here.
48
+ const found = await planOf(ctx);
49
+ if (found)
50
+ return found;
51
+ throw error;
52
+ }
53
+ return plan;
54
+ }
55
+ /** People who count as seats: the org's, guests apart. An app without org roles has one. */
56
+ export const seatsOf = async (ctx, plan) => plan.seats ??
57
+ await ctx.as().call("org.seats", {}).catch(() => 1);
58
+ /** What's been charged by day since `from`, and up to `to` if it says (USD): a use in its very millisecond was in it. */
59
+ async function chargedByDay(ctx, from, to) {
60
+ const totals = await ctx.as().db.collection("usage").query("totals", { filter: { at: { $gte: from, ...(to ? { $lte: to } : {}) } }, by: ["day"] });
61
+ return Object.fromEntries(totals.map((t) => [t.key.day, t.price ?? 0]));
62
+ }
63
+ /** What counts of what it was given: from a test mode, not once the app is live. */
64
+ const counted = (ctx, history) => settingsOf(ctx).live ? history.filter((c) => !c.sandbox) : history;
65
+ /**
66
+ * A usage waiver's end: what it let through is given back, so it doesn't count
67
+ * afterwards. Not what was charged, but what the balance lost by it: credits that
68
+ * would have lapsed anyway aren't given back for good.
69
+ */
70
+ export async function endWaiver(ctx, plan, at) {
71
+ const waived = plan.waived?.usage;
72
+ if (!waived)
73
+ return;
74
+ const history = counted(ctx, await credits(ctx).find({}));
75
+ const spent = await chargedByDay(ctx, plan.since);
76
+ const from = waived.from > plan.since ? waived.from : plan.since;
77
+ const during = await chargedByDay(ctx, from, at);
78
+ const without = Object.fromEntries(Object.entries(spent).map(([day, price]) => [day, price - (during[day] ?? 0)]));
79
+ const today = at.slice(0, 10);
80
+ const amount = walk(history, without, today).balance - walk(history, spent, today).balance;
81
+ if (amount > 0) {
82
+ await credits(ctx).insertOne({
83
+ _id: `waived:${waived.from}`,
84
+ kind: "waived",
85
+ amount: Math.round(amount * 1e8) / 1e8,
86
+ at,
87
+ note: "Usage waived",
88
+ reason: waived.reason,
89
+ ...(waived.by ? { by: waived.by } : {}),
90
+ });
91
+ }
92
+ await plans(ctx).updateOne({ _id: PLAN }, { $unset: { "waived.usage": "" } });
93
+ }
94
+ /** Writes what's come due. */
95
+ export async function settle(ctx, now) {
96
+ let plan = await ensurePlan(ctx, now);
97
+ // Limits have just come on for it: what it used before they did doesn't count.
98
+ if (!plan.counting) {
99
+ await plans(ctx).updateOne({ _id: PLAN }, { $set: { since: now, counting: true } });
100
+ plan = (await planOf(ctx));
101
+ }
102
+ const kept = new Set((await credits(ctx).find({})).map((c) => c._id));
103
+ const grant = async (doc) => {
104
+ if (kept.has(doc._id) || !doc.amount)
105
+ return;
106
+ kept.add(doc._id);
107
+ await credits(ctx).insertOne({ at: now, ...doc });
108
+ };
109
+ const terms = plan.terms.credits;
110
+ if (terms?.once) {
111
+ await grant({ _id: "trial", kind: "trial", amount: terms.once, note: plan.terms.title });
112
+ }
113
+ // A plan a provider collects for gets its credits when a period is paid for (`grantPeriod`).
114
+ const byHand = !plan.channel || plan.channel === "manual";
115
+ if (byHand && (terms?.monthly || terms?.perSeat)) {
116
+ const period = periodOf(plan.anchor, now);
117
+ const id = `plan:${plan.name}:${period.start}`;
118
+ if (!kept.has(id)) {
119
+ const seats = terms.perSeat ? await seatsOf(ctx, plan) : 0;
120
+ await grant({
121
+ _id: id,
122
+ kind: "plan",
123
+ amount: (terms.monthly ?? 0) + (terms.perSeat ?? 0) * seats,
124
+ expires: period.end,
125
+ note: plan.terms.title,
126
+ });
127
+ }
128
+ }
129
+ const until = plan.waived?.usage?.until;
130
+ if (until && until <= now) {
131
+ await endWaiver(ctx, plan, until);
132
+ plan = (await planOf(ctx));
133
+ }
134
+ return plan;
135
+ }
136
+ /**
137
+ * The month's limit: what its people chose; none if they chose none; else what
138
+ * its plan starts with, so a team's grows with its seats and doesn't stop it with
139
+ * credits left; else the app's.
140
+ */
141
+ async function limitOf(ctx, plan) {
142
+ if (plan.budget === null)
143
+ return undefined;
144
+ if (typeof plan.budget === "number")
145
+ return plan.budget;
146
+ const perSeat = plan.terms.budget?.perSeat;
147
+ if (perSeat)
148
+ return perSeat * await seatsOf(ctx, plan);
149
+ return settingsOf(ctx).budget;
150
+ }
151
+ /** Where the org stands now, from what's kept. */
152
+ export async function standingOf(ctx, now) {
153
+ const plan = await planOf(ctx);
154
+ const history = counted(ctx, await credits(ctx).find({}, { sort: { at: -1 } }));
155
+ const today = now.slice(0, 10);
156
+ const month = monthOf(now);
157
+ // Not started yet (limits off): nothing has counted.
158
+ const spent = plan?.counting ? await chargedByDay(ctx, plan.since) : {};
159
+ const limit = plan ? await limitOf(ctx, plan) : undefined;
160
+ const usageWaiver = plan?.waived?.usage;
161
+ return {
162
+ plan,
163
+ walked: walk(history, spent, today),
164
+ history,
165
+ month: {
166
+ ...month,
167
+ spent: Object.entries(spent).filter(([day]) => day >= month.start)
168
+ .reduce((sum, [, price]) => sum + price, 0),
169
+ ...(limit !== undefined ? { limit } : {}),
170
+ },
171
+ ...(plan && (plan.terms.credits?.monthly || plan.terms.credits?.perSeat)
172
+ ? { period: periodOf(plan.anchor, now) }
173
+ : {}),
174
+ waivedUsage: !!usageWaiver && (!usageWaiver.until || usageWaiver.until > now),
175
+ };
176
+ }
177
+ /** The soonest a date changes what `room` should say. */
178
+ function untilOf(standing, now) {
179
+ const days = [
180
+ standing.month.end,
181
+ standing.period?.end,
182
+ ...standing.walked.buckets.map((bucket) => bucket.expires),
183
+ ].filter((day) => !!day).map((day) => `${day}T00:00:00.000Z`);
184
+ const times = [
185
+ ...days,
186
+ standing.plan?.waived?.usage?.until,
187
+ new Date(new Date(now).getTime() + HOUR).toISOString(),
188
+ ].filter((at) => !!at);
189
+ return times.sort()[0];
190
+ }
191
+ /** `room`, worked out from where it stands. */
192
+ function roomFrom(ctx, standing, now) {
193
+ const { warnAt } = settingsOf(ctx);
194
+ const { limit, spent } = standing.month;
195
+ const granted = standing.walked.buckets.reduce((sum, bucket) => sum + bucket.amount, 0);
196
+ return {
197
+ _id: ROOM,
198
+ credits: standing.waivedUsage ? NO_LIMIT : standing.walked.balance,
199
+ budget: limit === undefined ? NO_LIMIT : limit - spent,
200
+ ...(limit !== undefined ? { limit, lowBudget: limit * (1 - warnAt) } : {}),
201
+ ...(standing.waivedUsage ? {} : { lowCredits: granted * (1 - warnAt) }),
202
+ until: untilOf(standing, now),
203
+ };
204
+ }
205
+ /** Settles, works out `room` again, and keeps it. */
206
+ export async function refresh(ctx, now) {
207
+ await settle(ctx, now);
208
+ const room = roomFrom(ctx, await standingOf(ctx, now), now);
209
+ await rooms(ctx).replaceOne({ _id: ROOM }, room, { upsert: true });
210
+ return room;
211
+ }
212
+ /** Something changed what's left: the next check works it out again. */
213
+ export const forget = (ctx) => rooms(ctx).deleteOne({ _id: ROOM });
214
+ const usd = (amount) => `$${amount % 1 ? amount.toFixed(2) : amount}`;
215
+ /** Where it's changed, as a link where the words are shown as a message. */
216
+ const BILLING = "[Billing](/org?tab=billing)";
217
+ /** What a check answers for `room`, in the words people see when it stops them. */
218
+ export function verdictOf(room) {
219
+ if (room.credits <= 0) {
220
+ return {
221
+ ok: false,
222
+ why: "credits",
223
+ message: `This org is out of credits. Its owners and admins can add more in ${BILLING}.`,
224
+ };
225
+ }
226
+ if (room.budget <= 0) {
227
+ const limit = room.limit !== undefined ? `its ${usd(room.limit)} budget` : "its budget";
228
+ return {
229
+ ok: false,
230
+ why: "budget",
231
+ message: `This org has reached ${limit} for the month. Its owners and admins can raise it in ${BILLING}.`,
232
+ };
233
+ }
234
+ return { ok: true };
235
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * The balance: credits and what was spent, walked day by day (UTC), so each
3
+ * day's spending comes out of what was there that day and what lapses, lapses.
4
+ * Pure: no store, no clock.
5
+ *
6
+ * Each day: what's expired goes, what was granted comes (paying any debt
7
+ * first), then what was spent is taken: what expires soonest first, then, among
8
+ * those alike, plan credits, the trial, gifts, and last what was bought.
9
+ * Spending beyond the credits is debt, which the next grant pays. A refund comes
10
+ * out of what the money bought, not of what was about to lapse anyway.
11
+ */
12
+ export type Grant = {
13
+ _id: string;
14
+ kind: string;
15
+ amount: number;
16
+ at: string;
17
+ expires?: string;
18
+ /** Taken away: what it comes out of first. */
19
+ takes?: string;
20
+ };
21
+ export type Bucket = {
22
+ id: string;
23
+ kind: string;
24
+ amount: number;
25
+ left: number;
26
+ expires?: string;
27
+ };
28
+ export type Walked = {
29
+ /** Credits left less any debt: negative when more was spent than there was. */
30
+ balance: number;
31
+ /** The credits still there, in the order they'll be spent. */
32
+ buckets: Bucket[];
33
+ debt: number;
34
+ /** Credits that lapsed unspent. */
35
+ expired: number;
36
+ };
37
+ export declare function walk(grants: Grant[], spent: Record<string, number>, today: string): Walked;
38
+ /**
39
+ * The period `now` falls in, for an `anchor`: from its day of the month to the
40
+ * same day the next (a shorter month's last, where it has none).
41
+ */
42
+ export declare function periodOf(anchor: string, now: string): {
43
+ start: string;
44
+ end: string;
45
+ };
46
+ /** The first day of the month `now` falls in, and of the next. */
47
+ export declare function monthOf(now: string): {
48
+ start: string;
49
+ end: string;
50
+ };
@@ -0,0 +1,127 @@
1
+ const RANK = {
2
+ plan: 0,
3
+ trial: 1,
4
+ gift: 2,
5
+ referral: 3,
6
+ waived: 4,
7
+ adjustment: 5,
8
+ purchase: 6,
9
+ };
10
+ /** USD to a millionth of a cent, as the ledger keeps it. */
11
+ const round = (usd) => Math.round(usd * 1e8) / 1e8;
12
+ const order = (a, b) => (a.expires ?? "9999").localeCompare(b.expires ?? "9999") ||
13
+ (RANK[a.kind] ?? 9) - (RANK[b.kind] ?? 9) || a.id.localeCompare(b.id);
14
+ export function walk(grants, spent, today) {
15
+ const byDay = new Map();
16
+ for (const grant of grants) {
17
+ const day = grant.at.slice(0, 10);
18
+ byDay.set(day, [...byDay.get(day) ?? [], grant]);
19
+ }
20
+ const days = [...new Set([...byDay.keys(), ...Object.keys(spent), today])]
21
+ .filter((day) => day <= today).sort();
22
+ let buckets = [];
23
+ let debt = 0;
24
+ let expired = 0;
25
+ const take = (amount, first) => {
26
+ let owed = amount;
27
+ const ordered = first
28
+ ? [...buckets.filter((b) => b.kind === first), ...buckets.filter((b) => b.kind !== first)]
29
+ : buckets;
30
+ for (const bucket of ordered) {
31
+ if (owed <= 0)
32
+ break;
33
+ const taken = Math.min(bucket.left, owed);
34
+ bucket.left -= taken;
35
+ owed -= taken;
36
+ }
37
+ debt += owed;
38
+ buckets = buckets.filter((bucket) => bucket.left > 1e-9);
39
+ };
40
+ for (const day of days) {
41
+ buckets = buckets.filter((bucket) => {
42
+ if (bucket.expires && bucket.expires <= day) {
43
+ expired += bucket.left;
44
+ return false;
45
+ }
46
+ return true;
47
+ });
48
+ let out = spent[day] ?? 0;
49
+ const taken = [];
50
+ for (const grant of byDay.get(day) ?? []) {
51
+ if (grant.amount < 0) {
52
+ if (grant.takes)
53
+ taken.push(grant);
54
+ else
55
+ out -= grant.amount;
56
+ continue;
57
+ }
58
+ if (grant.expires && grant.expires <= day)
59
+ continue;
60
+ const paid = Math.min(debt, grant.amount);
61
+ debt -= paid;
62
+ if (grant.amount > paid) {
63
+ buckets.push({
64
+ id: grant._id,
65
+ kind: grant.kind,
66
+ amount: grant.amount,
67
+ left: grant.amount - paid,
68
+ ...(grant.expires ? { expires: grant.expires } : {}),
69
+ });
70
+ }
71
+ }
72
+ buckets.sort(order);
73
+ for (const grant of taken)
74
+ take(-grant.amount, grant.takes);
75
+ if (out >= 0)
76
+ take(out);
77
+ else {
78
+ // More came back than went (a correction): it pays debt, then it's credit like any other.
79
+ const back = -out;
80
+ const paid = Math.min(debt, back);
81
+ debt -= paid;
82
+ if (back > paid) {
83
+ buckets.push({
84
+ id: `back:${day}`,
85
+ kind: "adjustment",
86
+ amount: back - paid,
87
+ left: back - paid,
88
+ });
89
+ buckets.sort(order);
90
+ }
91
+ }
92
+ }
93
+ const left = buckets.reduce((sum, bucket) => sum + bucket.left, 0);
94
+ return {
95
+ balance: round(left - debt),
96
+ buckets: buckets.map((bucket) => ({
97
+ ...bucket,
98
+ left: round(bucket.left),
99
+ amount: round(bucket.amount),
100
+ })),
101
+ debt: round(debt),
102
+ expired: round(expired),
103
+ };
104
+ }
105
+ const dayIn = (year, month, day) => {
106
+ const last = new Date(Date.UTC(year, month + 1, 0)).getUTCDate();
107
+ return new Date(Date.UTC(year, month, Math.min(day, last))).toISOString().slice(0, 10);
108
+ };
109
+ /**
110
+ * The period `now` falls in, for an `anchor`: from its day of the month to the
111
+ * same day the next (a shorter month's last, where it has none).
112
+ */
113
+ export function periodOf(anchor, now) {
114
+ const day = new Date(anchor).getUTCDate();
115
+ const at = new Date(now);
116
+ const [y, m] = [at.getUTCFullYear(), at.getUTCMonth()];
117
+ const here = dayIn(y, m, day);
118
+ return now.slice(0, 10) >= here
119
+ ? { start: here, end: dayIn(y, m + 1, day) }
120
+ : { start: dayIn(y, m - 1, day), end: here };
121
+ }
122
+ /** The first day of the month `now` falls in, and of the next. */
123
+ export function monthOf(now) {
124
+ const at = new Date(now);
125
+ const [y, m] = [at.getUTCFullYear(), at.getUTCMonth()];
126
+ return { start: dayIn(y, m, 1), end: dayIn(y, m + 1, 1) };
127
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Adding to what the org has. **Add credits:** a few amounts to choose from, or
3
+ * one of their own within the limits; what the balance comes to after, any
4
+ * debt paid first; then a page of the payment provider's, where they confirm:
5
+ * the card never reaches the app. If it fails, why, with what they chose kept.
6
+ * Where nothing can be bought here, it says who credits come from, with no dead
7
+ * buttons. **Gift code:** typed in, what it added; if it isn't good, why, right
8
+ * there, with what was typed kept.
9
+ */
10
+ import { type ReactNode } from "react";
11
+ import { type BillingState } from "./numbers.js";
12
+ import { type Call } from "./types.js";
13
+ /** Leaving for a payment page: what's here now is kept, to tell on coming back what's new. */
14
+ export declare function leavingFor(url: string, state: BillingState): void;
15
+ /** A button left busy on leaving is released on coming back through the browser's Back. */
16
+ export declare function useBackFromLeaving(release: () => void): void;
17
+ export declare function AddCredits({ state, team, call }: {
18
+ state: BillingState;
19
+ team: string;
20
+ call: Call;
21
+ }): ReactNode;
22
+ export declare function Redeem({ call, onRedeemed }: {
23
+ call: Call;
24
+ onRedeemed(): Promise<void>;
25
+ }): ReactNode;
@@ -0,0 +1,92 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ /**
3
+ * Adding to what the org has. **Add credits:** a few amounts to choose from, or
4
+ * one of their own within the limits; what the balance comes to after, any
5
+ * debt paid first; then a page of the payment provider's, where they confirm:
6
+ * the card never reaches the app. If it fails, why, with what they chose kept.
7
+ * Where nothing can be bought here, it says who credits come from, with no dead
8
+ * buttons. **Gift code:** typed in, what it added; if it isn't good, why, right
9
+ * there, with what was typed kept.
10
+ */
11
+ import { useEffect, useId, useState } from "react";
12
+ import { Button, Card, Field, Icon, Input, Notice } from "copilotz/web";
13
+ import { balanceAfter, beforeOf, dayShown, dollars, money, topUpOf, } from "./numbers.js";
14
+ import { LEFT, useAct } from "./types.js";
15
+ /** Leaving for a payment page: what's here now is kept, to tell on coming back what's new. */
16
+ export function leavingFor(url, state) {
17
+ try {
18
+ sessionStorage.setItem(LEFT, JSON.stringify(beforeOf(state)));
19
+ }
20
+ catch { /* Coming back, it goes by how recent a payment is. */ }
21
+ location.assign(url);
22
+ }
23
+ /** A button left busy on leaving is released on coming back through the browser's Back. */
24
+ export function useBackFromLeaving(release) {
25
+ useEffect(() => {
26
+ const back = (event) => event.persisted && release();
27
+ addEventListener("pageshow", back);
28
+ return () => removeEventListener("pageshow", back);
29
+ }, []);
30
+ }
31
+ export function AddCredits({ state, team, call }) {
32
+ const pay = state.pay;
33
+ const id = useId();
34
+ const options = pay?.topUps ?? [];
35
+ const [choice, setChoice] = useState(options[1] ?? options[0] ?? "other");
36
+ const [typed, setTyped] = useState("");
37
+ const { busy, error, run, release } = useAct();
38
+ useBackFromLeaving(release);
39
+ if (!pay) {
40
+ return (_jsxs(Card, { as: "section", className: "billing-card", id: "billing-add", "aria-labelledby": `${id}-head`, children: [_jsx("header", { className: "billing-card-head", children: _jsx("h3", { id: `${id}-head`, children: "Add credits" }) }), _jsxs("p", { className: "billing-text", children: ["Credits here come from ", team, ". Ask them for more and they'll show up on this page."] })] }));
41
+ }
42
+ const parsed = choice === "other" ? topUpOf(typed, pay) : { amount: choice };
43
+ const amount = "amount" in parsed ? parsed.amount : undefined;
44
+ const wrong = choice === "other" && typed.trim() && "error" in parsed ? parsed.error : undefined;
45
+ const after = amount === undefined ? undefined : balanceAfter(state.balance ?? 0, amount);
46
+ const go = (event) => {
47
+ event.preventDefault();
48
+ if (amount === undefined || busy)
49
+ return;
50
+ void run(async () => {
51
+ const { url } = await call("billing.checkout", { credits: amount });
52
+ leavingFor(url, state);
53
+ }, true);
54
+ };
55
+ return (_jsxs(Card, { as: "section", className: "billing-card", id: "billing-add", "aria-labelledby": `${id}-head`, children: [_jsx("header", { className: "billing-card-head", children: _jsx("h3", { id: `${id}-head`, children: "Add credits" }) }), _jsxs("form", { className: "billing-add", onSubmit: go, children: [_jsxs("div", { className: "billing-choices", role: "radiogroup", "aria-labelledby": `${id}-head`, children: [options.map((option) => (_jsxs("label", { className: "billing-choice", "data-on": choice === option || undefined, children: [_jsx("input", { type: "radio", name: `${id}-amount`, checked: choice === option, disabled: busy, onChange: () => setChoice(option) }), dollars(option)] }, option))), _jsxs("label", { className: "billing-choice", "data-on": choice === "other" || undefined, children: [_jsx("input", { type: "radio", name: `${id}-amount`, checked: choice === "other", disabled: busy, onChange: () => setChoice("other") }), "Other"] })] }), choice === "other"
56
+ ? (_jsx(Field, { label: "Amount", hint: `Between ${dollars(pay.min)} and ${dollars(pay.max)}.`, error: wrong, children: (control) => (_jsx(Input, { ...control, inputMode: "decimal", autoComplete: "off", placeholder: "0", value: typed, disabled: busy, autoFocus: true, className: "billing-money-input", onChange: (event) => setTyped(event.target.value) })) }))
57
+ : null, _jsx("p", { className: "billing-after", "aria-live": "polite", children: after !== undefined
58
+ ? (_jsxs(_Fragment, { children: ["Your balance will be ", _jsx("b", { children: money(after) })] }))
59
+ : " " }), error ? _jsx(Notice, { tone: "destructive", role: "alert", children: error }) : null, _jsx(Button, { type: "submit", variant: "primary", size: "lg", loading: busy, disabled: amount === undefined, className: "billing-go", children: busy
60
+ ? "Opening the payment page…"
61
+ : amount !== undefined
62
+ ? `Continue to pay ${dollars(amount)}`
63
+ : "Continue to payment" }), _jsxs("p", { className: "billing-small", children: [_jsx(Icon, { name: "lock", size: 13 }), "You'll confirm on a secure payment page. Credits you buy never lapse, and they're spent last."] })] })] }));
64
+ }
65
+ export function Redeem({ call, onRedeemed }) {
66
+ const [code, setCode] = useState("");
67
+ const [added, setAdded] = useState();
68
+ const { busy, error, run, clear } = useAct();
69
+ const typed = code.trim();
70
+ const go = (event) => {
71
+ event.preventDefault();
72
+ if (!typed || busy)
73
+ return;
74
+ void run(async () => {
75
+ const answer = await call("billing.redeem", {
76
+ code: typed,
77
+ });
78
+ setAdded(answer);
79
+ setCode("");
80
+ await onRedeemed();
81
+ });
82
+ };
83
+ return (_jsxs(Card, { as: "section", className: "billing-card", "aria-labelledby": "billing-redeem", children: [_jsx("header", { className: "billing-card-head", children: _jsx("h3", { id: "billing-redeem", children: "Gift code" }) }), _jsxs("form", { className: "billing-redeem", onSubmit: go, children: [_jsx(Field, { label: "Have a code? Type it here", error: error, hint: error ? undefined : "Its credits go to this org, for everyone here to use.", children: (control) => (_jsx(Input, { ...control, value: code, spellCheck: false, autoCapitalize: "characters", autoComplete: "off", placeholder: "GIFT-XXXXX-XXXXX", className: "billing-code-input", onChange: (event) => {
84
+ setCode(event.target.value);
85
+ setAdded(undefined);
86
+ clear();
87
+ } })) }), _jsx(Button, { type: "submit", loading: busy, disabled: !typed, className: "billing-go-small", children: "Redeem" }), added
88
+ ? (_jsxs(Notice, { tone: "success", role: "status", children: [_jsxs("b", { children: [money(added.amount), " added."] }), " ", added.expires
89
+ ? `They lapse ${dayShown(added.expires)}, and they're spent before what lasts longer.`
90
+ : "They don't lapse."] }))
91
+ : null] })] }));
92
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * This month's budget: what's been spent against its limit as a meter, until
3
+ * when, and changing it (or having none). Before they set one, it says what
4
+ * happens at the limit: agents stop and computers sleep, nothing is deleted,
5
+ * and it resets next month; and if what they typed is under what's been spent
6
+ * already, that work stops right away. Near the limit the meter says so in
7
+ * words as well as colour; at it, plainly.
8
+ */
9
+ import { type ReactNode } from "react";
10
+ import { type BillingState } from "./numbers.js";
11
+ import { type Call } from "./types.js";
12
+ export declare function BudgetCard({ state, call, onChanged }: {
13
+ state: BillingState;
14
+ call: Call;
15
+ onChanged(): Promise<void>;
16
+ }): ReactNode;
@@ -0,0 +1,62 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ /**
3
+ * This month's budget: what's been spent against its limit as a meter, until
4
+ * when, and changing it (or having none). Before they set one, it says what
5
+ * happens at the limit: agents stop and computers sleep, nothing is deleted,
6
+ * and it resets next month; and if what they typed is under what's been spent
7
+ * already, that work stops right away. Near the limit the meter says so in
8
+ * words as well as colour; at it, plainly.
9
+ */
10
+ import { useEffect, useState } from "react";
11
+ import { Badge, Button, Card, Checkbox, Dialog, Field, Icon, Input, Notice } from "copilotz/web";
12
+ import { amountOf, budgetStand, dayShown, money } from "./numbers.js";
13
+ import { useAct } from "./types.js";
14
+ export function BudgetCard({ state, call, onChanged }) {
15
+ const [changing, setChanging] = useState(false);
16
+ const stand = budgetStand(state);
17
+ const month = state.month;
18
+ if (!month)
19
+ return null;
20
+ // An org that isn't charged has no limit to think about, unless one was set.
21
+ if (state.waived && stand.tone === "none")
22
+ return null;
23
+ const resets = dayShown(month.end);
24
+ return (_jsxs(Card, { as: "section", className: "billing-card", id: "billing-budget", "aria-labelledby": "billing-month", children: [_jsxs("header", { className: "billing-card-head", children: [_jsx("h3", { id: "billing-month", children: "This month's budget" }), stand.tone === "at"
25
+ ? _jsx(Badge, { tone: "warning", children: "Limit reached" })
26
+ : stand.tone === "near"
27
+ ? _jsx(Badge, { tone: "warning", children: "Near the limit" })
28
+ : null] }), stand.limit !== undefined
29
+ ? (_jsxs(_Fragment, { children: [_jsxs("p", { className: "billing-spent", children: [_jsx("b", { children: money(stand.spent) }), " of ", money(stand.limit)] }), _jsx("div", { className: "billing-meter", "data-tone": stand.tone, role: "meter", "aria-label": "Spent this month, against the limit", "aria-valuemin": 0, "aria-valuemax": stand.limit, "aria-valuenow": Math.min(stand.spent, stand.limit), "aria-valuetext": `${money(stand.spent)} of ${money(stand.limit)}`, children: _jsx("span", { style: { width: `${Math.max(stand.share * 100, stand.spent > 0 ? 1.5 : 0)}%` } }) }), _jsx("p", { className: "billing-text", children: stand.tone === "at"
30
+ ? `It resets ${resets}, or raise it to carry on now.`
31
+ : `${money(stand.left ?? 0)} left. Resets ${resets}.` })] }))
32
+ : (_jsxs(_Fragment, { children: [_jsxs("p", { className: "billing-spent", children: [_jsx("b", { children: money(stand.spent) }), " so far"] }), _jsxs("p", { className: "billing-text", children: ["No limit: agents can use what credits you have. A new month starts ", resets, "."] })] })), _jsx("div", { className: "billing-card-end", children: _jsx(Button, { variant: stand.tone === "at" ? "primary" : "outline", onClick: () => setChanging(true), children: stand.limit === undefined ? "Set a limit" : "Change budget" }) }), _jsx(BudgetDialog, { open: changing, close: () => setChanging(false), limit: stand.limit, spent: stand.spent, resets: resets, call: call, onChanged: onChanged })] }));
33
+ }
34
+ function BudgetDialog({ open, close, limit, spent, resets, call, onChanged }) {
35
+ const [none, setNone] = useState(false);
36
+ const [typed, setTyped] = useState("");
37
+ const { busy, error, run, clear } = useAct();
38
+ useEffect(() => {
39
+ if (!open)
40
+ return;
41
+ setNone(false);
42
+ setTyped(limit === undefined ? "" : String(limit));
43
+ clear();
44
+ }, [open]);
45
+ const amount = amountOf(typed);
46
+ const wrong = !none && typed.trim() && amount === undefined ? "That isn't an amount." : undefined;
47
+ const valid = none || amount !== undefined;
48
+ const stops = !none && amount !== undefined && amount <= spent;
49
+ const save = (event) => {
50
+ event?.preventDefault();
51
+ if (!valid || busy)
52
+ return;
53
+ void run(async () => {
54
+ await call("billing.budget", { usd: none ? null : amount });
55
+ await onChanged();
56
+ close();
57
+ });
58
+ };
59
+ return (_jsx(Dialog, { open: open, onClose: close, size: "sm", title: "This month's budget", description: `${money(spent)} spent so far. It starts over ${resets}.`, footer: _jsxs(_Fragment, { children: [_jsx(Button, { variant: "ghost", onClick: close, disabled: busy, children: "Cancel" }), _jsx(Button, { variant: "primary", loading: busy, disabled: !valid, onClick: () => save(), children: none ? "Remove the limit" : amount !== undefined ? `Set to ${money(amount)}` : "Save" })] }), children: _jsxs("form", { className: "billing-dialog", onSubmit: save, children: [_jsx(Field, { label: "Limit for a month", error: wrong, children: (control) => (_jsx(Input, { ...control, inputMode: "decimal", autoComplete: "off", autoFocus: true, placeholder: "50", disabled: none, className: "billing-money-input", value: none ? "" : typed, onChange: (event) => setTyped(event.target.value) })) }), _jsx(Checkbox, { label: "No limit: use whatever credits there are", checked: none, onChange: (event) => setNone(event.target.checked) }), stops
60
+ ? (_jsxs(Notice, { tone: "warning", role: "status", children: ["That's no more than what's already been spent, so work stops right away, until", " ", resets, " or until you raise it."] }))
61
+ : null, _jsxs("ul", { className: "billing-rules", children: [_jsxs("li", { children: [_jsx(Icon, { name: "pause", size: 14 }), "At the limit, agents stop and computers sleep."] }), _jsxs("li", { children: [_jsx(Icon, { name: "lock", size: 14 }), "Nothing is deleted."] }), _jsxs("li", { children: [_jsx(Icon, { name: "refresh", size: 14 }), "It resets ", resets, ", and every month after."] })] }), error ? _jsx(Notice, { tone: "destructive", role: "alert", children: error }) : null] }) }));
62
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * The note above where you type (kind `billing.composer`, chat's
3
+ * `composerNotes`): so people know before they send. Nothing at all while it
4
+ * asks, when there's nothing to say, or if it can't ask. Stopped, it says the
5
+ * org is out of credits or at its budget: with a way to Billing for those who
6
+ * run it, and who can help for everyone else; that one can't be put away. Low,
7
+ * for those who run it, what's left and a way to add more, which they can put
8
+ * away for this visit. It asks when it appears, when the page comes back, and
9
+ * when agents finish working (`hints.working`), which is when it changes.
10
+ */
11
+ import { type ReactNode } from "react";
12
+ import type { ViewContext } from "copilotz";
13
+ export declare const composerNoteView: (_: unknown, ctx: ViewContext<ReactNode>) => ReactNode;