@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,65 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * The note above where you type (kind `billing.composer`, chat's
4
+ * `composerNotes`): so people know before they send. Nothing at all while it
5
+ * asks, when there's nothing to say, or if it can't ask. Stopped, it says the
6
+ * org is out of credits or at its budget: with a way to Billing for those who
7
+ * run it, and who can help for everyone else; that one can't be put away. Low,
8
+ * for those who run it, what's left and a way to add more, which they can put
9
+ * away for this visit. It asks when it appears, when the page comes back, and
10
+ * when agents finish working (`hints.working`), which is when it changes.
11
+ */
12
+ import { useEffect, useState } from "react";
13
+ import { Button, IconButton, Notice } from "copilotz/web";
14
+ import { useStanding } from "copilotz/plugins/interfaces/contract";
15
+ import { noticeKey, noticeSaid } from "./numbers.js";
16
+ import { STYLE } from "./style.js";
17
+ const BILLING = "/org?tab=billing";
18
+ /** Where dismissing is kept: for this visit, this org. */
19
+ const keyOf = (org, said) => `billing-note-away:${org}:${noticeKey(said)}`;
20
+ const away = (key) => {
21
+ try {
22
+ return sessionStorage.getItem(key) === "1";
23
+ }
24
+ catch {
25
+ return false;
26
+ }
27
+ };
28
+ export const composerNoteView = (_, ctx) => (_jsxs(_Fragment, { children: [_jsx("style", { href: "billing-web", precedence: "copilotz", children: STYLE }), _jsx(Note, { call: ctx.call, working: ctx.hints.working === true })] }));
29
+ function Note({ call, working }) {
30
+ // The org it's in (spaces' `org.me`, where the app serves many): its notes are kept apart.
31
+ const here = useStanding(call)?.org;
32
+ const [said, setSaid] = useState(null);
33
+ const [gone, setGone] = useState();
34
+ useEffect(() => {
35
+ if (working)
36
+ return;
37
+ let current = true;
38
+ const ask = () => call("billing.notice", {}).then((answer) => current && setSaid(answer),
39
+ // A failure to ask says nothing: the real thing decides when someone sends.
40
+ () => { });
41
+ const again = () => document.visibilityState === "visible" && void ask();
42
+ void ask();
43
+ document.addEventListener("visibilitychange", again);
44
+ return () => {
45
+ current = false;
46
+ document.removeEventListener("visibilitychange", again);
47
+ };
48
+ }, [working]);
49
+ if (!said)
50
+ return null;
51
+ const key = keyOf(here?.id ?? "", said);
52
+ if (said.tone === "low" && (gone === key || away(key)))
53
+ return null;
54
+ const words = noticeSaid(said, here?.kind === "personal");
55
+ const stop = said.tone === "stop";
56
+ return (_jsxs(Notice, { tone: stop ? "warning" : "neutral", role: "status", className: "billing-note", action: _jsxs(_Fragment, { children: [said.runs
57
+ ? (_jsx(Button, { size: "sm", variant: stop ? "primary" : "outline", href: BILLING, children: stop ? "Go to Billing" : said.why === "budget" ? "Change budget" : "Add credits" }))
58
+ : null, stop ? null : (_jsx(IconButton, { icon: "close", size: "sm", label: "Not now", onClick: () => {
59
+ try {
60
+ sessionStorage.setItem(key, "1");
61
+ }
62
+ catch { /* It stays away for this page only. */ }
63
+ setGone(key);
64
+ } }))] }), children: [_jsxs("b", { children: [words.title, words.more ? "." : ""] }), words.more ? ` ${words.more}` : ""] }));
65
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * What came in, newest first: credits (what they were, why, how much, when, and
3
+ * when they lapse) and payments (what for, how much, when). A long list shows
4
+ * its first few, the rest a press away. Nothing yet says what will show here.
5
+ * Staff also see a payment's reference and what the channel kept, marked as
6
+ * theirs; the org's people never see that.
7
+ */
8
+ import { type ReactNode } from "react";
9
+ import { type BillingState } from "./numbers.js";
10
+ export declare function History({ state }: {
11
+ state: BillingState;
12
+ }): ReactNode;
@@ -0,0 +1,55 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * What came in, newest first: credits (what they were, why, how much, when, and
4
+ * when they lapse) and payments (what for, how much, when). A long list shows
5
+ * its first few, the rest a press away. Nothing yet says what will show here.
6
+ * Staff also see a payment's reference and what the channel kept, marked as
7
+ * theirs; the org's people never see that.
8
+ */
9
+ import { useState } from "react";
10
+ import { Button, Card, EmptyState, Icon, Tabs } from "copilotz/web";
11
+ import { dayShown, dollars, inCurrency, kindSaid, money, newestFirst, purposeSaid, whenShown, } from "./numbers.js";
12
+ const FIRST = 8;
13
+ export function History({ state }) {
14
+ const credits = newestFirst(state.history ?? []);
15
+ const payments = newestFirst(state.payments ?? []);
16
+ const [which, setWhich] = useState("credits");
17
+ const [all, setAll] = useState(false);
18
+ const rows = which === "credits" ? credits : payments;
19
+ const shown = all ? rows : rows.slice(0, FIRST);
20
+ return (_jsxs(Card, { as: "section", className: "billing-card billing-history", "aria-labelledby": "billing-history", children: [_jsxs("header", { className: "billing-card-head billing-history-head", children: [_jsx("h3", { id: "billing-history", children: "History" }), _jsx(Tabs, { label: "Which history", value: which, onChange: (next) => {
21
+ setWhich(next);
22
+ setAll(false);
23
+ }, tabs: [
24
+ { value: "credits", label: "Credits", count: credits.length || undefined },
25
+ { value: "payments", label: "Payments", count: payments.length || undefined },
26
+ ] })] }), rows.length
27
+ ? (_jsx("ul", { className: "billing-rows", children: which === "credits"
28
+ ? shown.map((row) => _jsx(CreditLine, { row: row }, row._id))
29
+ : shown.map((row) => (_jsx(PaymentLine, { row: row, staff: state.staff }, row._id))) }))
30
+ : (_jsx(EmptyState, { icon: which === "credits" ? "star" : "note", heading: "h3", title: which === "credits" ? "No credits yet" : "No payments yet", className: "billing-none", children: which === "credits"
31
+ ? "Credits show here as they're added."
32
+ : "What this org pays shows here, as it's paid." })), rows.length > FIRST
33
+ ? (_jsx(Button, { size: "sm", variant: "ghost", className: "billing-more", onClick: () => setAll(!all), children: all ? "Show fewer" : `Show all ${rows.length}` }))
34
+ : null] }));
35
+ }
36
+ function CreditLine({ row }) {
37
+ // A plan's credits and a trial note the plan they came from; anything else, why it was given.
38
+ const ofPlan = row.kind === "plan" || row.kind === "trial";
39
+ const more = [
40
+ row.note ? (ofPlan ? `${row.note} plan` : row.note) : null,
41
+ row.expires ? `Lapses ${dayShown(row.expires)}` : null,
42
+ ].filter(Boolean).join(" · ");
43
+ return (_jsxs("li", { className: "billing-row", children: [_jsxs("span", { className: "billing-row-text", children: [_jsx("b", { children: kindSaid(row.kind) }), more ? _jsx("span", { className: "billing-row-note", children: more }) : null] }), _jsxs("span", { className: "billing-row-end", children: [_jsx("span", { className: "billing-row-amount", "data-way": row.amount < 0 ? "out" : "in", children: row.amount < 0 ? money(row.amount) : `+${money(row.amount)}` }), _jsx("small", { children: whenShown(row.at) })] })] }));
44
+ }
45
+ function PaymentLine({ row, staff }) {
46
+ const more = [
47
+ row.note,
48
+ row.credits
49
+ ? `${dollars(Math.abs(row.credits))} of credits${row.credits < 0 ? " taken back" : ""}`
50
+ : null,
51
+ ].filter(Boolean).join(" · ");
52
+ return (_jsxs("li", { className: "billing-row", children: [_jsxs("span", { className: "billing-row-text", children: [_jsx("b", { children: purposeSaid(row.purpose) }), more ? _jsx("span", { className: "billing-row-note", children: more }) : null, staff
53
+ ? (_jsxs("span", { className: "billing-row-staff", children: [_jsx(Icon, { name: "shield", size: 11 }), row.channel, ": ", row.ref, row.fee !== undefined ? ` · kept ${inCurrency(row.fee, row.currency)}` : "", row.rate !== undefined ? ` · ${row.rate} USD each` : ""] }))
54
+ : null] }), _jsxs("span", { className: "billing-row-end", children: [_jsx("span", { className: "billing-row-amount", "data-way": row.amount < 0 ? "out" : "paid", children: inCurrency(row.amount, row.currency) }), _jsx("small", { children: whenShown(row.at) })] })] }));
55
+ }
@@ -0,0 +1,256 @@
1
+ /**
2
+ * Billing as the web says it: what `billing.state` answers, in words and
3
+ * numbers people read. Money to the cent, so a balance can be trusted; a day
4
+ * as the calendar has it, wherever you are (the backend's days are UTC);
5
+ * credits by what they are and in the order they're spent; what a plan
6
+ * includes and what it comes to for the org's seats; why the org is stopped
7
+ * or running low; how far along the month's budget is; an amount as typed; and
8
+ * whether what was just paid has landed. Only prices: never a cost or a fee.
9
+ */
10
+ /** A pile of credits, as the walk leaves it: what it was, what's left, when it lapses. */
11
+ export type Bucket = {
12
+ id: string;
13
+ kind: string;
14
+ amount: number;
15
+ left: number;
16
+ expires?: string;
17
+ };
18
+ /** A credit in the org's history. */
19
+ export type CreditRow = {
20
+ _id: string;
21
+ kind: string;
22
+ amount: number;
23
+ at: string;
24
+ expires?: string;
25
+ note?: string;
26
+ };
27
+ /** Money that came in. */
28
+ export type PaymentRow = {
29
+ _id: string;
30
+ channel: string;
31
+ ref: string;
32
+ amount: number;
33
+ currency: string;
34
+ purpose: string;
35
+ credits?: number;
36
+ note?: string;
37
+ fee?: number;
38
+ rate?: number;
39
+ at: string;
40
+ };
41
+ export type Seat = {
42
+ price: number;
43
+ currency: string;
44
+ };
45
+ export type CreditTerms = {
46
+ once?: number;
47
+ monthly?: number;
48
+ perSeat?: number;
49
+ };
50
+ export type Terms = {
51
+ title: string;
52
+ seat?: Seat;
53
+ credits?: CreditTerms;
54
+ prices?: string;
55
+ /** Which orgs may be on it; none: any. */
56
+ for?: "personal" | "team";
57
+ /** The model tier its agents use while the org hasn't chosen one. */
58
+ models?: string;
59
+ };
60
+ /** A waiver: why, by whom, until when; for seats, whose. */
61
+ export type Waiver = {
62
+ from: string;
63
+ reason: string;
64
+ until?: string;
65
+ by?: string;
66
+ people?: string[];
67
+ };
68
+ export type Plan = {
69
+ name: string;
70
+ terms: Terms;
71
+ since: string;
72
+ channel?: string;
73
+ status?: string;
74
+ customer?: string;
75
+ subscription?: string;
76
+ fixedSeats?: number;
77
+ waived?: {
78
+ usage?: Waiver;
79
+ seats?: Waiver;
80
+ };
81
+ };
82
+ /** A plan the org can move to by itself. */
83
+ export type Offer = {
84
+ name: string;
85
+ title: string;
86
+ seat: Seat;
87
+ credits?: CreditTerms;
88
+ for?: "personal" | "team";
89
+ };
90
+ export type Pay = {
91
+ topUps: number[];
92
+ min: number;
93
+ max: number;
94
+ /** Those it can move to, its own kind of org's first. */
95
+ plans: Offer[];
96
+ /** Those only another kind of org can have (a team's, for a personal org). */
97
+ elsewhere?: Offer[];
98
+ manage: boolean;
99
+ };
100
+ export type Month = {
101
+ start: string;
102
+ end: string;
103
+ spent: number;
104
+ limit?: number;
105
+ };
106
+ /** What `billing.state` answers. People who don't run the org get only the first few. */
107
+ export type BillingState = {
108
+ on: boolean;
109
+ runs: boolean;
110
+ staff: boolean;
111
+ ok?: boolean;
112
+ why?: "credits" | "budget";
113
+ message?: string;
114
+ low?: boolean;
115
+ plan?: Plan | null;
116
+ seats?: number;
117
+ /** Those a seat is charged for: all but those waived. */
118
+ billedSeats?: number;
119
+ /** What it's running low on. */
120
+ lowOn?: "credits" | "budget";
121
+ period?: {
122
+ start: string;
123
+ end: string;
124
+ };
125
+ balance?: number;
126
+ buckets?: Bucket[];
127
+ debt?: number;
128
+ waived?: boolean;
129
+ month?: Month;
130
+ history?: CreditRow[];
131
+ payments?: PaymentRow[];
132
+ pay?: Pay | null;
133
+ plans?: Record<string, Terms>;
134
+ };
135
+ /** Dollars to the cent: "$1,234.50", "$0.00"; "<$0.01" for crumbs; a minus sign below zero. */
136
+ export declare function money(n: number): string;
137
+ /** Dollars, cents only when there are some: "$25", "$12.50", "$1,000". */
138
+ export declare function dollars(n: number): string;
139
+ /** An amount in its currency: "$25", "R$ 100.50"; a code it doesn't know stands as it is. */
140
+ export declare function inCurrency(n: number, currency: string): string;
141
+ /** A day as the calendar has it ("Dec 1"; its year when it isn't this one), wherever you are. */
142
+ export declare function dayShown(day: string, now?: number): string;
143
+ /** The day before: a period's last, since the backend's `end` is the first day after it. */
144
+ export declare const dayBefore: (day: string) => string;
145
+ /** A period's dates, its last day included: "Nov 1 to Nov 30". */
146
+ export declare const periodShown: (period: {
147
+ start: string;
148
+ end: string;
149
+ }, now?: number) => string;
150
+ /** When something happened, where you are: "Nov 1", with its year when it isn't this one. */
151
+ export declare function whenShown(at: string, now?: number): string;
152
+ /** What a credit is, in words. */
153
+ export declare const kindSaid: (kind: string) => string;
154
+ /** What a payment was for, in words. */
155
+ export declare const purposeSaid: (purpose: string) => string;
156
+ /** A credit's expiry, for a row: when it lapses, or that it doesn't. */
157
+ export declare const lapseSaid: (expires: string | undefined, now?: number) => string;
158
+ /**
159
+ * Credits as they read: those of one kind that lapse alike are one pile, and
160
+ * the order they'll be spent in stays.
161
+ */
162
+ export declare function pilesOf(buckets: Bucket[]): Bucket[];
163
+ /** Newest first, however it came. */
164
+ export declare const newestFirst: <T extends {
165
+ at: string;
166
+ }>(rows: T[]) => T[];
167
+ /**
168
+ * What a plan's terms give, in sentences' pieces, and for this many seats. A
169
+ * personal org is one person: its seat isn't worth saying.
170
+ */
171
+ export declare function includes(terms: Pick<Terms, "credits">, seats?: number, personal?: boolean): string[];
172
+ /** "1 seat", "3 seats". */
173
+ export declare const seatsSaid: (n: number) => string;
174
+ /**
175
+ * What a plan comes to, per seat and for the org's: what's asked before they
176
+ * go. A personal org's is just a month's price.
177
+ */
178
+ export declare function priceFor(seat: Seat, seats: number | undefined, personal?: boolean): {
179
+ each?: string;
180
+ total: string;
181
+ };
182
+ /**
183
+ * This period's plan credits: what came, what's left of it, and the day they
184
+ * end (the period's end, when what's left lapses); none when none came.
185
+ */
186
+ export declare function planCredits(s: Pick<BillingState, "period" | "history" | "buckets">): {
187
+ amount: number;
188
+ left: number;
189
+ until: string;
190
+ } | undefined;
191
+ /** What a plan's credits for this period come to, in words: "$4.20 of this month's credits, until Dec 3". */
192
+ export declare function planCreditsSaid(c: {
193
+ amount: number;
194
+ left: number;
195
+ until: string;
196
+ }, renews: boolean, now?: number): {
197
+ title: string;
198
+ more: string;
199
+ };
200
+ /** What it says the org is running low on. */
201
+ export declare const lowOn: (s: BillingState) => "credits" | "budget" | undefined;
202
+ /** How the month stands against its limit: the meter's fill, and fine, near or at it. */
203
+ export declare function budgetStand(s: Pick<BillingState, "month" | "ok" | "why" | "low" | "buckets" | "balance" | "waived">): {
204
+ limit?: number;
205
+ spent: number;
206
+ left?: number;
207
+ share: number;
208
+ tone: "none" | "fine" | "near" | "at";
209
+ };
210
+ /** An amount as typed: "$1,000.50" is 1000.5. None for what isn't one. */
211
+ export declare function amountOf(typed: string): number | undefined;
212
+ /** Whether a typed top-up can be bought, and what to say if not. */
213
+ export declare function topUpOf(typed: string, limits: Pick<Pay, "min" | "max">): {
214
+ amount: number;
215
+ } | {
216
+ error: string;
217
+ };
218
+ /** What the org's balance comes to after adding this (any debt is paid first). */
219
+ export declare const balanceAfter: (balance: number, adding: number) => number;
220
+ /** What's kept on leaving for a payment page, to tell on coming back what's new. */
221
+ export type Before = {
222
+ payments: string[];
223
+ plan?: string;
224
+ subscription?: string;
225
+ };
226
+ export declare const beforeOf: (s: BillingState) => Before;
227
+ /**
228
+ * What landed, if it has, from a payment just made: words to thank them with,
229
+ * or none while it hasn't. Only what the state shows counts, never that they
230
+ * came back.
231
+ */
232
+ export declare function landed(s: BillingState, paid: "credits" | "plan", before: Before | undefined, now?: number): string | undefined;
233
+ /** What `?paid=` says was just paid, if anything we know. */
234
+ export declare const paidIn: (search: string) => "credits" | "plan" | undefined;
235
+ /** The address without `?paid=`, so reading it again doesn't confirm it again. */
236
+ export declare function withoutPaid(search: string): string;
237
+ /** Who to ask when nothing can be bought: the app's team, by the name the page's title ends with. */
238
+ export declare function teamOf(title: string): string;
239
+ /** A contract's id, from its title: "Acme & Co" is `acme-co`. */
240
+ export declare const slug: (title: string) => string;
241
+ /** A date field's day, as the moment it starts (UTC): when a waiver or a code ends. */
242
+ export declare const startOfDay: (day: string) => string;
243
+ /** The stop or low notice above the composer, in words (`billing.notice`'s answer). */
244
+ export type Notice = {
245
+ tone: "stop" | "low";
246
+ why: "credits" | "budget";
247
+ left?: number;
248
+ runs: boolean;
249
+ };
250
+ /** Which low notice this is, so dismissing one says nothing of the other. */
251
+ export declare const noticeKey: (n: Notice) => string;
252
+ /** What the note above the composer says. */
253
+ export declare function noticeSaid(n: Notice, personal?: boolean): {
254
+ title: string;
255
+ more?: string;
256
+ };
@@ -0,0 +1,291 @@
1
+ /**
2
+ * Billing as the web says it: what `billing.state` answers, in words and
3
+ * numbers people read. Money to the cent, so a balance can be trusted; a day
4
+ * as the calendar has it, wherever you are (the backend's days are UTC);
5
+ * credits by what they are and in the order they're spent; what a plan
6
+ * includes and what it comes to for the org's seats; why the org is stopped
7
+ * or running low; how far along the month's budget is; an amount as typed; and
8
+ * whether what was just paid has landed. Only prices: never a cost or a fee.
9
+ */
10
+ const MINUS = "−";
11
+ const DAY = 86_400_000;
12
+ /** Dollars to the cent: "$1,234.50", "$0.00"; "<$0.01" for crumbs; a minus sign below zero. */
13
+ export function money(n) {
14
+ if (n < 0)
15
+ return `${MINUS}${money(-n)}`;
16
+ if (n > 0 && n < 0.005)
17
+ return "<$0.01";
18
+ return n.toLocaleString("en-US", { style: "currency", currency: "USD" });
19
+ }
20
+ /** Dollars, cents only when there are some: "$25", "$12.50", "$1,000". */
21
+ export function dollars(n) {
22
+ if (n < 0)
23
+ return `${MINUS}${dollars(-n)}`;
24
+ const cents = Math.round(n * 100) % 100 !== 0;
25
+ return n.toLocaleString("en-US", {
26
+ style: "currency",
27
+ currency: "USD",
28
+ minimumFractionDigits: cents ? 2 : 0,
29
+ maximumFractionDigits: 2,
30
+ });
31
+ }
32
+ /** An amount in its currency: "$25", "R$ 100.50"; a code it doesn't know stands as it is. */
33
+ export function inCurrency(n, currency) {
34
+ const code = currency.toUpperCase();
35
+ if (code === "USD")
36
+ return dollars(n);
37
+ const cents = Math.round(Math.abs(n) * 100) % 100 !== 0;
38
+ try {
39
+ return n.toLocaleString("en-US", {
40
+ style: "currency",
41
+ currency: code,
42
+ minimumFractionDigits: cents ? 2 : 0,
43
+ maximumFractionDigits: 2,
44
+ });
45
+ }
46
+ catch {
47
+ return `${n.toFixed(2)} ${code}`;
48
+ }
49
+ }
50
+ /** A day as the calendar has it ("Dec 1"; its year when it isn't this one), wherever you are. */
51
+ export function dayShown(day, now = Date.now()) {
52
+ const date = new Date(`${day.slice(0, 10)}T00:00:00Z`);
53
+ if (Number.isNaN(date.getTime()))
54
+ return day;
55
+ const year = date.getUTCFullYear() !== new Date(now).getUTCFullYear();
56
+ return date.toLocaleDateString("en-US", {
57
+ timeZone: "UTC",
58
+ month: "short",
59
+ day: "numeric",
60
+ ...(year ? { year: "numeric" } : {}),
61
+ });
62
+ }
63
+ /** The day before: a period's last, since the backend's `end` is the first day after it. */
64
+ export const dayBefore = (day) => new Date(Date.parse(`${day.slice(0, 10)}T00:00:00Z`) - DAY).toISOString().slice(0, 10);
65
+ /** A period's dates, its last day included: "Nov 1 to Nov 30". */
66
+ export const periodShown = (period, now = Date.now()) => `${dayShown(period.start, now)} to ${dayShown(dayBefore(period.end), now)}`;
67
+ /** When something happened, where you are: "Nov 1", with its year when it isn't this one. */
68
+ export function whenShown(at, now = Date.now()) {
69
+ const date = new Date(at);
70
+ if (Number.isNaN(date.getTime()))
71
+ return at;
72
+ const year = date.getFullYear() !== new Date(now).getFullYear();
73
+ return date.toLocaleDateString("en-US", {
74
+ month: "short",
75
+ day: "numeric",
76
+ ...(year ? { year: "numeric" } : {}),
77
+ });
78
+ }
79
+ const KINDS = {
80
+ trial: "Trial credits",
81
+ plan: "Plan credits",
82
+ gift: "Gift",
83
+ referral: "Referral",
84
+ purchase: "Purchased",
85
+ waived: "Usage given back",
86
+ adjustment: "Adjustment",
87
+ };
88
+ /** What a credit is, in words. */
89
+ export const kindSaid = (kind) => KINDS[kind] ?? kind[0].toUpperCase() + kind.slice(1);
90
+ const PURPOSES = {
91
+ seats: "Plan",
92
+ credits: "Credits",
93
+ contract: "Contract",
94
+ refund: "Refund",
95
+ };
96
+ /** What a payment was for, in words. */
97
+ export const purposeSaid = (purpose) => PURPOSES[purpose] ?? purpose;
98
+ /** A credit's expiry, for a row: when it lapses, or that it doesn't. */
99
+ export const lapseSaid = (expires, now = Date.now()) => expires ? `Lapses ${dayShown(expires, now)}` : "Doesn't lapse";
100
+ /**
101
+ * Credits as they read: those of one kind that lapse alike are one pile, and
102
+ * the order they'll be spent in stays.
103
+ */
104
+ export function pilesOf(buckets) {
105
+ const piles = new Map();
106
+ for (const bucket of buckets) {
107
+ const key = `${bucket.kind}:${bucket.expires ?? ""}`;
108
+ const pile = piles.get(key);
109
+ if (pile) {
110
+ pile.left += bucket.left;
111
+ pile.amount += bucket.amount;
112
+ }
113
+ else
114
+ piles.set(key, { ...bucket });
115
+ }
116
+ return [...piles.values()];
117
+ }
118
+ /** Newest first, however it came. */
119
+ export const newestFirst = (rows) => [...rows].sort((a, b) => b.at.localeCompare(a.at));
120
+ /**
121
+ * What a plan's terms give, in sentences' pieces, and for this many seats. A
122
+ * personal org is one person: its seat isn't worth saying.
123
+ */
124
+ export function includes(terms, seats, personal = false) {
125
+ const c = terms.credits ?? {};
126
+ const said = [];
127
+ if (c.once)
128
+ said.push(`${dollars(c.once)} of credits to start`);
129
+ if (c.monthly)
130
+ said.push(`${dollars(c.monthly)} of credits each month`);
131
+ if (c.perSeat && personal)
132
+ said.push(`${dollars(c.perSeat)} of credits each month`);
133
+ else if (c.perSeat) {
134
+ const all = seats && seats > 1 ? ` (${dollars(c.perSeat * seats)} for ${seats} seats)` : "";
135
+ said.push(`${dollars(c.perSeat)} of credits a seat each month${all}`);
136
+ }
137
+ return said;
138
+ }
139
+ /** "1 seat", "3 seats". */
140
+ export const seatsSaid = (n) => `${n.toLocaleString("en-US")} seat${n === 1 ? "" : "s"}`;
141
+ /**
142
+ * What a plan comes to, per seat and for the org's: what's asked before they
143
+ * go. A personal org's is just a month's price.
144
+ */
145
+ export function priceFor(seat, seats, personal = false) {
146
+ if (personal)
147
+ return { total: `${inCurrency(seat.price, seat.currency)} a month` };
148
+ const count = Math.max(seats ?? 1, 1);
149
+ return {
150
+ each: `${inCurrency(seat.price, seat.currency)} a seat, each month`,
151
+ total: `${inCurrency(seat.price * count, seat.currency)} a month for ${seatsSaid(count)}`,
152
+ };
153
+ }
154
+ /**
155
+ * This period's plan credits: what came, what's left of it, and the day they
156
+ * end (the period's end, when what's left lapses); none when none came.
157
+ */
158
+ export function planCredits(s) {
159
+ const until = s.period?.end;
160
+ if (!until)
161
+ return undefined;
162
+ const ours = (c) => c.kind === "plan" && c.expires === until;
163
+ const amount = (s.history ?? []).filter(ours).reduce((sum, c) => sum + c.amount, 0);
164
+ if (amount <= 0)
165
+ return undefined;
166
+ const left = (s.buckets ?? []).filter(ours).reduce((sum, b) => sum + b.left, 0);
167
+ return { amount, left: Math.min(Math.max(left, 0), amount), until };
168
+ }
169
+ /** What a plan's credits for this period come to, in words: "$4.20 of this month's credits, until Dec 3". */
170
+ export function planCreditsSaid(c, renews, now = Date.now()) {
171
+ const day = dayShown(c.until, now);
172
+ if (c.left < 0.005) {
173
+ return {
174
+ title: `This month's ${dollars(c.amount)} of credits are used up`,
175
+ more: renews
176
+ ? `More come with the plan on ${day}. Until then, any other credits are used.`
177
+ : "Any other credits are used now.",
178
+ };
179
+ }
180
+ return {
181
+ title: `${money(c.left)} of this month's credits, until ${day}`,
182
+ more: `Of ${dollars(c.amount)}. What's left on ${day} doesn't carry over; credits you buy never lapse.`,
183
+ };
184
+ }
185
+ /** What it says the org is running low on. */
186
+ export const lowOn = (s) => s.low ? s.lowOn ?? "credits" : undefined;
187
+ /** How the month stands against its limit: the meter's fill, and fine, near or at it. */
188
+ export function budgetStand(s) {
189
+ const spent = s.month?.spent ?? 0;
190
+ const limit = s.month?.limit;
191
+ if (limit === undefined)
192
+ return { spent, share: 0, tone: "none" };
193
+ const at = (s.ok === false && s.why === "budget") || spent >= limit;
194
+ const share = limit > 0 ? Math.min(spent / limit, 1) : 1;
195
+ const near = share >= 0.8 || lowOn({ on: true, runs: true, staff: false, ...s }) === "budget";
196
+ return {
197
+ limit,
198
+ spent,
199
+ left: Math.max(limit - spent, 0),
200
+ share: at ? 1 : share,
201
+ tone: at ? "at" : near ? "near" : "fine",
202
+ };
203
+ }
204
+ /** An amount as typed: "$1,000.50" is 1000.5. None for what isn't one. */
205
+ export function amountOf(typed) {
206
+ const plain = typed.trim().replace(/^\$\s*/, "").replace(/,/g, "");
207
+ if (!/^(\d+(\.\d*)?|\.\d+)$/.test(plain))
208
+ return undefined;
209
+ return Math.round(Number(plain) * 100) / 100;
210
+ }
211
+ /** Whether a typed top-up can be bought, and what to say if not. */
212
+ export function topUpOf(typed, limits) {
213
+ const range = `Between ${dollars(limits.min)} and ${dollars(limits.max)}.`;
214
+ const amount = amountOf(typed);
215
+ if (amount === undefined || amount < limits.min || amount > limits.max)
216
+ return { error: range };
217
+ return { amount };
218
+ }
219
+ /** What the org's balance comes to after adding this (any debt is paid first). */
220
+ export const balanceAfter = (balance, adding) => balance + adding;
221
+ export const beforeOf = (s) => ({
222
+ payments: (s.payments ?? []).map((p) => p._id),
223
+ ...(s.plan?.name ? { plan: s.plan.name } : {}),
224
+ ...(s.plan?.subscription ? { subscription: s.plan.subscription } : {}),
225
+ });
226
+ /** How long back a payment counts as this one, when nothing was kept on leaving (another tab). */
227
+ const RECENT = 60 * 60_000;
228
+ /**
229
+ * What landed, if it has, from a payment just made: words to thank them with,
230
+ * or none while it hasn't. Only what the state shows counts, never that they
231
+ * came back.
232
+ */
233
+ export function landed(s, paid, before, now = Date.now()) {
234
+ if (paid === "plan") {
235
+ const plan = s.plan;
236
+ if (!plan?.subscription)
237
+ return undefined;
238
+ if (before && plan.subscription === before.subscription && plan.name === before.plan) {
239
+ return undefined;
240
+ }
241
+ return `You're on the ${plan.terms.title} plan.`;
242
+ }
243
+ const fresh = (s.payments ?? []).filter((p) => p.purpose === "credits" && p.amount > 0 &&
244
+ (before ? !before.payments.includes(p._id) : now - Date.parse(p.at) < RECENT));
245
+ if (!fresh.length)
246
+ return undefined;
247
+ const bought = fresh.reduce((sum, p) => sum + (p.credits ?? 0), 0);
248
+ return bought > 0 ? `${money(bought)} of credits added.` : "Your credits are in.";
249
+ }
250
+ /** What `?paid=` says was just paid, if anything we know. */
251
+ export const paidIn = (search) => {
252
+ const paid = new URLSearchParams(search).get("paid");
253
+ return paid === "credits" || paid === "plan" ? paid : undefined;
254
+ };
255
+ /** The address without `?paid=`, so reading it again doesn't confirm it again. */
256
+ export function withoutPaid(search) {
257
+ const params = new URLSearchParams(search);
258
+ params.delete("paid");
259
+ const rest = params.toString();
260
+ return rest ? `?${rest}` : "";
261
+ }
262
+ /** Who to ask when nothing can be bought: the app's team, by the name the page's title ends with. */
263
+ export function teamOf(title) {
264
+ const app = title.split(" · ").pop()?.trim();
265
+ return app ? `the ${app} team` : "the team";
266
+ }
267
+ /** A contract's id, from its title: "Acme & Co" is `acme-co`. */
268
+ export const slug = (title) => title.toLowerCase().normalize("NFKD").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
269
+ /** A date field's day, as the moment it starts (UTC): when a waiver or a code ends. */
270
+ export const startOfDay = (day) => `${day}T00:00:00.000Z`;
271
+ /** Which low notice this is, so dismissing one says nothing of the other. */
272
+ export const noticeKey = (n) => `${n.tone}:${n.why}`;
273
+ /** What the note above the composer says. */
274
+ export function noticeSaid(n, personal = false) {
275
+ const org = personal ? "You're" : "This org is";
276
+ const has = personal ? "You've" : "This org has";
277
+ if (n.tone === "stop") {
278
+ return n.why === "budget"
279
+ ? {
280
+ title: `${has} reached the month's budget`,
281
+ ...(n.runs ? {} : { more: "Its owners and admins can raise it." }),
282
+ }
283
+ : {
284
+ title: `${org} out of credits`,
285
+ ...(n.runs ? {} : { more: "Its owners and admins can add more." }),
286
+ };
287
+ }
288
+ return n.why === "budget"
289
+ ? { title: `${money(n.left ?? 0)} left of this month's budget` }
290
+ : { title: `${money(n.left ?? 0)} of credits left` };
291
+ }