@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,31 @@
1
+ /**
2
+ * The org's billing (kind `billing.org`, a section of the org's page, the web
3
+ * shell's `orgSettings`, at `/org?tab=billing`). People meet this when they're
4
+ * running out, stopped mid-work, or paying, so it opens with what matters most:
5
+ * what the org has left, what that's made of and the order it'll be spent in,
6
+ * and if it can't go on, why, what stopped, and the way forward right there.
7
+ * Then adding credits and redeeming a gift code, the plan, the month's budget,
8
+ * and everything that came in. Coming back from paying, it says the payment is
9
+ * being confirmed and reads again until it shows: it never claims what the
10
+ * state doesn't show yet. An org that isn't charged says so, without numbers.
11
+ * Anyone who doesn't run it sees only whether work can go on and who can help.
12
+ * Platform staff also get their panel (staff.tsx), in the staff colour.
13
+ */
14
+ import { type ReactNode } from "react";
15
+ import type { ViewContext } from "copilotz";
16
+ import { type BillingState } from "./numbers.js";
17
+ import { type Org } from "./types.js";
18
+ export declare const orgBillingView: (_: unknown, ctx: ViewContext<ReactNode>) => ReactNode;
19
+ /** Someone who doesn't run the org: whether work can go on, and who can help. */
20
+ export declare function ForMembers({ state, org }: {
21
+ state: BillingState;
22
+ org?: Org;
23
+ }): ReactNode;
24
+ /** The balance first: the number, then what it's made of, in the order it'll be spent. */
25
+ export declare function Balance({ state, personal, team, quiet }: {
26
+ state: BillingState;
27
+ personal: boolean;
28
+ team: string;
29
+ /** A payment is being confirmed: not the moment to say they're out. */
30
+ quiet: boolean;
31
+ }): ReactNode;
@@ -0,0 +1,238 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * The org's billing (kind `billing.org`, a section of the org's page, the web
4
+ * shell's `orgSettings`, at `/org?tab=billing`). People meet this when they're
5
+ * running out, stopped mid-work, or paying, so it opens with what matters most:
6
+ * what the org has left, what that's made of and the order it'll be spent in,
7
+ * and if it can't go on, why, what stopped, and the way forward right there.
8
+ * Then adding credits and redeeming a gift code, the plan, the month's budget,
9
+ * and everything that came in. Coming back from paying, it says the payment is
10
+ * being confirmed and reads again until it shows: it never claims what the
11
+ * state doesn't show yet. An org that isn't charged says so, without numbers.
12
+ * Anyone who doesn't run it sees only whether work can go on and who can help.
13
+ * Platform staff also get their panel (staff.tsx), in the staff colour.
14
+ */
15
+ import { useCallback, useEffect, useRef, useState } from "react";
16
+ import { Button, Card, Icon, IconButton, message, Notice, Skeleton, Spinner } from "copilotz/web";
17
+ import { AddCredits, Redeem } from "./add.js";
18
+ import { BudgetCard } from "./budget.js";
19
+ import { History } from "./history.js";
20
+ import { dayShown, kindSaid, landed, lapseSaid, lowOn, money, paidIn, pilesOf, teamOf, withoutPaid, } from "./numbers.js";
21
+ import { PlanCard, PlanOffers } from "./plan.js";
22
+ import { StaffPanel } from "./staff.js";
23
+ import { STYLE } from "./style.js";
24
+ import { LEFT } from "./types.js";
25
+ /** How long apart it reads again, confirming a payment: about half a minute in all. */
26
+ const TRIES = [1000, 1500, 2000, 3000, 4000, 5000, 6000, 8000];
27
+ export const orgBillingView = (_, ctx) => (_jsxs(_Fragment, { children: [_jsx("style", { href: "billing-web", precedence: "copilotz", children: STYLE }), _jsx(Billing, { org: ctx.hints.org, call: ctx.call })] }));
28
+ function Billing({ org, call }) {
29
+ const [state, setState] = useState();
30
+ const [error, setError] = useState();
31
+ const live = useRef(true);
32
+ const read = useCallback(async () => {
33
+ try {
34
+ const answer = await call("billing.state", {});
35
+ if (!live.current)
36
+ return;
37
+ setState(answer);
38
+ setError(undefined);
39
+ }
40
+ catch (e) {
41
+ if (live.current)
42
+ setError(message(e));
43
+ }
44
+ }, []);
45
+ useEffect(() => {
46
+ live.current = true;
47
+ void read();
48
+ // Coming back to the page, it's read again: credits go down as agents work.
49
+ const again = () => document.visibilityState === "visible" && void read();
50
+ document.addEventListener("visibilitychange", again);
51
+ return () => {
52
+ live.current = false;
53
+ document.removeEventListener("visibilitychange", again);
54
+ };
55
+ }, []);
56
+ const payment = usePayment(state, read);
57
+ if (error && !state) {
58
+ return (_jsxs(Notice, { tone: "destructive", role: "alert", action: _jsx(Button, { size: "sm", onClick: () => void read(), children: "Try again" }), children: ["We couldn't read this org's billing just now. ", error] }));
59
+ }
60
+ if (!state) {
61
+ return (_jsxs("div", { className: "billing", "aria-busy": "true", children: [_jsx(Skeleton, { height: 196, radius: "xl" }), _jsxs("div", { className: "billing-pair", children: [_jsx(Skeleton, { height: 228, radius: "xl" }), _jsx(Skeleton, { height: 228, radius: "xl" })] }), _jsx(Skeleton, { height: 180, radius: "xl" })] }));
62
+ }
63
+ if (!state.on && !state.staff) {
64
+ return (_jsx("div", { className: "billing", children: _jsxs(Card, { className: "billing-calm", children: [_jsx("span", { className: "billing-calm-icon", children: _jsx(Icon, { name: "check-circle", size: 20 }) }), _jsxs("div", { children: [_jsx("h3", { children: "Nothing's charged for this org yet" }), _jsx("p", { children: "Everyone here can keep working as usual." })] })] }) }));
65
+ }
66
+ if (!state.runs)
67
+ return _jsx(ForMembers, { state: state, org: org });
68
+ const personal = org?.kind === "personal";
69
+ const team = teamOf(document.title);
70
+ const quiet = payment.phase === "waiting" || payment.phase === "late";
71
+ // What stopped them, or soon will, comes straight after the balance.
72
+ const budgetFirst = state.why === "budget" || lowOn(state) === "budget" ||
73
+ state.plan?.status === "past_due";
74
+ const staffPanel = state.staff
75
+ ? _jsx(StaffPanel, { state: state, org: org, call: call, onChanged: read })
76
+ : null;
77
+ const planAndBudget = (_jsxs("div", { className: "billing-pair billing-pair-even", "data-budget-first": (budgetFirst && state.plan?.status !== "past_due") || undefined, children: [_jsx(PlanCard, { state: state, team: team, personal: personal, call: call }), _jsx(BudgetCard, { state: state, call: call, onChanged: read })] }));
78
+ return (_jsxs("div", { className: "billing", children: [!state.on
79
+ ? (_jsx(Notice, { tone: "warning", icon: "shield", title: "Limits are off for the whole app", children: "Nobody is charged or stopped, in any org, and nothing starts by itself: no plans, trials or balances. What you set below is kept for when they're turned on." }))
80
+ : null, _jsx(PaymentNotice, { payment: payment }), state.on
81
+ ? (_jsxs(_Fragment, { children: [_jsx(Balance, { state: state, personal: personal, team: team, quiet: quiet }), staffPanel, budgetFirst ? planAndBudget : null, _jsxs("div", { className: "billing-pair", "data-single": state.waived || undefined, children: [state.waived ? null : _jsx(AddCredits, { state: state, team: team, call: call }), _jsx(Redeem, { call: call, onRedeemed: read })] }), budgetFirst ? null : planAndBudget, _jsx(PlanOffers, { state: state, personal: personal, call: call }), _jsx(History, { state: state })] }))
82
+ : staffPanel, error
83
+ ? (_jsx(Notice, { tone: "neutral", action: _jsx(Button, { size: "sm", onClick: () => void read(), children: "Try again" }), children: "This may be a moment out of date: we couldn't read it again just now." }))
84
+ : null] }));
85
+ }
86
+ /** Someone who doesn't run the org: whether work can go on, and who can help. */
87
+ export function ForMembers({ state, org }) {
88
+ const name = org?.name ?? "This org";
89
+ if (state.ok === false) {
90
+ return (_jsx("div", { className: "billing", children: _jsxs(Notice, { tone: "warning", title: state.why === "budget"
91
+ ? `${name} has reached its budget for the month`
92
+ : `${name} is out of credits`, children: ["Agents can't run until there's more. Nothing is deleted. Its owners and admins can", " ", state.why === "budget" ? "raise it" : "add more", "."] }) }));
93
+ }
94
+ return (_jsx("div", { className: "billing", children: _jsxs(Card, { className: "billing-calm", children: [_jsx("span", { className: "billing-calm-icon", children: _jsx(Icon, { name: "check-circle", size: 20 }) }), _jsxs("div", { children: [_jsx("h3", { children: "All good" }), _jsxs("p", { children: [name, "'s owners and admins look after its billing."] })] })] }) }));
95
+ }
96
+ /** What's scrolled to, and given focus, for the way forward named at the top. */
97
+ const toSection = (id, focus) => {
98
+ const section = document.getElementById(id);
99
+ section?.scrollIntoView({ behavior: "smooth", block: "center" });
100
+ section?.querySelector(focus)?.focus({ preventScroll: true });
101
+ };
102
+ const addCredits = () => toSection("billing-add", "input:checked, input, button");
103
+ const changeBudget = () => toSection("billing-budget", "button");
104
+ /** The balance first: the number, then what it's made of, in the order it'll be spent. */
105
+ export function Balance({ state, personal, team, quiet }) {
106
+ const balance = state.balance ?? 0;
107
+ const debt = state.debt ?? 0;
108
+ const buckets = state.buckets ?? [];
109
+ if (state.waived) {
110
+ const until = state.plan?.waived?.usage?.until;
111
+ return (_jsxs(Card, { as: "section", className: "billing-calm billing-waived", "aria-label": "Balance", children: [_jsx("span", { className: "billing-calm-icon", children: _jsx(Icon, { name: "check-circle", size: 20 }) }), _jsxs("div", { children: [_jsx("h3", { children: "Usage isn't charged" }), _jsxs("p", { children: ["Agents and computers run without using any credits", until ? `, until ${dayShown(until)}` : "", "."] })] })] }));
112
+ }
113
+ return (_jsxs(Card, { as: "section", className: "billing-balance", "aria-labelledby": "billing-balance", "data-stopped": state.ok === false || undefined, children: [quiet ? null : _jsx(Standing, { state: state, personal: personal, team: team }), _jsxs("div", { className: "billing-balance-grid", children: [_jsxs("div", { className: "billing-balance-main", children: [_jsx("h3", { id: "billing-balance", className: "billing-label", children: "Balance" }), _jsx("p", { className: "billing-amount", "data-negative": balance < 0 || undefined, children: money(balance) }), debt > 0
114
+ ? (_jsxs("p", { className: "billing-debt", children: ["The last work went ", money(debt), " ", "past what was left. The next credits cover that first."] }))
115
+ : null, _jsx("p", { className: "billing-worth", children: "$1 of credits pays for $1 of usage, at the listed prices." })] }), buckets.length ? _jsx(Buckets, { buckets: buckets }) : null] })] }));
116
+ }
117
+ /** Why the org can't go on, or soon won't: what stopped, and what to do, in its place. */
118
+ function Standing({ state, personal, team }) {
119
+ const month = state.month;
120
+ const pay = state.pay;
121
+ const resets = month ? dayShown(month.end) : "";
122
+ if (state.ok === false && state.why === "budget") {
123
+ return (_jsxs(Notice, { tone: "warning", role: "status", className: "billing-standing billing-wrap", title: `${personal ? "You've" : "This org has"} reached this month's ${month?.limit !== undefined ? `${money(month.limit)} ` : ""}budget`, action: _jsx(Button, { size: "sm", variant: "primary", onClick: changeBudget, children: "Change budget" }), children: ["Agents have stopped and computers are asleep. Nothing is deleted. It resets", " ", resets, ", or raise it to carry on now."] }));
124
+ }
125
+ if (state.ok === false) {
126
+ return (_jsxs(Notice, { tone: "warning", role: "status", className: "billing-standing billing-wrap", title: `${personal ? "You're" : "This org is"} out of credits`, action: pay
127
+ ? _jsx(Button, { size: "sm", variant: "primary", onClick: addCredits, children: "Add credits" })
128
+ : undefined, children: ["Agents have stopped and computers are asleep. Nothing is deleted. ", pay
129
+ ? "Add credits and you can carry on right away."
130
+ : `Credits come from ${team}: once they add more, you can carry on.`] }));
131
+ }
132
+ const low = lowOn(state);
133
+ if (low === "budget" && month?.limit !== undefined) {
134
+ return (_jsxs(Notice, { tone: "neutral", icon: "info", role: "status", className: "billing-standing billing-wrap", title: `${money(Math.max(month.limit - month.spent, 0))} left of this month's budget`, action: _jsx(Button, { size: "sm", onClick: changeBudget, children: "Change budget" }), children: ["At the limit, agents stop and computers sleep until ", resets, "."] }));
135
+ }
136
+ if (low === "credits") {
137
+ return (_jsx(Notice, { tone: "neutral", icon: "info", role: "status", className: "billing-standing billing-wrap", title: "Running low on credits", action: pay ? _jsx(Button, { size: "sm", onClick: addCredits, children: "Add credits" }) : undefined, children: pay
138
+ ? "Add some now and nobody gets stopped mid-work."
139
+ : `Credits come from ${team}: ask them for more before they run out.` }));
140
+ }
141
+ return null;
142
+ }
143
+ /** Each kind of credit in one colour of the theme's, wherever it shows. */
144
+ const COLOURS = {
145
+ plan: 1,
146
+ trial: 5,
147
+ gift: 3,
148
+ referral: 3,
149
+ purchase: 2,
150
+ waived: 4,
151
+ adjustment: 4,
152
+ };
153
+ /** The credits there are, as one bar and a list, in the order they'll be spent. */
154
+ function Buckets({ buckets: given }) {
155
+ const piles = pilesOf(given);
156
+ const whole = piles.reduce((sum, pile) => sum + pile.left, 0);
157
+ return (_jsxs("div", { className: "billing-buckets", children: [_jsx("h4", { className: "billing-label", children: piles.length > 1 ? "Spent in this order" : "Made up of" }), piles.length > 1
158
+ ? (_jsx("div", { className: "billing-stack", "aria-hidden": "true", children: piles.map((bucket) => (_jsx("span", { "data-colour": COLOURS[bucket.kind] ?? 4, style: { flexGrow: bucket.left / whole } }, bucket.id))) }))
159
+ : null, _jsx("ol", { className: "billing-bucket-list", children: piles.map((bucket) => (_jsxs("li", { children: [_jsx("i", { "data-colour": COLOURS[bucket.kind] ?? 4, "aria-hidden": "true" }), _jsxs("span", { className: "billing-bucket-name", children: [kindSaid(bucket.kind), _jsx("small", { children: lapseSaid(bucket.expires) })] }), _jsxs("span", { className: "billing-bucket-left", children: [money(bucket.left), Math.abs(bucket.left - bucket.amount) > 0.005
160
+ ? _jsxs("small", { children: ["of ", money(bucket.amount)] })
161
+ : null] })] }, bucket.id))) })] }));
162
+ }
163
+ const keptBefore = () => {
164
+ try {
165
+ const kept = sessionStorage.getItem(LEFT);
166
+ return kept ? JSON.parse(kept) : undefined;
167
+ }
168
+ catch {
169
+ return undefined;
170
+ }
171
+ };
172
+ const forgetBefore = () => {
173
+ try {
174
+ sessionStorage.removeItem(LEFT);
175
+ }
176
+ catch { /* Nothing was kept. */ }
177
+ };
178
+ /**
179
+ * Coming back with `?paid=`: what was bought lands by the payment provider's
180
+ * word, which may come a moment later, so it reads again until the state shows
181
+ * it, a few times, and then says plainly it isn't showing yet.
182
+ */
183
+ function usePayment(state, read) {
184
+ const [paid] = useState(() => paidIn(globalThis.location?.search ?? ""));
185
+ const [before] = useState(keptBefore);
186
+ const [phase, setPhase] = useState(paid ? "waiting" : "none");
187
+ const [thanks, setThanks] = useState();
188
+ useEffect(() => {
189
+ if (!paid || !state || phase === "done" || phase === "none")
190
+ return;
191
+ const said = landed(state, paid, before);
192
+ if (!said)
193
+ return;
194
+ setThanks(said);
195
+ setPhase("done");
196
+ forgetBefore();
197
+ // Read again later, this page doesn't confirm it twice.
198
+ history.replaceState(history.state, "", location.pathname + withoutPaid(location.search));
199
+ }, [state, phase]);
200
+ useEffect(() => {
201
+ if (phase !== "waiting")
202
+ return;
203
+ let timer;
204
+ let current = true;
205
+ const next = (at) => {
206
+ if (at >= TRIES.length)
207
+ return setPhase("late");
208
+ timer = setTimeout(async () => {
209
+ await read();
210
+ if (current)
211
+ next(at + 1);
212
+ }, TRIES[at]);
213
+ };
214
+ next(0);
215
+ return () => {
216
+ current = false;
217
+ clearTimeout(timer);
218
+ };
219
+ }, [phase]);
220
+ return {
221
+ phase,
222
+ thanks,
223
+ again: () => setPhase("waiting"),
224
+ dismiss: () => setPhase("none"),
225
+ };
226
+ }
227
+ function PaymentNotice({ payment }) {
228
+ if (payment.phase === "waiting") {
229
+ return (_jsx(Notice, { tone: "info", icon: false, role: "status", className: "billing-paying", children: _jsxs("span", { className: "billing-paying-line", children: [_jsx(Spinner, { size: 15 }), _jsxs("span", { children: [_jsx("b", { children: "Confirming your payment\u2026" }), " ", "This usually takes a few seconds. You can stay here or get back to work."] })] }) }));
230
+ }
231
+ if (payment.phase === "late") {
232
+ return (_jsx(Notice, { tone: "neutral", role: "status", className: "billing-wrap", title: "Your payment isn't showing yet", action: _jsx(Button, { size: "sm", onClick: payment.again, children: "Check again" }), children: "If you paid, it'll land here soon: there's no need to pay again. Sometimes a bank takes a little longer." }));
233
+ }
234
+ if (payment.phase === "done") {
235
+ return (_jsx(Notice, { tone: "success", role: "status", title: "Thank you", action: _jsx(IconButton, { icon: "close", size: "sm", label: "Dismiss", onClick: payment.dismiss }), children: payment.thanks }));
236
+ }
237
+ return null;
238
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * The org's plan: its name, what's left of this period's plan credits and the
3
+ * day they end (what's left then doesn't carry over), what it includes, its
4
+ * seats and this period's dates. If the last payment failed, it says so and how
5
+ * to fix it: what they have still works, and new plan credits wait until it's
6
+ * sorted. Beside it (a card of its own, `PlanOffers`): the plans they can move
7
+ * to, those for their kind of org first, each with its price a seat and the
8
+ * total for their seats shown before they go, then a payment page where they
9
+ * confirm; in a personal org, that a team's plan is for team orgs. Already
10
+ * subscribed, those changes happen in "Manage billing", the payment provider's
11
+ * own page (card, invoices, cancelling).
12
+ */
13
+ import type { ReactNode } from "react";
14
+ import { type BillingState } from "./numbers.js";
15
+ import { type Call } from "./types.js";
16
+ export declare function PlanCard({ state, team, personal, call }: {
17
+ state: BillingState;
18
+ team: string;
19
+ /** A personal org: one person, so no seats to speak of. */
20
+ personal?: boolean;
21
+ call: Call;
22
+ }): ReactNode;
23
+ /** The plans they can move to; none while a subscription runs (that's Manage billing's). */
24
+ export declare function PlanOffers({ state, personal, call }: {
25
+ state: BillingState;
26
+ personal?: boolean;
27
+ call: Call;
28
+ }): ReactNode;
@@ -0,0 +1,84 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Badge, Button, Card, Icon, Notice } from "copilotz/web";
3
+ import { leavingFor, useBackFromLeaving } from "./add.js";
4
+ import { dayShown, includes, inCurrency, periodShown, planCredits, planCreditsSaid, priceFor, seatsSaid, } from "./numbers.js";
5
+ import { useAct } from "./types.js";
6
+ export function PlanCard({ state, team, personal = false, call }) {
7
+ const plan = state.plan;
8
+ const pay = state.pay;
9
+ const manage = useAct();
10
+ useBackFromLeaving(manage.release);
11
+ if (!plan)
12
+ return null;
13
+ const pastDue = plan.status === "past_due";
14
+ const ended = plan.status === "canceled";
15
+ const subscribed = !!plan.subscription && !ended;
16
+ const perSeat = plan.fixedSeats !== undefined ||
17
+ (!personal && (!!plan.terms.seat || !!plan.terms.credits?.perSeat));
18
+ const open = () => void manage.run(async () => {
19
+ const { url } = await call("billing.portal", {});
20
+ leavingFor(url, state);
21
+ }, true);
22
+ const said = includes(plan.terms, state.seats, personal);
23
+ const credits = planCredits(state);
24
+ return (_jsxs(Card, { as: "section", className: "billing-card", "aria-labelledby": "billing-plan", children: [_jsxs("header", { className: "billing-card-head", children: [_jsx("h3", { id: "billing-plan", children: "Plan" }), pastDue
25
+ ? _jsx(Badge, { tone: "warning", children: "Payment failed" })
26
+ : ended
27
+ ? _jsx(Badge, { children: "Ended" })
28
+ : null] }), _jsx("p", { className: "billing-plan-name", children: plan.terms.title }), credits ? _jsx(PlanCredits, { credits: credits, renews: !pastDue && !ended }) : null, pastDue
29
+ ? (_jsxs(Notice, { tone: "warning", role: "status", className: "billing-wrap", title: "The last payment didn't go through", action: pay?.manage
30
+ ? (_jsx(Button, { size: "sm", variant: "primary", loading: manage.busy, onClick: open, children: "Fix payment" }))
31
+ : undefined, children: ["What you have still works. New plan credits wait until it's sorted.", pay?.manage ? "" : ` Ask ${team} for help.`] }))
32
+ : null, _jsxs("dl", { className: "billing-facts", children: [said.length
33
+ ? (_jsxs("div", { children: [_jsx("dt", { children: "Includes" }), _jsx("dd", { children: said.join(" · ") })] }))
34
+ : null, perSeat && state.seats !== undefined
35
+ ? (_jsxs("div", { children: [_jsx("dt", { children: "Seats" }), _jsxs("dd", { children: [seatsSaid(plan.fixedSeats ?? Math.max(state.seats, 1)), _jsx("small", { children: plan.fixedSeats !== undefined
36
+ ? " · set by your contract"
37
+ : " · guests don't count" })] })] }))
38
+ : null, state.period
39
+ ? (_jsxs("div", { children: [_jsx("dt", { children: "This period" }), _jsxs("dd", { children: [periodShown(state.period), pastDue || ended ? null : _jsxs("small", { children: ["\u00B7 renews ", dayShown(state.period.end)] })] })] }))
40
+ : null] }), subscribed && pay?.manage
41
+ ? _jsx("p", { className: "billing-small", children: "To change or cancel your plan, use Manage billing." })
42
+ : null, pay?.manage && !pastDue
43
+ ? (_jsxs("div", { className: "billing-card-end", children: [_jsx(Button, { icon: "external", loading: manage.busy, onClick: open, children: "Manage billing" }), _jsx("span", { className: "billing-small", children: "Card, invoices and cancelling." })] }))
44
+ : null, manage.error ? _jsx(Notice, { tone: "destructive", role: "alert", children: manage.error }) : null] }));
45
+ }
46
+ /** This period's plan credits: what's left, until when, as a meter that empties. */
47
+ function PlanCredits({ credits, renews }) {
48
+ const said = planCreditsSaid(credits, renews);
49
+ const share = credits.amount > 0 ? credits.left / credits.amount : 0;
50
+ return (_jsxs("div", { className: "billing-plan-credits", children: [_jsx("p", { className: "billing-plan-left", children: said.title }), _jsx("div", { className: "billing-meter", role: "meter", "aria-label": "This month's plan credits left", "aria-valuemin": 0, "aria-valuemax": credits.amount, "aria-valuenow": Math.round(credits.left * 100) / 100, children: _jsx("span", { style: { width: `${Math.round(share * 1000) / 10}%` } }) }), _jsx("p", { className: "billing-small", children: said.more })] }));
51
+ }
52
+ /** The plans they can move to; none while a subscription runs (that's Manage billing's). */
53
+ export function PlanOffers({ state, personal = false, call }) {
54
+ const plan = state.plan;
55
+ const offers = (state.pay?.plans ?? []).filter((offer) => offer.name !== plan?.name);
56
+ // A personal org isn't offered a team's plan: it's told where that is instead.
57
+ const forTeams = (state.pay?.elsewhere ?? []).filter((offer) => offer.for === "team");
58
+ const subscribed = !!plan?.subscription && plan.status !== "canceled";
59
+ // On a contract, moving is a conversation, and what a checkout charges isn't its seats.
60
+ if ((!offers.length && !forTeams.length) || subscribed || plan?.fixedSeats !== undefined) {
61
+ return null;
62
+ }
63
+ return (_jsxs(Card, { as: "section", className: "billing-card", "aria-labelledby": "billing-offers", children: [_jsxs("header", { className: "billing-card-head", children: [_jsx("h3", { id: "billing-offers", children: offers.length ? "Move to a plan" : "Plans" }), offers.length
64
+ ? _jsx("p", { className: "billing-small", children: "You'll confirm on a secure payment page before you pay." })
65
+ : null] }), offers.length
66
+ ? (_jsx("ul", { className: "billing-offers", children: offers.map((offer) => (_jsx(OfferRow, { offer: offer, state: state, personal: personal, call: call }, offer.name))) }))
67
+ : null, forTeams.length ? _jsx(ForTeams, { offers: forTeams }) : null] }));
68
+ }
69
+ /** A team's plans, said to a personal org: what they are, and that a team org takes them. */
70
+ function ForTeams({ offers }) {
71
+ const names = offers.map((offer) => `${offer.title} (${inCurrency(offer.seat.price, offer.seat.currency)} a seat a month)`);
72
+ return (_jsxs("p", { className: "billing-small billing-for-teams", children: [_jsx(Icon, { name: "users", size: 14 }), _jsxs("span", { children: ["Working with others? ", names.join(" and "), " ", offers.length === 1 ? "is" : "are", " ", "for team orgs: make one from the org switcher, and choose it in that org's Billing."] })] }));
73
+ }
74
+ /** A plan they can move to: what it costs them, before they go. */
75
+ function OfferRow({ offer, state, personal, call }) {
76
+ const { busy, error, run, release } = useAct();
77
+ useBackFromLeaving(release);
78
+ const price = priceFor(offer.seat, state.billedSeats ?? state.seats, personal);
79
+ const gets = includes(offer, state.seats, personal);
80
+ return (_jsxs("li", { className: "billing-offer", children: [_jsxs("div", { className: "billing-offer-text", children: [_jsx("b", { children: offer.title }), _jsx("span", { className: "billing-offer-total", children: price.total }), price.each ? _jsx("span", { children: price.each }) : null, gets.length ? _jsx("span", { children: gets.join(" · ") }) : null] }), _jsxs(Button, { className: "billing-go", loading: busy, onClick: () => void run(async () => {
81
+ const { url } = await call("billing.checkout", { plan: offer.name });
82
+ leavingFor(url, state);
83
+ }, true), children: ["Move to ", offer.title] }), error ? _jsx(Notice, { tone: "destructive", role: "alert", children: error }) : null] }));
84
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * What platform staff do with an org's billing, in the staff colour and marked
3
+ * as theirs: where it stands now (its plan, how it pays, any waiver, who waived
4
+ * it and why), and doing something about it: setting its plan or a contract of
5
+ * its own, giving or taking credits, waiving its usage or seats (and lifting
6
+ * that), recording a payment that came in by hand. Each in a dialog that says
7
+ * exactly what will happen, on a button naming it: all of it is on the org's
8
+ * record, and most of it can only be corrected afterwards, not undone.
9
+ */
10
+ import { type ReactNode } from "react";
11
+ import { type BillingState } from "./numbers.js";
12
+ import { type Call, type Org } from "./types.js";
13
+ type Props = {
14
+ state: BillingState;
15
+ org?: Org;
16
+ call: Call;
17
+ onChanged(): Promise<void>;
18
+ };
19
+ export declare function StaffPanel({ state, org, call, onChanged }: Props): ReactNode;
20
+ export {};