Billing on Stripe's pages, month-end charges, warnings; sudo by workspace
Cards, invoices, receipts and billing details live on Stripe's hosted billing page (the customer portal), opened from Billing; g1t never sees a card number. Staff send a customer the same page from sudo: a one-time link and the page's permanent sign-in, logged. Billing now charges the card on file when a month closes, for what was owed, and autopay near the limit charges what is owed rather than the cost of free usage. Unpaid charges carry into the next month's limit, so a new month is no fresh allowance. Owners are emailed at 50, 80 and 100% of their limit and when a card is declined, through identity's new notify_owners. sudo is organised around workspaces: name, owners, members and created first, then what each is billed and to whom; enterprises have their own pages, and internal billing ids no longer show. Identity has staff-only admin_workspaces and admin_workspace for it. The app's two top bars are 56px instead of 48.
| 158 | 158 | again, g1t rebuilds each one from the commit it was serving, by itself. | |
| 159 | 159 | ||
| 160 | 160 | What counts is this month's usage (UTC), each item at what it cost g1t or | |
| 161 | − | what it is charged, whichever is more, less what was paid this month. Even | |
| 161 | + | what it is charged, whichever is more, less what was paid this month, plus | |
| 162 | + | any charges left unpaid from earlier months: a new month is not a fresh | |
| 163 | + | allowance. Even | |
| 162 | 164 | usage that is free to you, such as the free minutes, counts at its cost: | |
| 163 | 165 | the limit is about what g1t has spent on a workspace's behalf. | |
| 164 | 166 | ||
| 205 | 207 | Each change is made by g1t staff in g1t's billing console and recorded with | |
| 206 | 208 | who made it and why. To ask for one, write to support. | |
| 207 | 209 | ||
| 210 | + | ## Your card, invoices and billing details | |
| 211 | + | ||
| 212 | + | These live on **Stripe's billing page**, not on g1t: g1t never sees or | |
| 213 | + | stores card numbers. An owner opens it from **Settings → Billing → Card | |
| 214 | + | and invoices → Manage billing on Stripe** (or **Add a card on Stripe**), | |
| 215 | + | and there adds or replaces the card, downloads invoices and receipts, and | |
| 216 | + | sets the billing email, address and tax ID. Saving a card charges | |
| 217 | + | nothing. g1t support never takes card details by phone or email; if you | |
| 218 | + | need help, we send you a link to that same Stripe page. | |
| 219 | + | ||
| 220 | + | With a card on file: | |
| 221 | + | ||
| 222 | + | - **Near the usage limit** (80%), g1t charges it for what the workspace | |
| 223 | + | owes, at least $5, so work does not stop. | |
| 224 | + | - **When each month closes**, g1t charges it for what the workspace owed | |
| 225 | + | at the end of the month, and the statement shows the payment as *Usage | |
| 226 | + | for 2026-10, charged to the card on file when the month closed*. | |
| 227 | + | - **If it is declined**, work stops until the workspace pays, and the | |
| 228 | + | Billing page and the API say why. Replace the card or add credit to pay. | |
| 229 | + | ||
| 230 | + | Comped workspaces are never charged, and an enterprise's workspaces are | |
| 231 | + | billed through the enterprise. Test-mode cards are never charged | |
| 232 | + | automatically. | |
| 233 | + | ||
| 208 | 234 | ## Add credit | |
| 209 | 235 | ||
| 210 | 236 | Credit is a payment in advance: it pays for usage as it happens, and lowers |
| 1 | 1 | # sudo | |
| 2 | 2 | ||
| 3 | − | g1t's staff console, at <https://sudo.g1t.sh>. Staff use it to manage how | |
| 4 | − | accounts pay: comp a workspace, set custom terms (a discount, a ceiling on | |
| 5 | − | unpaid usage, an end date), create Enterprise accounts that pay for several | |
| 6 | − | workspaces, move workspaces on and off them, issue credits, and see where | |
| 7 | − | every account stands this month with its ledger and audit log. | |
| 3 | + | g1t's staff console, at <https://sudo.g1t.sh>. It is organised the way | |
| 4 | + | customers know g1t: by **workspace**. | |
| 5 | + | ||
| 6 | + | - **Workspaces** (the home page): every workspace, with its owners, members, | |
| 7 | + | who it is billed to, its terms, this month's usage against its limit, | |
| 8 | + | what it was charged and what it cost g1t. Search by workspace, owner, | |
| 9 | + | email or enterprise; filter to stopped or warning, comped or custom, or | |
| 10 | + | on an enterprise. A workspace's page shows its members, and under | |
| 11 | + | **Billing** its terms, who it is billed to (move it onto or off an | |
| 12 | + | enterprise), a credit form, a Stripe billing link, its ledger and its | |
| 13 | + | audit log. | |
| 14 | + | - **Enterprises**: customers that pay for several workspaces with one | |
| 15 | + | bill, one limit and one set of terms. Each has its workspaces (add or | |
| 16 | + | remove them), combined usage, terms, credits, ledger and audit log. | |
| 17 | + | ||
| 18 | + | Billing's internal account ids (`ws_<slug>` for a workspace's own, | |
| 19 | + | `ent_…` for an enterprise) are never shown as names; an enterprise's id | |
| 20 | + | appears only as small "Billing account id" text. Old `/accounts/…` links | |
| 21 | + | redirect to the workspace or enterprise they meant. | |
| 22 | + | ||
| 23 | + | **Cards stay on Stripe.** sudo never shows a card field. To help a customer | |
| 24 | + | update their card or see invoices, staff make a Stripe billing link on the | |
| 25 | + | workspace's page (it is recorded) and send it to the owner. | |
| 8 | 26 | ||
| 9 | − | It holds no data. Everything goes to the billing service's staff methods | |
| 10 | − | (`admin_*`, see `BillingAdminApi` in `packages/contracts/src/billing.ts`), | |
| 11 | − | and each change is recorded there with the staff member's email. | |
| 27 | + | It holds no data. Workspaces, owners and members come from identity's | |
| 28 | + | staff methods (`admin_workspaces`, `admin_workspace`; `IdentityAdminApi` in | |
| 29 | + | `packages/contracts/src/identity.ts`); everything about money goes to the | |
| 30 | + | billing service's (`admin_*`, `BillingAdminApi` in | |
| 31 | + | `packages/contracts/src/billing.ts`), where each change is recorded with | |
| 32 | + | the staff member's email. Both are reached over service bindings only, and | |
| 33 | + | nothing but sudo binds to them. | |
| 12 | 34 | ||
| 13 | 35 | ## How it is locked | |
| 14 | 36 | ||
| 22 | 44 | `STAFF_EMAILS` are all set, every request gets a 403 saying sudo is not | |
| 23 | 45 | configured. | |
| 24 | 46 | 4. **Changes** are POSTs only, and only from sudo's own pages (`Origin`, or | |
| 25 | − | `Referer`, must be `https://sudo.g1t.sh`). Terms, enterprise moves and new | |
| 26 | − | enterprises show a confirmation step first; a credit needs the workspace's | |
| 27 | − | slug typed out. | |
| 47 | + | `Referer`, must be `https://sudo.g1t.sh`). Terms, enterprise moves, new | |
| 48 | + | enterprises and Stripe billing links show a confirmation step first; a | |
| 49 | + | credit needs the workspace's slug typed out. | |
| 28 | 50 | 5. **The pages ship no JavaScript.** The content security policy forbids | |
| 29 | 51 | every script and inline style; responses are `no-store`, `noindex` and | |
| 30 | 52 | cannot be framed. The worker has no `workers.dev` address or preview URLs. | |
| 54 | 76 | } | |
| 55 | 77 | ``` | |
| 56 | 78 | ||
| 57 | − | 6. Deploy: `scripts/deploy.sh sudo` (after `billing`, whose `admin_*` | |
| 58 | − | methods it calls). | |
| 79 | + | 6. Deploy: `scripts/deploy.sh sudo` (after `billing` and `identity`, whose | |
| 80 | + | `admin_*` methods it calls). | |
| 59 | 81 | ||
| 60 | − | Visit <https://sudo.g1t.sh>: Access asks you to sign in, then the accounts | |
| 82 | + | Visit <https://sudo.g1t.sh>: Access asks you to sign in, then the workspaces | |
| 61 | 83 | list opens. Anyone else gets Access's own refusal; anyone Access lets in who | |
| 62 | 84 | is not in `STAFF_EMAILS` gets a 403 from the worker. | |
| 63 | 85 | ||
| 73 | 95 | ||
| 74 | 96 | ```sh | |
| 75 | 97 | npm run typecheck -w @g1t/sudo | |
| 76 | − | npm test -w @g1t/sudo # JWT verification, forms, money | |
| 98 | + | npm test -w @g1t/sudo # JWT verification, forms, money, the workspace join | |
| 77 | 99 | npm run build -w @g1t/sudo | |
| 78 | 100 | ``` | |
| 79 | 101 |
| 1 | + | /** | |
| 2 | + | * The billing sections shared by a workspace's page and an enterprise's: | |
| 3 | + | * confirmations, terms, credit, the Stripe billing link, the ledger and the | |
| 4 | + | * audit log. Plain forms; sudo ships no JavaScript. | |
| 5 | + | */ | |
| 6 | + | import { CreditCard, Gift, ScrollText, UserRound } from "lucide-react"; | |
| 7 | + | import type { ReactNode } from "react"; | |
| 8 | + | import { Link } from "react-router"; | |
| 9 | + | ||
| 10 | + | import type { AdminAction, AdminOwner, BillingLink, LedgerEntry, Terms } from "@g1t/contracts"; | |
| 11 | + | ||
| 12 | + | import { Avatar, Badge, Button, EmptyState, Field, Input, Notice, Section, Select, Textarea, When } from "~/components/ui"; | |
| 13 | + | import { dollarsField, usd } from "~/lib/money"; | |
| 14 | + | import type { Review, SectionError } from "~/lib/review"; | |
| 15 | + | ||
| 16 | + | export function Hidden({ values }: { values: Record<string, string> }) { | |
| 17 | + | return ( | |
| 18 | + | <> | |
| 19 | + | {Object.entries(values).map(([name, value]) => ( | |
| 20 | + | <input key={name} type="hidden" name={name} value={value} /> | |
| 21 | + | ))} | |
| 22 | + | </> | |
| 23 | + | ); | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | export function Figure({ label, value, hint }: { label: string; value: string; hint?: ReactNode }) { | |
| 27 | + | return ( | |
| 28 | + | <div className="rounded-lg border border-line bg-surface px-4 py-3"> | |
| 29 | + | <p className="text-xs text-muted">{label}</p> | |
| 30 | + | <p className="tabular mt-1 text-lg font-semibold tracking-tight">{value}</p> | |
| 31 | + | {hint && <p className="mt-0.5 text-xs text-faint">{hint}</p>} | |
| 32 | + | </div> | |
| 33 | + | ); | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | /** Owners' usernames, each with their email beneath (and on hover). */ | |
| 37 | + | export function Owners({ owners, compact = false }: { owners: AdminOwner[]; compact?: boolean }) { | |
| 38 | + | if (owners.length === 0) return <span className="text-faint">No owner</span>; | |
| 39 | + | if (compact) { | |
| 40 | + | return ( | |
| 41 | + | <span className="flex flex-col gap-0.5"> | |
| 42 | + | {owners.map((owner) => ( | |
| 43 | + | <span key={owner.username} title={owner.email ?? "No email"} className="min-w-0 truncate"> | |
| 44 | + | <span className="font-mono text-fg-soft">{owner.username}</span> | |
| 45 | + | {owner.email && <span className="block truncate text-xs text-faint">{owner.email}</span>} | |
| 46 | + | </span> | |
| 47 | + | ))} | |
| 48 | + | </span> | |
| 49 | + | ); | |
| 50 | + | } | |
| 51 | + | return ( | |
| 52 | + | <ul className="flex flex-wrap gap-2"> | |
| 53 | + | {owners.map((owner) => ( | |
| 54 | + | <li key={owner.username} title={owner.email ?? "No email"} className="inline-flex items-center gap-1.5 rounded-md border border-line bg-bg px-2 py-1 text-xs"> | |
| 55 | + | <Avatar name={owner.username} size={14} square={false} /> | |
| 56 | + | <span className="font-mono">{owner.username}</span> | |
| 57 | + | {owner.email && <span className="text-faint">{owner.email}</span>} | |
| 58 | + | </li> | |
| 59 | + | ))} | |
| 60 | + | </ul> | |
| 61 | + | ); | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | // --- Confirmation ------------------------------------------------------------ | |
| 65 | + | ||
| 66 | + | function describeTerms(terms: Terms): [string, string][] { | |
| 67 | + | return [ | |
| 68 | + | ["Terms", terms.kind === "custom" ? "Custom" : terms.kind === "comped" ? "Comped" : "Standard"], | |
| 69 | + | ["Discount", terms.kind === "custom" ? `${terms.discountPercent}%` : "—"], | |
| 70 | + | ["Limit", terms.ceilingMicros == null ? "By trust" : usd(terms.ceilingMicros)], | |
| 71 | + | ["Until", terms.until ? terms.until.slice(0, 10) : "No end"], | |
| 72 | + | ["Note", terms.note || "—"], | |
| 73 | + | ]; | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | export function ReviewPanel({ review, pathname }: { review: Review; pathname: string }) { | |
| 77 | + | let title: string; | |
| 78 | + | let body: ReactNode; | |
| 79 | + | let danger = false; | |
| 80 | + | let confirm = "Confirm"; | |
| 81 | + | if (review.intent === "terms") { | |
| 82 | + | const before = describeTerms(review.before); | |
| 83 | + | const after = describeTerms(review.after); | |
| 84 | + | title = "Confirm the new terms"; | |
| 85 | + | danger = review.after.kind === "comped"; | |
| 86 | + | body = ( | |
| 87 | + | <> | |
| 88 | + | <div className="overflow-x-auto"> | |
| 89 | + | <table className="w-full text-sm"> | |
| 90 | + | <thead> | |
| 91 | + | <tr className="text-left text-xs text-muted"> | |
| 92 | + | <th className="py-1.5 pr-4 font-medium" /> | |
| 93 | + | <th className="py-1.5 pr-4 font-medium">Now</th> | |
| 94 | + | <th className="py-1.5 font-medium">After</th> | |
| 95 | + | </tr> | |
| 96 | + | </thead> | |
| 97 | + | <tbody> | |
| 98 | + | {after.map(([label, value], index) => ( | |
| 99 | + | <tr key={label} className="border-t border-line align-top"> | |
| 100 | + | <td className="py-1.5 pr-4 text-muted">{label}</td> | |
| 101 | + | <td className="py-1.5 pr-4 break-words text-faint">{before[index][1]}</td> | |
| 102 | + | <td className={`py-1.5 break-words ${value !== before[index][1] ? "font-medium text-fg" : "text-muted"}`}>{value}</td> | |
| 103 | + | </tr> | |
| 104 | + | ))} | |
| 105 | + | </tbody> | |
| 106 | + | </table> | |
| 107 | + | </div> | |
| 108 | + | {review.after.kind === "comped" && ( | |
| 109 | + | <p className="mt-3 text-sm text-warn"> | |
| 110 | + | Comped: nothing will be charged{review.after.until ? ` until ${review.after.until.slice(0, 10)}` : ""}. Usage is still recorded | |
| 111 | + | at cost. | |
| 112 | + | </p> | |
| 113 | + | )} | |
| 114 | + | </> | |
| 115 | + | ); | |
| 116 | + | } else if (review.intent === "attach") { | |
| 117 | + | title = `Bill ${review.workspace} to ${review.targetName}?`; | |
| 118 | + | body = ( | |
| 119 | + | <p className="text-sm text-muted"> | |
| 120 | + | From now on <span className="font-mono text-fg">{review.workspace}</span>'s usage is billed to{" "} | |
| 121 | + | <span className="text-fg">{review.targetName}</span> and counts against its limit and terms, not its own. | |
| 122 | + | </p> | |
| 123 | + | ); | |
| 124 | + | } else if (review.intent === "detach") { | |
| 125 | + | title = `Move ${review.workspace} off ${review.from}?`; | |
| 126 | + | danger = true; | |
| 127 | + | body = ( | |
| 128 | + | <p className="text-sm text-muted"> | |
| 129 | + | <span className="font-mono text-fg">{review.workspace}</span> goes back to paying for itself, on its own terms and the limit | |
| 130 | + | its trust gives it. It may stop at once if its own limit is lower than what it owes. | |
| 131 | + | </p> | |
| 132 | + | ); | |
| 133 | + | } else { | |
| 134 | + | title = `Make a Stripe billing link for ${review.workspace}?`; | |
| 135 | + | confirm = "Make the link"; | |
| 136 | + | body = ( | |
| 137 | + | <p className="text-sm text-muted"> | |
| 138 | + | Opens a one-time session on Stripe's billing page for <span className="font-mono text-fg">{review.workspace}</span>'s | |
| 139 | + | customer, where they update their card and see their invoices. Anyone with the link can use it until it expires, so send it | |
| 140 | + | only to the workspace's owner. Making it is recorded with your email. | |
| 141 | + | </p> | |
| 142 | + | ); | |
| 143 | + | } | |
| 144 | + | return ( | |
| 145 | + | <section id="review" className={`scroll-mt-20 rounded-lg border p-4 sm:p-5 ${danger ? "border-warn/40 bg-warn/5" : "border-merged/40 bg-merged/5"}`}> | |
| 146 | + | <h2 className="font-semibold tracking-tight">{title}</h2> | |
| 147 | + | <div className="mt-3">{body}</div> | |
| 148 | + | <form method="post" action={`${pathname}#${review.intent === "billing-link" ? "billing-link" : "top"}`} className="mt-4 flex flex-wrap items-center gap-2"> | |
| 149 | + | <Hidden values={review.fields} /> | |
| 150 | + | <input type="hidden" name="intent" value={review.intent} /> | |
| 151 | + | <input type="hidden" name="confirm" value="yes" /> | |
| 152 | + | <Button type="submit" variant={danger ? "danger" : "lavender"}> | |
| 153 | + | {confirm} | |
| 154 | + | </Button> | |
| 155 | + | <Link to={pathname} className="px-2 text-sm text-muted hover:text-fg"> | |
| 156 | + | Cancel | |
| 157 | + | </Link> | |
| 158 | + | </form> | |
| 159 | + | </section> | |
| 160 | + | ); | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | // --- Terms ------------------------------------------------------------------- | |
| 164 | + | ||
| 165 | + | const KINDS: { value: Terms["kind"]; title: string; text: string }[] = [ | |
| 166 | + | { value: "standard", title: "Standard", text: "Published prices; the limit comes from trust." }, | |
| 167 | + | { value: "comped", title: "Comped", text: "Nothing charged. Usage still recorded at cost." }, | |
| 168 | + | { value: "custom", title: "Custom", text: "A discount, a custom limit, or both." }, | |
| 169 | + | ]; | |
| 170 | + | ||
| 171 | + | export function TermsForm({ terms, pathname, error }: { terms: Terms; pathname: string; error: SectionError }) { | |
| 172 | + | const values = error?.values; | |
| 173 | + | const kind = values?.kind ?? terms.kind; | |
| 174 | + | return ( | |
| 175 | + | <Section | |
| 176 | + | id="terms" | |
| 177 | + | title="Terms" | |
| 178 | + | description={ | |
| 179 | + | terms.setBy ? ( | |
| 180 | + | <> | |
| 181 | + | Set by <span className="font-mono">{terms.setBy}</span> on <When at={terms.setAt} /> | |
| 182 | + | {terms.note && <> · “{terms.note}”</>} | |
| 183 | + | </> | |
| 184 | + | ) : ( | |
| 185 | + | "Standard, as everyone starts." | |
| 186 | + | ) | |
| 187 | + | } | |
| 188 | + | > | |
| 189 | + | <form method="post" action={`${pathname}#review`} className="space-y-4"> | |
| 190 | + | <input type="hidden" name="intent" value="terms" /> | |
| 191 | + | {error && <Notice tone="error">{error.error}</Notice>} | |
| 192 | + | <fieldset> | |
| 193 | + | <legend className="mb-1.5 text-sm font-medium text-muted">Kind</legend> | |
| 194 | + | <div className="grid gap-2 sm:grid-cols-3"> | |
| 195 | + | {KINDS.map((option) => ( | |
| 196 | + | <label | |
| 197 | + | key={option.value} | |
| 198 | + | className="flex cursor-pointer gap-2.5 rounded-md border border-line bg-bg p-3 transition-colors hover:border-line-strong has-checked:border-merged/60 has-checked:bg-merged/8" | |
| 199 | + | > | |
| 200 | + | <input type="radio" name="kind" value={option.value} defaultChecked={kind === option.value} className="mt-0.5" required /> | |
| 201 | + | <span> | |
| 202 | + | <span className="block text-sm font-medium">{option.title}</span> | |
| 203 | + | <span className="mt-0.5 block text-xs text-muted">{option.text}</span> | |
| 204 | + | </span> | |
| 205 | + | </label> | |
| 206 | + | ))} | |
| 207 | + | </div> | |
| 208 | + | </fieldset> | |
| 209 | + | <div className="grid gap-4 sm:grid-cols-3"> | |
| 210 | + | <Field label="Discount %" hint="Custom only."> | |
| 211 | + | <Input name="discount" inputMode="numeric" pattern="\d{1,3}" placeholder="0" defaultValue={values?.discount ?? (terms.discountPercent ? String(terms.discountPercent) : "")} /> | |
| 212 | + | </Field> | |
| 213 | + | <Field label="Limit $" hint="Unpaid usage allowed. Blank: trust decides."> | |
| 214 | + | <Input name="ceiling" inputMode="decimal" placeholder="By trust" defaultValue={values?.ceiling ?? dollarsField(terms.ceilingMicros)} /> | |
| 215 | + | </Field> | |
| 216 | + | <Field label="Until" hint="Blank: no end. UTC."> | |
| 217 | + | <Input type="date" name="until" defaultValue={values?.until ?? (terms.until ? terms.until.slice(0, 10) : "")} /> | |
| 218 | + | </Field> | |
| 219 | + | </div> | |
| 220 | + | <Field label="Note" hint="Required. Why, for whoever looks next."> | |
| 221 | + | <Textarea name="note" rows={2} required maxLength={500} defaultValue={values?.note ?? ""} placeholder="e.g. Design partner through launch" /> | |
| 222 | + | </Field> | |
| 223 | + | <div className="flex justify-end"> | |
| 224 | + | <Button type="submit">Review terms</Button> | |
| 225 | + | </div> | |
| 226 | + | </form> | |
| 227 | + | </Section> | |
| 228 | + | ); | |
| 229 | + | } | |
| 230 | + | ||
| 231 | + | // --- Credit ------------------------------------------------------------------- | |
| 232 | + | ||
| 233 | + | export function CreditForm({ workspaces, pathname, error }: { workspaces: string[]; pathname: string; error: SectionError }) { | |
| 234 | + | const values = error?.values; | |
| 235 | + | const single = workspaces.length === 1 ? workspaces[0] : null; | |
| 236 | + | return ( | |
| 237 | + | <Section id="credit" title="Issue credit" description="A refund or goodwill. Added to the workspace's balance at once."> | |
| 238 | + | {workspaces.length === 0 ? ( | |
| 239 | + | <p className="text-sm text-muted">Add a workspace first: credit goes to a workspace.</p> | |
| 240 | + | ) : ( | |
| 241 | + | <form method="post" action={`${pathname}#credit`} className="space-y-4"> | |
| 242 | + | <input type="hidden" name="intent" value="credit" /> | |
| 243 | + | {error && <Notice tone="error">{error.error}</Notice>} | |
| 244 | + | {single ? ( | |
| 245 | + | <input type="hidden" name="workspace" value={single} /> | |
| 246 | + | ) : ( | |
| 247 | + | <Field label="Workspace"> | |
| 248 | + | <Select name="workspace" required defaultValue={values?.workspace ?? ""}> | |
| 249 | + | <option value="" disabled> | |
| 250 | + | Choose a workspace | |
| 251 | + | </option> | |
| 252 | + | {workspaces.map((slug) => ( | |
| 253 | + | <option key={slug} value={slug}> | |
| 254 | + | {slug} | |
| 255 | + | </option> | |
| 256 | + | ))} | |
| 257 | + | </Select> | |
| 258 | + | </Field> | |
| 259 | + | )} | |
| 260 | + | <Field label="Amount $" hint="Up to $10,000 at a time."> | |
| 261 | + | <Input name="amount" inputMode="decimal" required placeholder="25.00" defaultValue={values?.amount ?? ""} /> | |
| 262 | + | </Field> | |
| 263 | + | <Field label="Note" hint="Required. Shown on the workspace's statement."> | |
| 264 | + | <Textarea name="note" rows={2} required maxLength={500} placeholder="e.g. Refund for the failed runs on Oct 2" defaultValue={values?.note ?? ""} /> | |
| 265 | + | </Field> | |
| 266 | + | <Field | |
| 267 | + | label="Confirm" | |
| 268 | + | hint={ | |
| 269 | + | <> | |
| 270 | + | Type the workspace's slug{single && <> (<span className="font-mono text-muted">{single}</span>)</>} to issue it. | |
| 271 | + | </> | |
| 272 | + | } | |
| 273 | + | > | |
| 274 | + | <Input name="confirmation" required placeholder={single ?? "workspace-slug"} className="font-mono" /> | |
| 275 | + | </Field> | |
| 276 | + | <div className="flex justify-end"> | |
| 277 | + | <Button type="submit" variant="lavender"> | |
| 278 | + | <Gift size={14} /> | |
| 279 | + | Issue credit | |
| 280 | + | </Button> | |
| 281 | + | </div> | |
| 282 | + | </form> | |
| 283 | + | )} | |
| 284 | + | </Section> | |
| 285 | + | ); | |
| 286 | + | } | |
| 287 | + | ||
| 288 | + | // --- Stripe billing link ----------------------------------------------------- | |
| 289 | + | ||
| 290 | + | /** | |
| 291 | + | * Card details, invoices and receipts live on Stripe's own billing page. | |
| 292 | + | * sudo never shows a card field: staff send the customer a link instead. | |
| 293 | + | */ | |
| 294 | + | export function BillingLinkSection({ | |
| 295 | + | link, | |
| 296 | + | pathname, | |
| 297 | + | error, | |
| 298 | + | }: { | |
| 299 | + | link: BillingLink | null; | |
| 300 | + | pathname: string; | |
| 301 | + | error: SectionError; | |
| 302 | + | }) { | |
| 303 | + | return ( | |
| 304 | + | <Section | |
| 305 | + | id="billing-link" | |
| 306 | + | title="Stripe billing page" | |
| 307 | + | description="Where the customer updates their card and sees invoices. g1t never takes card numbers." | |
| 308 | + | > | |
| 309 | + | {error && ( | |
| 310 | + | <div className="mb-4"> | |
| 311 | + | <Notice tone="error">{error.error}</Notice> | |
| 312 | + | </div> | |
| 313 | + | )} | |
| 314 | + | {link ? ( | |
| 315 | + | <div className="space-y-4"> | |
| 316 | + | <Notice tone="info">Send this to the customer. g1t never takes card numbers; they enter them on Stripe.</Notice> | |
| 317 | + | <Field label="One-time link" hint={link.expiresNote}> | |
| 318 | + | <Input readOnly value={link.portalUrl} className="font-mono text-xs" aria-label="One-time Stripe billing link" /> | |
| 319 | + | </Field> | |
| 320 | + | {link.loginUrl && ( | |
| 321 | + | <Field label="Permanent sign-in page" hint={`The customer signs in with ${link.customerEmail ?? "the email Stripe has for them"}.`}> | |
| 322 | + | <Input readOnly value={link.loginUrl} className="font-mono text-xs" aria-label="Stripe billing sign-in page" /> | |
| 323 | + | </Field> | |
| 324 | + | )} | |
| 325 | + | {!link.loginUrl && link.customerEmail && <p className="text-xs text-faint">Stripe has {link.customerEmail} for this customer.</p>} | |
| 326 | + | </div> | |
| 327 | + | ) : ( | |
| 328 | + | <form method="post" action={`${pathname}#review`} className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> | |
| 329 | + | <input type="hidden" name="intent" value="billing-link" /> | |
| 330 | + | <p className="text-sm text-muted"> | |
| 331 | + | Never take card details over the phone or by email. Make a link to Stripe's billing page and send it to the owner. | |
| 332 | + | </p> | |
| 333 | + | <Button type="submit" variant="quiet" className="shrink-0"> | |
| 334 | + | <CreditCard size={14} /> | |
| 335 | + | Make a link | |
| 336 | + | </Button> | |
| 337 | + | </form> | |
| 338 | + | )} | |
| 339 | + | </Section> | |
| 340 | + | ); | |
| 341 | + | } | |
| 342 | + | ||
| 343 | + | // --- Ledger and audit --------------------------------------------------------- | |
| 344 | + | ||
| 345 | + | const ENTRY_KIND: Record<string, string> = { top_up: "Top-up", usage: "Usage", credit: "Credit" }; | |
| 346 | + | ||
| 347 | + | export function LedgerSection({ | |
| 348 | + | ledger, | |
| 349 | + | showWorkspace = false, | |
| 350 | + | description = "Recent lines of the statement, newest first.", | |
| 351 | + | }: { | |
| 352 | + | ledger: LedgerEntry[]; | |
| 353 | + | showWorkspace?: boolean; | |
| 354 | + | description?: ReactNode; | |
| 355 | + | }) { | |
| 356 | + | return ( | |
| 357 | + | <Section title="Ledger" description={description}> | |
| 358 | + | {ledger.length === 0 ? ( | |
| 359 | + | <EmptyState title="Nothing yet" /> | |
| 360 | + | ) : ( | |
| 361 | + | <div className="-mx-4 -my-4 overflow-x-auto sm:-mx-5 sm:-my-5"> | |
| 362 | + | <table className="w-full min-w-[36rem] text-sm"> | |
| 363 | + | <thead> | |
| 364 | + | <tr className="border-b border-line text-left text-xs text-muted"> | |
| 365 | + | <th className="px-4 py-2 font-medium sm:pl-5">When</th> | |
| 366 | + | <th className="px-4 py-2 font-medium">What</th> | |
| 367 | + | <th className="px-4 py-2 text-right font-medium sm:pr-5">Amount</th> | |
| 368 | + | </tr> | |
| 369 | + | </thead> | |
| 370 | + | <tbody> | |
| 371 | + | {ledger.map((entry) => ( | |
| 372 | + | <tr key={entry.id} className="border-b border-line align-top last:border-0"> | |
| 373 | + | <td className="px-4 py-2.5 text-xs whitespace-nowrap text-muted sm:pl-5"> | |
| 374 | + | <When at={entry.createdAt} time /> | |
| 375 | + | </td> | |
| 376 | + | <td className="px-4 py-2.5"> | |
| 377 | + | <div className="flex flex-wrap items-center gap-1.5"> | |
| 378 | + | <Badge tone={entry.amountMicros > 0 ? "mint" : "plain"}>{ENTRY_KIND[entry.kind] ?? entry.kind}</Badge> | |
| 379 | + | {showWorkspace && entry.workspace && ( | |
| 380 | + | <Link to={`/workspaces/${encodeURIComponent(entry.workspace)}`} className="font-mono text-xs text-merged hover:underline"> | |
| 381 | + | {entry.workspace} | |
| 382 | + | </Link> | |
| 383 | + | )} | |
| 384 | + | <span className="break-words">{entry.description}</span> | |
| 385 | + | </div> | |
| 386 | + | <p className="mt-0.5 font-mono text-xs text-faint"> | |
| 387 | + | {[ | |
| 388 | + | entry.repo && (entry.number != null ? `${entry.repo}#${entry.number}` : entry.repo), | |
| 389 | + | entry.task, | |
| 390 | + | entry.model, | |
| 391 | + | entry.billedTo === "workspace" ? "own provider" : null, | |
| 392 | + | entry.createdBy && `by ${entry.createdBy}`, | |
| 393 | + | ] | |
| 394 | + | .filter(Boolean) | |
| 395 | + | .join(" · ")} | |
| 396 | + | </p> | |
| 397 | + | </td> | |
| 398 | + | <td className={`tabular px-4 py-2.5 text-right whitespace-nowrap sm:pr-5 ${entry.amountMicros > 0 ? "text-accent" : "text-fg-soft"}`}> | |
| 399 | + | {usd(entry.amountMicros, { signed: true })} | |
| 400 | + | </td> | |
| 401 | + | </tr> | |
| 402 | + | ))} | |
| 403 | + | </tbody> | |
| 404 | + | </table> | |
| 405 | + | </div> | |
| 406 | + | )} | |
| 407 | + | </Section> | |
| 408 | + | ); | |
| 409 | + | } | |
| 410 | + | ||
| 411 | + | const ACTION: Record<string, string> = { | |
| 412 | + | terms: "Terms changed", | |
| 413 | + | create: "Enterprise created", | |
| 414 | + | attach: "Workspace added", | |
| 415 | + | detach: "Workspace removed", | |
| 416 | + | credit: "Credit issued", | |
| 417 | + | billing_link: "Billing link made", | |
| 418 | + | }; | |
| 419 | + | ||
| 420 | + | export function AuditSection({ audit, description = "Every change made in sudo, and by whom." }: { audit: AdminAction[]; description?: ReactNode }) { | |
| 421 | + | return ( | |
| 422 | + | <Section title="Audit log" description={description}> | |
| 423 | + | {audit.length === 0 ? ( | |
| 424 | + | <p className="flex items-center gap-2 text-sm text-muted"> | |
| 425 | + | <ScrollText size={14} /> | |
| 426 | + | No changes yet. | |
| 427 | + | </p> | |
| 428 | + | ) : ( | |
| 429 | + | <ol className="space-y-3"> | |
| 430 | + | {audit.map((entry) => ( | |
| 431 | + | <li key={entry.id} className="border-l-2 border-merged/40 pl-3"> | |
| 432 | + | <p className="flex flex-wrap items-center gap-x-2 text-sm"> | |
| 433 | + | <span className="font-medium text-merged">{ACTION[entry.action] ?? entry.action}</span> | |
| 434 | + | <span className="text-xs text-faint"> | |
| 435 | + | <When at={entry.createdAt} time /> | |
| 436 | + | </span> | |
| 437 | + | </p> | |
| 438 | + | {entry.detail && <p className="mt-0.5 text-sm break-words text-fg-soft">{entry.detail}</p>} | |
| 439 | + | <p className="mt-0.5 flex items-center gap-1 font-mono text-xs text-faint"> | |
| 440 | + | <UserRound size={11} /> | |
| 441 | + | {entry.by} | |
| 442 | + | </p> | |
| 443 | + | </li> | |
| 444 | + | ))} | |
| 445 | + | </ol> | |
| 446 | + | )} | |
| 447 | + | </Section> | |
| 448 | + | ); | |
| 449 | + | } |
| 7 | 7 | import type { ComponentProps, ReactNode } from "react"; | |
| 8 | 8 | import { Link, type LinkProps } from "react-router"; | |
| 9 | 9 | ||
| 10 | − | import type { Limit, PayingAccount, Terms, Trust } from "@g1t/contracts"; | |
| 10 | + | import type { Limit, Terms, Trust } from "@g1t/contracts"; | |
| 11 | 11 | ||
| 12 | 12 | import { usd } from "~/lib/money"; | |
| 13 | 13 | ||
| 148 | 148 | ); | |
| 149 | 149 | } | |
| 150 | 150 | ||
| 151 | − | export function KindBadge({ kind }: { kind: PayingAccount["kind"] }) { | |
| 152 | − | return kind === "enterprise" ? <Badge tone="lavender">Enterprise</Badge> : <Badge>Workspace</Badge>; | |
| 153 | − | } | |
| 154 | − | ||
| 155 | 151 | export function TermsBadge({ terms }: { terms: Terms }) { | |
| 156 | 152 | if (terms.kind === "comped") return <Badge tone="mint">Comped</Badge>; | |
| 157 | 153 | if (terms.kind === "custom") { | |
| 185 | 181 | } | |
| 186 | 182 | ||
| 187 | 183 | /** | |
| 188 | − | * Unpaid usage this month against the ceiling, as a bar coloured by where | |
| 189 | − | * it stands. An account with no ceiling (g1t's own) shows the figure only. | |
| 184 | + | * Usage this month that is not paid for yet, against the limit, as a bar | |
| 185 | + | * coloured by where it stands. With no limit (comped) it shows the figure. | |
| 190 | 186 | */ | |
| 191 | 187 | export function ExposureBar({ limit, wide = false }: { limit: Limit; wide?: boolean }) { | |
| 192 | 188 | const { exposureMicros, ceilingMicros, state } = limit; | |
| 197 | 193 | <div className="flex items-baseline justify-between gap-2 text-xs"> | |
| 198 | 194 | <span className="tabular font-medium text-fg-soft"> | |
| 199 | 195 | {usd(exposureMicros)} | |
| 200 | − | <span className="font-normal text-faint"> / {ceilingMicros == null ? "no ceiling" : usd(ceilingMicros)}</span> | |
| 196 | + | <span className="font-normal text-faint"> / {ceilingMicros == null ? "no limit" : usd(ceilingMicros)}</span> | |
| 201 | 197 | </span> | |
| 202 | 198 | {percent != null && <span className={`tabular ${STATE[state].text}`}>{percent}%</span>} | |
| 203 | 199 | </div> | |
| 204 | − | <svg viewBox="0 0 100 4" preserveAspectRatio="none" className="mt-1.5 block h-1.5 w-full" role="img" aria-label={`${STATE[state].label}: ${percent ?? 0}% of the ceiling`}> | |
| 200 | + | <svg viewBox="0 0 100 4" preserveAspectRatio="none" className="mt-1.5 block h-1.5 w-full" role="img" aria-label={`${STATE[state].label}: ${percent ?? 0}% of the limit`}> | |
| 205 | 201 | <rect x="0" y="0" width="100" height="4" rx="2" fill="var(--g1t-raised)" /> | |
| 206 | 202 | {ceilingMicros != null && share > 0 && ( | |
| 207 | 203 | <rect x="0" y="0" width={Math.max(2, share * 100)} height="4" rx="2" fill={STATE[state].fill} /> |
| 1 | + | /** | |
| 2 | + | * The changes staff make to how a workspace or an enterprise pays: terms, | |
| 3 | + | * moving workspaces on and off enterprises, credits, and Stripe billing | |
| 4 | + | * links. What is acted on comes from the billing service and identity, not | |
| 5 | + | * from the form; each change shows a confirmation first, and a credit | |
| 6 | + | * needs the workspace's slug typed out. | |
| 7 | + | */ | |
| 8 | + | import type { Terms } from "@g1t/contracts"; | |
| 9 | + | import { data, redirect } from "react-router"; | |
| 10 | + | ||
| 11 | + | import { fields, parseCredit, parseNote, parseSlug, parseTerms, text } from "./forms"; | |
| 12 | + | import type { ActionData } from "./review"; | |
| 13 | + | import { admin, identity } from "./services.server"; | |
| 14 | + | import type { Staff } from "./staff"; | |
| 15 | + | import type { Enterprise } from "./workspaces"; | |
| 16 | + | ||
| 17 | + | /** What a page acts on. `accountId` is billing's internal id, never shown. */ | |
| 18 | + | export type Subject = | |
| 19 | + | | { kind: "workspace"; slug: string; accountId: string; terms: Terms; billedTo: Enterprise | null } | |
| 20 | + | | { kind: "enterprise"; accountId: string; name: string; terms: Terms; workspaces: string[] }; | |
| 21 | + | ||
| 22 | + | function failed(section: string, error: string, values?: Record<string, string>) { | |
| 23 | + | return data<ActionData>({ error, section, values }, { status: 422 }); | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | export async function billingAction(request: Request, staff: Staff, subject: Subject, path: string) { | |
| 27 | + | const form = await request.formData(); | |
| 28 | + | const intent = text(form, "intent"); | |
| 29 | + | const confirmed = text(form, "confirm") === "yes"; | |
| 30 | + | const back = (done: string) => redirect(`${path}?done=${done}#top`); | |
| 31 | + | const isEnterprise = subject.kind === "enterprise"; | |
| 32 | + | ||
| 33 | + | if (intent === "terms") { | |
| 34 | + | const values = fields(form, "kind", "discount", "ceiling", "note", "until"); | |
| 35 | + | if (subject.kind === "workspace" && subject.billedTo) { | |
| 36 | + | return failed("terms", `This workspace is charged on ${subject.billedTo.name}'s terms. Change them on the enterprise.`, values); | |
| 37 | + | } | |
| 38 | + | const terms = parseTerms(form, staff.email); | |
| 39 | + | if (!terms.ok) return failed("terms", terms.error, values); | |
| 40 | + | if (!confirmed) return { review: { intent, before: subject.terms, after: terms.value, fields: values } } satisfies ActionData; | |
| 41 | + | const result = await admin.setTerms(subject.accountId, terms.value, staff.email); | |
| 42 | + | if (!result.ok) return failed("terms", result.error.message, values); | |
| 43 | + | return back("terms"); | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | if (intent === "attach") { | |
| 47 | + | const values = fields(form, "workspace", "target"); | |
| 48 | + | const section = isEnterprise ? "members" : "billed-to"; | |
| 49 | + | let slug: string; | |
| 50 | + | let target: Enterprise; | |
| 51 | + | if (subject.kind === "enterprise") { | |
| 52 | + | const parsed = parseSlug(values.workspace); | |
| 53 | + | if (!parsed.ok) return failed(section, parsed.error, values); | |
| 54 | + | slug = parsed.value; | |
| 55 | + | if (subject.workspaces.includes(slug)) return failed(section, `${slug} is already on this enterprise.`, values); | |
| 56 | + | if (!(await identity.workspace(slug))) return failed(section, `There is no workspace called ${slug}.`, values); | |
| 57 | + | target = { id: subject.accountId, name: subject.name }; | |
| 58 | + | } else { | |
| 59 | + | slug = subject.slug; | |
| 60 | + | const enterprise = (await admin.accounts()).find((row) => row.account.kind === "enterprise" && row.account.id === values.target); | |
| 61 | + | if (!enterprise) return failed(section, "Choose an enterprise to move onto.", values); | |
| 62 | + | target = { id: enterprise.account.id, name: enterprise.account.name }; | |
| 63 | + | } | |
| 64 | + | if (!confirmed) return { review: { intent, workspace: slug, targetName: target.name, fields: values } } satisfies ActionData; | |
| 65 | + | const result = await admin.attach(slug, target.id, staff.email); | |
| 66 | + | if (!result.ok) return failed(section, result.error.message, values); | |
| 67 | + | return back("attach"); | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | if (intent === "detach") { | |
| 71 | + | const values = fields(form, "workspace"); | |
| 72 | + | const section = isEnterprise ? "members" : "billed-to"; | |
| 73 | + | let slug: string; | |
| 74 | + | let from: string; | |
| 75 | + | if (subject.kind === "enterprise") { | |
| 76 | + | slug = values.workspace; | |
| 77 | + | if (!subject.workspaces.includes(slug)) return failed(section, `${slug} is not on this enterprise.`); | |
| 78 | + | from = subject.name; | |
| 79 | + | } else { | |
| 80 | + | if (!subject.billedTo) return failed(section, `${subject.slug} already pays for itself.`); | |
| 81 | + | slug = subject.slug; | |
| 82 | + | from = subject.billedTo.name; | |
| 83 | + | } | |
| 84 | + | if (!confirmed) return { review: { intent, workspace: slug, from, fields: values } } satisfies ActionData; | |
| 85 | + | const result = await admin.attach(slug, null, staff.email); | |
| 86 | + | if (!result.ok) return failed(section, result.error.message); | |
| 87 | + | return back("detach"); | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | if (intent === "credit") { | |
| 91 | + | const values = fields(form, "workspace", "amount", "note", "confirmation"); | |
| 92 | + | const workspace = subject.kind === "enterprise" ? values.workspace : subject.slug; | |
| 93 | + | if (subject.kind === "enterprise" && !subject.workspaces.includes(workspace)) { | |
| 94 | + | return failed("credit", "Choose one of this enterprise's workspaces.", values); | |
| 95 | + | } | |
| 96 | + | const amount = parseCredit(values.amount); | |
| 97 | + | if (!amount.ok) return failed("credit", amount.error, values); | |
| 98 | + | const note = parseNote(values.note); | |
| 99 | + | if (!note.ok) return failed("credit", note.error, values); | |
| 100 | + | if (values.confirmation !== workspace) { | |
| 101 | + | return failed("credit", `Type the workspace's slug, ${workspace}, exactly, to issue the credit.`, { ...values, confirmation: "" }); | |
| 102 | + | } | |
| 103 | + | const result = await admin.credit(workspace, amount.value, note.value, staff.email); | |
| 104 | + | if (!result.ok) return failed("credit", result.error.message, values); | |
| 105 | + | return back("credit"); | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | if (intent === "billing-link") { | |
| 109 | + | if (subject.kind !== "workspace") return failed("top", "Billing links are made from a workspace's page."); | |
| 110 | + | if (!confirmed) return { review: { intent, workspace: subject.slug, fields: {} } } satisfies ActionData; | |
| 111 | + | const result = await admin.billingLink(subject.slug, staff.email); | |
| 112 | + | if (!result.ok) return failed("billing-link", result.error.message); | |
| 113 | + | // Shown once, in this response only: it is never stored or redirected to. | |
| 114 | + | return { link: result.value, workspace: subject.slug } satisfies ActionData; | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | return failed("top", "Unknown action."); | |
| 118 | + | } |
| 1 | + | /** | |
| 2 | + | * What a billing form posts back: an error for one section, a change to | |
| 3 | + | * confirm, or a Stripe billing link to hand on. Shared by the workspace and | |
| 4 | + | * enterprise pages. | |
| 5 | + | */ | |
| 6 | + | import type { BillingLink, Terms } from "@g1t/contracts"; | |
| 7 | + | ||
| 8 | + | export type Review = | |
| 9 | + | | { intent: "terms"; before: Terms; after: Terms; fields: Record<string, string> } | |
| 10 | + | | { intent: "attach"; workspace: string; targetName: string; fields: Record<string, string> } | |
| 11 | + | | { intent: "detach"; workspace: string; from: string; fields: Record<string, string> } | |
| 12 | + | | { intent: "billing-link"; workspace: string; fields: Record<string, string> }; | |
| 13 | + | ||
| 14 | + | export type ActionData = | |
| 15 | + | | { error: string; section: string; values?: Record<string, string> } | |
| 16 | + | | { review: Review } | |
| 17 | + | | { link: BillingLink; workspace: string }; | |
| 18 | + | ||
| 19 | + | export type SectionError = { error: string; values?: Record<string, string> } | null; | |
| 20 | + | ||
| 21 | + | /** The flash messages a change redirects back with (`?done=`). */ | |
| 22 | + | export const DONE: Record<string, string> = { | |
| 23 | + | terms: "Terms saved. They apply to charges from now on.", | |
| 24 | + | attach: "Workspace moved onto the enterprise.", | |
| 25 | + | detach: "Workspace moved off the enterprise. It pays for itself again.", | |
| 26 | + | credit: "Credit issued.", | |
| 27 | + | created: "Enterprise created.", | |
| 28 | + | }; | |
| 29 | + | ||
| 30 | + | export function doneMessage(url: string): string | null { | |
| 31 | + | const done = new URL(url).searchParams.get("done"); | |
| 32 | + | return done && DONE[done] ? DONE[done] : null; | |
| 33 | + | } |
| 1 | 1 | import { env } from "cloudflare:workers"; | |
| 2 | 2 | ||
| 3 | − | import { billingAdminClient } from "@g1t/contracts"; | |
| 3 | + | import { billingAdminClient, identityAdminClient } from "@g1t/contracts"; | |
| 4 | 4 | ||
| 5 | 5 | /** Staff-only billing, on the billing service. */ | |
| 6 | 6 | export const admin = billingAdminClient(env.BILLING); | |
| 7 | + | ||
| 8 | + | /** Staff-only identity: every workspace, its owners and members. */ | |
| 9 | + | export const identity = identityAdminClient(env.IDENTITY); |
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { test } from "node:test"; | |
| 3 | + | ||
| 4 | + | import type { AccountSummary, AdminWorkspace, Limit, Terms } from "@g1t/contracts"; | |
| 5 | + | ||
| 6 | + | import { STANDARD_TERMS, joinWorkspaces, legacyAccountPath, matchesQuery } from "./workspaces.ts"; | |
| 7 | + | ||
| 8 | + | function limit(workspace: string, account: string, exposureMicros = 0): Limit { | |
| 9 | + | return { | |
| 10 | + | workspace, | |
| 11 | + | account, | |
| 12 | + | accountName: account, | |
| 13 | + | trust: "new", | |
| 14 | + | exposureMicros, | |
| 15 | + | ceilingMicros: 10_000_000, | |
| 16 | + | trustCeilingMicros: 10_000_000, | |
| 17 | + | spendLimitMicros: null, | |
| 18 | + | state: "ok", | |
| 19 | + | message: null, | |
| 20 | + | }; | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | function workspace(slug: string, owner = `${slug}-owner`): AdminWorkspace { | |
| 24 | + | return { | |
| 25 | + | slug, | |
| 26 | + | name: slug.toUpperCase(), | |
| 27 | + | createdAt: "2026-10-01T00:00:00Z", | |
| 28 | + | owners: [{ username: owner, email: `${owner}@example.com` }], | |
| 29 | + | memberCount: 1, | |
| 30 | + | }; | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | const COMPED: Terms = { ...STANDARD_TERMS, kind: "comped", note: "g1t's own" }; | |
| 34 | + | ||
| 35 | + | const OWN: AccountSummary = { | |
| 36 | + | account: { id: "ws_syntaqx", kind: "workspace", name: "syntaqx", terms: COMPED, workspaces: ["syntaqx"], createdAt: "2026-10-05T00:00:00Z" }, | |
| 37 | + | limit: limit("syntaqx", "ws_syntaqx", 3_000_000), | |
| 38 | + | chargedMicros: 0, | |
| 39 | + | costMicros: 3_000_000, | |
| 40 | + | paidMicros: 0, | |
| 41 | + | byWorkspace: [{ workspace: "syntaqx", chargedMicros: 0, costMicros: 3_000_000, paidMicros: 0 }], | |
| 42 | + | }; | |
| 43 | + | ||
| 44 | + | const ACME: AccountSummary = { | |
| 45 | + | account: { id: "ent_acme", kind: "enterprise", name: "Acme Corp", terms: STANDARD_TERMS, workspaces: ["acme", "acme-labs"], createdAt: "2026-10-02T00:00:00Z" }, | |
| 46 | + | limit: limit("acme", "ent_acme", 9_000_000), | |
| 47 | + | chargedMicros: 12_000_000, | |
| 48 | + | costMicros: 6_000_000, | |
| 49 | + | paidMicros: 0, | |
| 50 | + | byWorkspace: [ | |
| 51 | + | { workspace: "acme", chargedMicros: 12_000_000, costMicros: 6_000_000, paidMicros: 0 }, | |
| 52 | + | { workspace: "acme-labs", chargedMicros: 0, costMicros: 0, paidMicros: 0 }, | |
| 53 | + | ], | |
| 54 | + | }; | |
| 55 | + | ||
| 56 | + | test("every workspace is listed, with or without billing", () => { | |
| 57 | + | const rows = joinWorkspaces([workspace("syntaqx"), workspace("quiet")], [OWN]); | |
| 58 | + | assert.deepEqual( | |
| 59 | + | rows.map((row) => row.slug), | |
| 60 | + | ["syntaqx", "quiet"], | |
| 61 | + | ); | |
| 62 | + | const [syntaqx, quiet] = rows; | |
| 63 | + | assert.equal(syntaqx.billing.terms.kind, "comped"); | |
| 64 | + | assert.equal(syntaqx.billing.costMicros, 3_000_000); | |
| 65 | + | assert.equal(syntaqx.billing.billedTo, null); | |
| 66 | + | assert.equal(quiet.billing.terms.kind, "standard"); | |
| 67 | + | assert.equal(quiet.billing.limit, null); | |
| 68 | + | assert.equal(quiet.billing.chargedMicros, 0); | |
| 69 | + | }); | |
| 70 | + | ||
| 71 | + | test("a workspace on an enterprise shows the enterprise and only its own share", () => { | |
| 72 | + | const rows = joinWorkspaces([workspace("acme"), workspace("acme-labs")], [ACME]); | |
| 73 | + | const labs = rows.find((row) => row.slug === "acme-labs"); | |
| 74 | + | assert.deepEqual(labs?.billing.billedTo, { id: "ent_acme", name: "Acme Corp" }); | |
| 75 | + | assert.equal(labs?.billing.chargedMicros, 0); | |
| 76 | + | assert.equal(labs?.billing.limit?.exposureMicros, 9_000_000); | |
| 77 | + | assert.equal(rows.find((row) => row.slug === "acme")?.billing.chargedMicros, 12_000_000); | |
| 78 | + | }); | |
| 79 | + | ||
| 80 | + | test("a slug only billing knows is still listed, marked unknown", () => { | |
| 81 | + | const rows = joinWorkspaces([], [ACME]); | |
| 82 | + | assert.deepEqual( | |
| 83 | + | rows.map((row) => [row.slug, row.known]), | |
| 84 | + | [ | |
| 85 | + | ["acme", false], | |
| 86 | + | ["acme-labs", false], | |
| 87 | + | ], | |
| 88 | + | ); | |
| 89 | + | }); | |
| 90 | + | ||
| 91 | + | test("search covers slug, name, owners, their emails and the enterprise", () => { | |
| 92 | + | const [row] = joinWorkspaces([workspace("acme-labs", "ada")], [ACME]); | |
| 93 | + | assert.ok(matchesQuery(row, "LABS")); | |
| 94 | + | assert.ok(matchesQuery(row, "ada@")); | |
| 95 | + | assert.ok(matchesQuery(row, "acme corp")); | |
| 96 | + | assert.ok(matchesQuery(row, "")); | |
| 97 | + | assert.ok(!matchesQuery(row, "globex")); | |
| 98 | + | }); | |
| 99 | + | ||
| 100 | + | test("old account links go to the workspace or the enterprise", () => { | |
| 101 | + | assert.equal(legacyAccountPath("ws_syntaqx"), "/workspaces/syntaqx"); | |
| 102 | + | assert.equal(legacyAccountPath("syntaqx"), "/workspaces/syntaqx"); | |
| 103 | + | assert.equal(legacyAccountPath("ent_01abc"), "/enterprises/ent_01abc"); | |
| 104 | + | assert.equal(legacyAccountPath("ws_"), null); | |
| 105 | + | assert.equal(legacyAccountPath("../etc"), null); | |
| 106 | + | }); |
| 1 | + | /** | |
| 2 | + | * sudo is organised around workspaces, as customers know them. Identity | |
| 3 | + | * says which workspaces exist and who owns them; billing says who pays for | |
| 4 | + | * each and where it stands. This joins the two, by slug. No Workers | |
| 5 | + | * imports, so it can be tested under Node. | |
| 6 | + | * | |
| 7 | + | * Billing's account ids (`ws_<slug>`, `ent_…`) are internal: they are used | |
| 8 | + | * to look things up and never shown as a name. | |
| 9 | + | */ | |
| 10 | + | import type { AccountSummary, AdminOwner, AdminWorkspace, Limit, Terms } from "@g1t/contracts"; | |
| 11 | + | ||
| 12 | + | /** Terms every workspace starts on. */ | |
| 13 | + | export const STANDARD_TERMS: Terms = { | |
| 14 | + | kind: "standard", | |
| 15 | + | discountPercent: 0, | |
| 16 | + | ceilingMicros: null, | |
| 17 | + | note: "", | |
| 18 | + | until: null, | |
| 19 | + | setBy: null, | |
| 20 | + | setAt: null, | |
| 21 | + | }; | |
| 22 | + | ||
| 23 | + | export type Enterprise = { id: string; name: string }; | |
| 24 | + | ||
| 25 | + | /** Where a workspace stands with billing. */ | |
| 26 | + | export type WorkspaceBilling = { | |
| 27 | + | /** The enterprise that pays for it, or null when it pays for itself. */ | |
| 28 | + | billedTo: Enterprise | null; | |
| 29 | + | /** The terms it is charged on: its own, or its enterprise's. */ | |
| 30 | + | terms: Terms; | |
| 31 | + | /** | |
| 32 | + | * Its limit this month: its own, or for a workspace on an enterprise the | |
| 33 | + | * enterprise's, which all its workspaces share. Null with no billing | |
| 34 | + | * activity yet. | |
| 35 | + | */ | |
| 36 | + | limit: Limit | null; | |
| 37 | + | /** This workspace's own figures this month (charged, cost) and ever (paid). */ | |
| 38 | + | chargedMicros: number; | |
| 39 | + | costMicros: number; | |
| 40 | + | paidMicros: number; | |
| 41 | + | }; | |
| 42 | + | ||
| 43 | + | export type WorkspaceRow = { | |
| 44 | + | slug: string; | |
| 45 | + | name: string; | |
| 46 | + | /** Null for a workspace billing knows and identity returned nothing for. */ | |
| 47 | + | createdAt: string | null; | |
| 48 | + | owners: AdminOwner[]; | |
| 49 | + | memberCount: number | null; | |
| 50 | + | /** False when only billing knows the slug. */ | |
| 51 | + | known: boolean; | |
| 52 | + | billing: WorkspaceBilling; | |
| 53 | + | }; | |
| 54 | + | ||
| 55 | + | /** The billing side of every workspace billing knows, by slug. */ | |
| 56 | + | export function billingBySlug(accounts: AccountSummary[]): Map<string, WorkspaceBilling> { | |
| 57 | + | const bySlug = new Map<string, WorkspaceBilling>(); | |
| 58 | + | for (const summary of accounts) { | |
| 59 | + | const { account } = summary; | |
| 60 | + | const shares = new Map((summary.byWorkspace ?? []).map((share) => [share.workspace, share])); | |
| 61 | + | if (account.kind === "enterprise") { | |
| 62 | + | for (const slug of account.workspaces) { | |
| 63 | + | const share = shares.get(slug); | |
| 64 | + | bySlug.set(slug, { | |
| 65 | + | billedTo: { id: account.id, name: account.name }, | |
| 66 | + | terms: account.terms, | |
| 67 | + | limit: summary.limit, | |
| 68 | + | chargedMicros: share?.chargedMicros ?? 0, | |
| 69 | + | costMicros: share?.costMicros ?? 0, | |
| 70 | + | paidMicros: share?.paidMicros ?? 0, | |
| 71 | + | }); | |
| 72 | + | } | |
| 73 | + | } else { | |
| 74 | + | const slug = account.workspaces[0] ?? summary.limit.workspace; | |
| 75 | + | // An enterprise's claim on a workspace wins over a stale own account. | |
| 76 | + | if (bySlug.get(slug)?.billedTo) continue; | |
| 77 | + | bySlug.set(slug, { | |
| 78 | + | billedTo: null, | |
| 79 | + | terms: account.terms, | |
| 80 | + | limit: summary.limit, | |
| 81 | + | chargedMicros: summary.chargedMicros, | |
| 82 | + | costMicros: summary.costMicros, | |
| 83 | + | paidMicros: summary.paidMicros, | |
| 84 | + | }); | |
| 85 | + | } | |
| 86 | + | } | |
| 87 | + | return bySlug; | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | /** A workspace with no billing activity: standard terms, nothing charged. */ | |
| 91 | + | export function noBilling(): WorkspaceBilling { | |
| 92 | + | return { billedTo: null, terms: STANDARD_TERMS, limit: null, chargedMicros: 0, costMicros: 0, paidMicros: 0 }; | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | /** | |
| 96 | + | * Every workspace identity listed, in its order, with its billing; then any | |
| 97 | + | * workspace only billing knows. A workspace with no billing activity is | |
| 98 | + | * still listed, on standard terms at $0. | |
| 99 | + | */ | |
| 100 | + | export function joinWorkspaces(workspaces: AdminWorkspace[], accounts: AccountSummary[]): WorkspaceRow[] { | |
| 101 | + | const billing = billingBySlug(accounts); | |
| 102 | + | const rows: WorkspaceRow[] = workspaces.map((workspace) => ({ | |
| 103 | + | slug: workspace.slug, | |
| 104 | + | name: workspace.name, | |
| 105 | + | createdAt: workspace.createdAt, | |
| 106 | + | owners: workspace.owners, | |
| 107 | + | memberCount: workspace.memberCount, | |
| 108 | + | known: true, | |
| 109 | + | billing: billing.get(workspace.slug) ?? noBilling(), | |
| 110 | + | })); | |
| 111 | + | const listed = new Set(rows.map((row) => row.slug)); | |
| 112 | + | for (const [slug, entry] of billing) { | |
| 113 | + | if (listed.has(slug)) continue; | |
| 114 | + | rows.push({ slug, name: slug, createdAt: null, owners: [], memberCount: null, known: false, billing: entry }); | |
| 115 | + | } | |
| 116 | + | return rows; | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | /** Whether a row matches a search: slug, name, owner, owner's email or enterprise. */ | |
| 120 | + | export function matchesQuery(row: WorkspaceRow, query: string): boolean { | |
| 121 | + | const q = query.trim().toLowerCase(); | |
| 122 | + | if (!q) return true; | |
| 123 | + | const haystack = [ | |
| 124 | + | row.slug, | |
| 125 | + | row.name, | |
| 126 | + | row.billing.billedTo?.name, | |
| 127 | + | ...row.owners.flatMap((owner) => [owner.username, owner.email]), | |
| 128 | + | ]; | |
| 129 | + | return haystack.some((value) => value?.toLowerCase().includes(q)); | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | /** | |
| 133 | + | * Where an old `/accounts/<id>` link now lives: a workspace's own account | |
| 134 | + | * (`ws_<slug>`, or a bare slug) at its workspace, an enterprise at its own | |
| 135 | + | * page. Null for anything else. | |
| 136 | + | */ | |
| 137 | + | export function legacyAccountPath(id: string): string | null { | |
| 138 | + | const value = id.trim().toLowerCase(); | |
| 139 | + | if (/^ent_[a-z0-9_-]{1,80}$/.test(value)) return `/enterprises/${encodeURIComponent(value)}`; | |
| 140 | + | const slug = value.startsWith("ws_") ? value.slice(3) : value; | |
| 141 | + | if (/^[a-z0-9](?:[a-z0-9]|-(?=[a-z0-9])){0,38}$/.test(slug)) return `/workspaces/${encodeURIComponent(slug)}`; | |
| 142 | + | return null; | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | /** A row's owners' usernames, as one line. */ | |
| 146 | + | export function ownerNames(owners: AdminOwner[]): string { | |
| 147 | + | return owners.map((owner) => owner.username).join(", "); | |
| 148 | + | } |
| 1 | − | import { Building2, ShieldCheck, Users } from "lucide-react"; | |
| 2 | − | import { isRouteErrorResponse, Link, Links, Meta, NavLink, Outlet, useRouteLoaderData } from "react-router"; | |
| 1 | + | import { Building2, Boxes, ShieldCheck } from "lucide-react"; | |
| 2 | + | import { isRouteErrorResponse, Link, Links, Meta, Outlet, useLocation, useRouteLoaderData } from "react-router"; | |
| 3 | 3 | ||
| 4 | 4 | import type { Route } from "./+types/root"; | |
| 5 | 5 | import "./app.css"; | |
| 26 | 26 | return { email: requireStaff(context).email }; | |
| 27 | 27 | } | |
| 28 | 28 | ||
| 29 | − | function NavItem({ to, end, children }: { to: string; end?: boolean; children: React.ReactNode }) { | |
| 29 | + | /** A top-level section; it stays lit on the pages beneath it. */ | |
| 30 | + | function NavItem({ to, active, children }: { to: string; active: (path: string) => boolean; children: React.ReactNode }) { | |
| 31 | + | const { pathname } = useLocation(); | |
| 32 | + | const isActive = active(pathname); | |
| 30 | 33 | return ( | |
| 31 | − | <NavLink | |
| 34 | + | <Link | |
| 32 | 35 | to={to} | |
| 33 | − | end={end} | |
| 34 | − | className={({ isActive }) => | |
| 35 | − | `inline-flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-sm transition-colors hover:bg-raised hover:text-fg ${ | |
| 36 | − | isActive ? "text-fg" : "text-muted" | |
| 37 | − | }` | |
| 38 | − | } | |
| 36 | + | aria-current={isActive ? "page" : undefined} | |
| 37 | + | className={`inline-flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-sm transition-colors hover:bg-raised hover:text-fg ${ | |
| 38 | + | isActive ? "bg-raised/60 text-fg" : "text-muted" | |
| 39 | + | }`} | |
| 39 | 40 | > | |
| 40 | 41 | {children} | |
| 41 | − | </NavLink> | |
| 42 | + | </Link> | |
| 42 | 43 | ); | |
| 43 | 44 | } | |
| 44 | 45 | ||
| 60 | 61 | <Logo /> | |
| 61 | 62 | </Link> | |
| 62 | 63 | <nav className="flex items-center gap-0.5"> | |
| 63 | − | <NavItem to="/" end> | |
| 64 | − | <Users size={14} className="hidden sm:block" /> | |
| 65 | − | Accounts | |
| 64 | + | <NavItem to="/" active={(path) => path === "/" || path.startsWith("/workspaces")}> | |
| 65 | + | <Boxes size={14} className="hidden sm:block" /> | |
| 66 | + | Workspaces | |
| 66 | 67 | </NavItem> | |
| 67 | − | <NavItem to="/enterprises/new"> | |
| 68 | + | <NavItem to="/enterprises" active={(path) => path.startsWith("/enterprises")}> | |
| 68 | 69 | <Building2 size={14} className="hidden sm:block" /> | |
| 69 | − | <span className="sm:hidden">New ent.</span> | |
| 70 | − | <span className="hidden sm:inline">New enterprise</span> | |
| 70 | + | Enterprises | |
| 71 | 71 | </NavItem> | |
| 72 | 72 | </nav> | |
| 73 | 73 | {root?.email && ( | |
| 114 | 114 | <p className="mt-3 break-words text-muted">{details}</p> | |
| 115 | 115 | <div className="mt-8"> | |
| 116 | 116 | <ButtonLink to="/" variant="quiet"> | |
| 117 | − | Back to accounts | |
| 117 | + | Back to workspaces | |
| 118 | 118 | </ButtonLink> | |
| 119 | 119 | </div> | |
| 120 | 120 | </main> |
| 1 | 1 | import { type RouteConfig, index, route } from "@react-router/dev/routes"; | |
| 2 | 2 | ||
| 3 | 3 | export default [ | |
| 4 | − | index("routes/accounts.tsx"), | |
| 5 | − | route("accounts/:id", "routes/account.tsx"), | |
| 4 | + | index("routes/workspaces.tsx"), | |
| 5 | + | route("workspaces", "routes/workspaces-redirect.tsx"), | |
| 6 | + | route("workspaces/:slug", "routes/workspace.tsx"), | |
| 7 | + | route("enterprises", "routes/enterprises.tsx"), | |
| 6 | 8 | route("enterprises/new", "routes/new-enterprise.tsx"), | |
| 9 | + | route("enterprises/:id", "routes/enterprise.tsx"), | |
| 10 | + | // Before sudo was organised around workspaces, everything was an account. | |
| 11 | + | route("accounts/*", "routes/legacy-accounts.tsx"), | |
| 7 | 12 | ] satisfies RouteConfig; |
| 1 | − | import { ArrowLeft, Building2, Gift, LogOut, Plus, ScrollText, Trash2, UserRound } from "lucide-react"; | |
| 2 | − | import type { ReactNode } from "react"; | |
| 3 | − | import { data, Link, redirect, useLocation } from "react-router"; | |
| 4 | − | ||
| 5 | − | import { type AccountDetail, type LedgerEntry, type Limit, type Terms, httpStatus } from "@g1t/contracts"; | |
| 6 | − | ||
| 7 | − | import type { Route } from "./+types/account"; | |
| 8 | − | import { | |
| 9 | − | Avatar, | |
| 10 | − | Badge, | |
| 11 | − | Button, | |
| 12 | − | EmptyState, | |
| 13 | − | ExposureBar, | |
| 14 | − | Field, | |
| 15 | − | Input, | |
| 16 | − | KindBadge, | |
| 17 | − | Notice, | |
| 18 | − | Section, | |
| 19 | − | Select, | |
| 20 | − | StateBadge, | |
| 21 | − | TermsBadge, | |
| 22 | − | Textarea, | |
| 23 | − | TrustBadge, | |
| 24 | − | When, | |
| 25 | − | } from "~/components/ui"; | |
| 26 | − | import { fields, isAccountId, parseCredit, parseNote, parseSlug, parseTerms, text } from "~/lib/forms"; | |
| 27 | − | import { dollarsField, usd } from "~/lib/money"; | |
| 28 | − | import { admin } from "~/lib/services.server"; | |
| 29 | − | import { requireStaff } from "~/lib/staff"; | |
| 30 | − | ||
| 31 | − | export const meta: Route.MetaFunction = ({ loaderData }) => [ | |
| 32 | − | { title: `${loaderData?.detail.summary.account.name ?? "Account"} · sudo` }, | |
| 33 | − | { name: "robots", content: "noindex, nofollow" }, | |
| 34 | − | ]; | |
| 35 | − | ||
| 36 | − | const DONE: Record<string, string> = { | |
| 37 | − | terms: "Terms saved. They apply to charges from now on.", | |
| 38 | − | attach: "Workspace moved onto the enterprise.", | |
| 39 | − | detach: "Workspace moved back onto its own account.", | |
| 40 | − | credit: "Credit issued.", | |
| 41 | − | created: "Enterprise created.", | |
| 42 | − | }; | |
| 43 | − | ||
| 44 | − | async function load(id: string): Promise<AccountDetail> { | |
| 45 | − | if (!isAccountId(id)) throw data("That is not an account id or a workspace slug.", { status: 404 }); | |
| 46 | − | const result = await admin.account(id); | |
| 47 | − | if (!result.ok) throw data(result.error.message, { status: httpStatus(result.error) }); | |
| 48 | − | return result.value; | |
| 49 | − | } | |
| 50 | − | ||
| 51 | − | export async function loader({ request, params, context }: Route.LoaderArgs) { | |
| 52 | − | requireStaff(context); | |
| 53 | − | const detail = await load(params.id); | |
| 54 | − | const { account } = detail.summary; | |
| 55 | − | // A workspace's page offers the enterprises it could move onto. | |
| 56 | − | const enterprises = | |
| 57 | − | account.kind === "workspace" | |
| 58 | − | ? (await admin.accounts()) | |
| 59 | − | .filter((row) => row.account.kind === "enterprise") | |
| 60 | − | .map((row) => ({ id: row.account.id, name: row.account.name })) | |
| 61 | − | : []; | |
| 62 | − | const done = new URL(request.url).searchParams.get("done"); | |
| 63 | − | return { detail, enterprises, done: done && DONE[done] ? DONE[done] : null }; | |
| 64 | − | } | |
| 65 | − | ||
| 66 | − | type Review = | |
| 67 | − | | { intent: "terms"; before: Terms; after: Terms; fields: Record<string, string> } | |
| 68 | − | | { intent: "attach"; workspace: string; target: string; targetName: string; fields: Record<string, string> } | |
| 69 | − | | { intent: "detach"; workspace: string; from: string; fields: Record<string, string> }; | |
| 70 | − | ||
| 71 | − | type ActionData = { error: string; section: string; values?: Record<string, string> } | { review: Review }; | |
| 72 | − | ||
| 73 | − | function failed(section: string, error: string, values?: Record<string, string>) { | |
| 74 | − | return data<ActionData>({ error, section, values }, { status: 422 }); | |
| 75 | − | } | |
| 76 | − | ||
| 77 | − | /** The workspace an account's page acts for when it is a workspace's own. */ | |
| 78 | − | function ownWorkspace(detail: AccountDetail): string { | |
| 79 | − | return detail.summary.limit.workspace; | |
| 80 | − | } | |
| 81 | − | ||
| 82 | − | export async function action({ request, params, context }: Route.ActionArgs) { | |
| 83 | − | const staff = requireStaff(context); | |
| 84 | − | // What is acted on comes from the billing service, not from the form. | |
| 85 | − | const detail = await load(params.id); | |
| 86 | − | const { account } = detail.summary; | |
| 87 | − | const form = await request.formData(); | |
| 88 | − | const intent = text(form, "intent"); | |
| 89 | − | const confirmed = text(form, "confirm") === "yes"; | |
| 90 | − | const back = (done: string) => redirect(`/accounts/${encodeURIComponent(params.id)}?done=${done}#top`); | |
| 91 | − | ||
| 92 | − | if (intent === "terms") { | |
| 93 | − | const values = fields(form, "kind", "discount", "ceiling", "note", "until"); | |
| 94 | − | const terms = parseTerms(form, staff.email); | |
| 95 | − | if (!terms.ok) return failed("terms", terms.error, values); | |
| 96 | − | if (!confirmed) return { review: { intent, before: account.terms, after: terms.value, fields: values } } satisfies ActionData; | |
| 97 | − | const result = await admin.setTerms(account.id, terms.value, staff.email); | |
| 98 | − | if (!result.ok) return failed("terms", result.error.message, values); | |
| 99 | − | return back("terms"); | |
| 100 | − | } | |
| 101 | − | ||
| 102 | − | if (intent === "attach") { | |
| 103 | − | const values = fields(form, "workspace", "target"); | |
| 104 | − | const slug = parseSlug(account.kind === "enterprise" ? values.workspace : ownWorkspace(detail)); | |
| 105 | − | if (!slug.ok) return failed("members", slug.error, values); | |
| 106 | − | let target = account.id; | |
| 107 | − | let targetName = account.name; | |
| 108 | − | if (account.kind === "workspace") { | |
| 109 | − | const enterprise = (await admin.accounts()).find((row) => row.account.kind === "enterprise" && row.account.id === values.target); | |
| 110 | − | if (!enterprise) return failed("enterprise", "Choose an enterprise to move onto.", values); | |
| 111 | − | target = enterprise.account.id; | |
| 112 | − | targetName = enterprise.account.name; | |
| 113 | − | } else if (account.workspaces.includes(slug.value)) { | |
| 114 | − | return failed("members", `${slug.value} is already on this enterprise.`, values); | |
| 115 | − | } | |
| 116 | − | if (!confirmed) { | |
| 117 | − | return { review: { intent, workspace: slug.value, target, targetName, fields: values } } satisfies ActionData; | |
| 118 | − | } | |
| 119 | − | const result = await admin.attach(slug.value, target, staff.email); | |
| 120 | − | if (!result.ok) return failed(account.kind === "enterprise" ? "members" : "enterprise", result.error.message, values); | |
| 121 | − | return back("attach"); | |
| 122 | − | } | |
| 123 | − | ||
| 124 | − | if (intent === "detach") { | |
| 125 | − | const values = fields(form, "workspace"); | |
| 126 | − | const workspace = account.kind === "enterprise" ? values.workspace : ownWorkspace(detail); | |
| 127 | − | const onIt = account.kind === "enterprise" ? account.workspaces.includes(workspace) : detail.summary.limit.account !== account.id; | |
| 128 | − | if (!onIt) return failed(account.kind === "enterprise" ? "members" : "enterprise", `${workspace} is not on an enterprise here.`); | |
| 129 | − | const from = account.kind === "enterprise" ? account.name : detail.summary.limit.accountName; | |
| 130 | − | if (!confirmed) return { review: { intent, workspace, from, fields: values } } satisfies ActionData; | |
| 131 | − | const result = await admin.attach(workspace, null, staff.email); | |
| 132 | − | if (!result.ok) return failed(account.kind === "enterprise" ? "members" : "enterprise", result.error.message); | |
| 133 | − | return back("detach"); | |
| 134 | − | } | |
| 135 | − | ||
| 136 | − | if (intent === "credit") { | |
| 137 | − | const values = fields(form, "workspace", "amount", "note", "confirmation"); | |
| 138 | − | const workspace = account.kind === "enterprise" ? values.workspace : ownWorkspace(detail); | |
| 139 | − | if (account.kind === "enterprise" && !account.workspaces.includes(workspace)) { | |
| 140 | − | return failed("credit", "Choose one of this account's workspaces.", values); | |
| 141 | − | } | |
| 142 | − | const amount = parseCredit(values.amount); | |
| 143 | − | if (!amount.ok) return failed("credit", amount.error, values); | |
| 144 | − | const note = parseNote(values.note); | |
| 145 | − | if (!note.ok) return failed("credit", note.error, values); | |
| 146 | − | if (values.confirmation !== workspace) { | |
| 147 | − | return failed("credit", `Type the workspace's slug, ${workspace}, exactly, to issue the credit.`, { ...values, confirmation: "" }); | |
| 148 | − | } | |
| 149 | − | const result = await admin.credit(workspace, amount.value, note.value, staff.email); | |
| 150 | − | if (!result.ok) return failed("credit", result.error.message, values); | |
| 151 | − | return back("credit"); | |
| 152 | − | } | |
| 153 | − | ||
| 154 | − | return failed("top", "Unknown action."); | |
| 155 | − | } | |
| 156 | − | ||
| 157 | − | export default function Account({ loaderData, actionData }: Route.ComponentProps) { | |
| 158 | − | const { detail, enterprises, done } = loaderData; | |
| 159 | − | const { summary } = detail; | |
| 160 | − | const { account, limit } = summary; | |
| 161 | − | const { pathname } = useLocation(); | |
| 162 | − | const result = actionData as ActionData | undefined; | |
| 163 | − | const review = result && "review" in result ? result.review : null; | |
| 164 | − | const error = (section: string) => (result && "error" in result && result.section === section ? result : null); | |
| 165 | − | const isEnterprise = account.kind === "enterprise"; | |
| 166 | − | const billedElsewhere = !isEnterprise && limit.account !== account.id; | |
| 167 | − | ||
| 168 | − | return ( | |
| 169 | − | <main id="top" className="mx-auto max-w-6xl scroll-mt-20 px-4 py-8 sm:py-10"> | |
| 170 | − | <Link to="/" className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-fg"> | |
| 171 | − | <ArrowLeft size={14} /> | |
| 172 | − | Accounts | |
| 173 | − | </Link> | |
| 174 | − | ||
| 175 | − | {/* Header */} | |
| 176 | − | <div className="mt-4 flex flex-wrap items-start justify-between gap-4"> | |
| 177 | − | <div className="flex min-w-0 items-start gap-3"> | |
| 178 | − | <Avatar name={account.name} size={40} /> | |
| 179 | − | <div className="min-w-0"> | |
| 180 | − | <h1 className="truncate text-2xl font-semibold tracking-tight">{account.name}</h1> | |
| 181 | − | <p className="mt-0.5 font-mono text-xs break-all text-faint">{account.id}</p> | |
| 182 | − | <div className="mt-2 flex flex-wrap gap-1.5"> | |
| 183 | − | <KindBadge kind={account.kind} /> | |
| 184 | − | <TermsBadge terms={account.terms} /> | |
| 185 | − | <TrustBadge trust={limit.trust} /> | |
| 186 | − | <StateBadge state={limit.state} /> | |
| 187 | − | {billedElsewhere && <Badge tone="lavender">Billed through {limit.accountName}</Badge>} | |
| 188 | − | </div> | |
| 189 | − | </div> | |
| 190 | − | </div> | |
| 191 | − | <p className="text-xs text-faint"> | |
| 192 | − | Account since <When at={account.createdAt} /> | |
| 193 | − | </p> | |
| 194 | − | </div> | |
| 195 | − | ||
| 196 | − | <div className="mt-6 space-y-3"> | |
| 197 | − | {done && <Notice tone="ok">{done}</Notice>} | |
| 198 | − | {error("top") && <Notice tone="error">{error("top")?.error}</Notice>} | |
| 199 | − | {limit.message && <Notice tone={limit.state === "stopped" ? "error" : limit.state === "warning" ? "warn" : "info"}>{limit.message}</Notice>} | |
| 200 | − | {review && <ReviewPanel review={review} pathname={pathname} />} | |
| 201 | − | </div> | |
| 202 | − | ||
| 203 | − | {/* This month */} | |
| 204 | − | <div className="mt-6 grid gap-3 sm:grid-cols-2 lg:grid-cols-4"> | |
| 205 | − | <div className="rounded-lg border border-line bg-surface px-4 py-3 sm:col-span-2"> | |
| 206 | − | <p className="mb-2 text-xs text-muted">Unpaid exposure this month</p> | |
| 207 | − | <ExposureBar limit={limit} wide /> | |
| 208 | − | <p className="mt-2 text-xs text-faint"> | |
| 209 | − | Trust ceiling {usd(limit.trustCeilingMicros)} · owner's spend limit {usd(limit.spendLimitMicros)} | |
| 210 | − | {account.terms.ceilingMicros != null && <> · custom ceiling {usd(account.terms.ceilingMicros)}</>} | |
| 211 | − | </p> | |
| 212 | − | </div> | |
| 213 | − | <Figure label="Charged this month" value={usd(summary.chargedMicros)} hint={`Cost to g1t ${usd(summary.costMicros)}`} /> | |
| 214 | − | <Figure label="Paid ever" value={usd(summary.paidMicros)} hint={`Margin this month ${usd(summary.chargedMicros - summary.costMicros)}`} /> | |
| 215 | − | </div> | |
| 216 | − | ||
| 217 | − | <div className="mt-6 grid gap-6 lg:grid-cols-[minmax(0,3fr)_minmax(0,2fr)]"> | |
| 218 | − | <div className="space-y-6"> | |
| 219 | − | <TermsForm terms={account.terms} pathname={pathname} error={error("terms")} /> | |
| 220 | − | ||
| 221 | − | {isEnterprise ? ( | |
| 222 | − | <MembersSection detail={detail} pathname={pathname} error={error("members")} /> | |
| 223 | − | ) : ( | |
| 224 | − | <EnterpriseSection | |
| 225 | − | billedElsewhere={billedElsewhere} | |
| 226 | − | limit={limit} | |
| 227 | − | enterprises={enterprises} | |
| 228 | − | pathname={pathname} | |
| 229 | − | error={error("enterprise")} | |
| 230 | − | /> | |
| 231 | − | )} | |
| 232 | − | ||
| 233 | − | <LedgerSection ledger={detail.ledger} /> | |
| 234 | − | </div> | |
| 235 | − | ||
| 236 | − | <div className="space-y-6"> | |
| 237 | − | <CreditForm | |
| 238 | − | workspaces={isEnterprise ? account.workspaces : [limit.workspace]} | |
| 239 | − | pathname={pathname} | |
| 240 | − | error={error("credit")} | |
| 241 | − | /> | |
| 242 | − | <AuditSection audit={detail.audit} /> | |
| 243 | − | </div> | |
| 244 | − | </div> | |
| 245 | − | </main> | |
| 246 | − | ); | |
| 247 | − | } | |
| 248 | − | ||
| 249 | − | function Figure({ label, value, hint }: { label: string; value: string; hint?: string }) { | |
| 250 | − | return ( | |
| 251 | − | <div className="rounded-lg border border-line bg-surface px-4 py-3"> | |
| 252 | − | <p className="text-xs text-muted">{label}</p> | |
| 253 | − | <p className="tabular mt-1 text-lg font-semibold tracking-tight">{value}</p> | |
| 254 | − | {hint && <p className="mt-0.5 text-xs text-faint">{hint}</p>} | |
| 255 | − | </div> | |
| 256 | − | ); | |
| 257 | − | } | |
| 258 | − | ||
| 259 | − | function Hidden({ values }: { values: Record<string, string> }) { | |
| 260 | − | return ( | |
| 261 | − | <> | |
| 262 | − | {Object.entries(values).map(([name, value]) => ( | |
| 263 | − | <input key={name} type="hidden" name={name} value={value} /> | |
| 264 | − | ))} | |
| 265 | − | </> | |
| 266 | − | ); | |
| 267 | − | } | |
| 268 | − | ||
| 269 | − | type SectionError = { error: string; values?: Record<string, string> } | null; | |
| 270 | − | ||
| 271 | − | // --- Confirmation ------------------------------------------------------------ | |
| 272 | − | ||
| 273 | − | function describeTerms(terms: Terms): [string, string][] { | |
| 274 | − | return [ | |
| 275 | − | ["Terms", terms.kind === "custom" ? "Custom" : terms.kind === "comped" ? "Comped" : "Standard"], | |
| 276 | − | ["Discount", terms.kind === "custom" ? `${terms.discountPercent}%` : "—"], | |
| 277 | − | ["Ceiling", terms.ceilingMicros == null ? "By trust" : usd(terms.ceilingMicros)], | |
| 278 | − | ["Until", terms.until ? terms.until.slice(0, 10) : "No end"], | |
| 279 | − | ["Note", terms.note || "—"], | |
| 280 | − | ]; | |
| 281 | − | } | |
| 282 | − | ||
| 283 | − | function ReviewPanel({ review, pathname }: { review: Review; pathname: string }) { | |
| 284 | − | let title: string; | |
| 285 | − | let body: ReactNode; | |
| 286 | − | let danger = false; | |
| 287 | − | if (review.intent === "terms") { | |
| 288 | − | const before = describeTerms(review.before); | |
| 289 | − | const after = describeTerms(review.after); | |
| 290 | − | title = "Confirm the new terms"; | |
| 291 | − | danger = review.after.kind === "comped"; | |
| 292 | − | body = ( | |
| 293 | − | <> | |
| 294 | − | <div className="overflow-x-auto"> | |
| 295 | − | <table className="w-full text-sm"> | |
| 296 | − | <thead> | |
| 297 | − | <tr className="text-left text-xs text-muted"> | |
| 298 | − | <th className="py-1.5 pr-4 font-medium" /> | |
| 299 | − | <th className="py-1.5 pr-4 font-medium">Now</th> | |
| 300 | − | <th className="py-1.5 font-medium">After</th> | |
| 301 | − | </tr> | |
| 302 | − | </thead> | |
| 303 | − | <tbody> | |
| 304 | − | {after.map(([label, value], index) => ( | |
| 305 | − | <tr key={label} className="border-t border-line align-top"> | |
| 306 | − | <td className="py-1.5 pr-4 text-muted">{label}</td> | |
| 307 | − | <td className="py-1.5 pr-4 break-words text-faint">{before[index][1]}</td> | |
| 308 | − | <td className={`py-1.5 break-words ${value !== before[index][1] ? "font-medium text-fg" : "text-muted"}`}>{value}</td> | |
| 309 | − | </tr> | |
| 310 | − | ))} | |
| 311 | − | </tbody> | |
| 312 | − | </table> | |
| 313 | − | </div> | |
| 314 | − | {review.after.kind === "comped" && ( | |
| 315 | − | <p className="mt-3 text-sm text-warn">Comped: nothing this account uses will be charged{review.after.until ? ` until ${review.after.until.slice(0, 10)}` : ""}. Usage is still recorded at cost.</p> | |
| 316 | − | )} | |
| 317 | − | </> | |
| 318 | − | ); | |
| 319 | − | } else if (review.intent === "attach") { | |
| 320 | − | title = `Move ${review.workspace} onto ${review.targetName}?`; | |
| 321 | − | body = ( | |
| 322 | − | <p className="text-sm text-muted"> | |
| 323 | − | From now on <span className="font-mono text-fg">{review.workspace}</span>'s usage is billed to{" "} | |
| 324 | − | <span className="text-fg">{review.targetName}</span> and counts against its limit and terms, not its own. | |
| 325 | − | </p> | |
| 326 | − | ); | |
| 327 | − | } else { | |
| 328 | − | title = `Move ${review.workspace} off ${review.from}?`; | |
| 329 | − | danger = true; | |
| 330 | − | body = ( | |
| 331 | − | <p className="text-sm text-muted"> | |
| 332 | − | <span className="font-mono text-fg">{review.workspace}</span> goes back to paying for itself, under its own terms and the | |
| 333 | − | ceiling its trust gives it. It may stop at once if its own ceiling is lower than its exposure. | |
| 334 | − | </p> | |
| 335 | − | ); | |
| 336 | − | } | |
| 337 | − | return ( | |
| 338 | − | <section id="review" className={`scroll-mt-20 rounded-lg border p-4 sm:p-5 ${danger ? "border-warn/40 bg-warn/5" : "border-merged/40 bg-merged/5"}`}> | |
| 339 | − | <h2 className="font-semibold tracking-tight">{title}</h2> | |
| 340 | − | <div className="mt-3">{body}</div> | |
| 341 | − | <form method="post" action={`${pathname}#top`} className="mt-4 flex flex-wrap items-center gap-2"> | |
| 342 | − | <Hidden values={review.fields} /> | |
| 343 | − | <input type="hidden" name="intent" value={review.intent} /> | |
| 344 | − | <input type="hidden" name="confirm" value="yes" /> | |
| 345 | − | <Button type="submit" variant={danger ? "danger" : "lavender"}> | |
| 346 | − | Confirm | |
| 347 | − | </Button> | |
| 348 | − | <Link to={pathname} className="px-2 text-sm text-muted hover:text-fg"> | |
| 349 | − | Cancel | |
| 350 | − | </Link> | |
| 351 | − | </form> | |
| 352 | − | </section> | |
| 353 | − | ); | |
| 354 | − | } | |
| 355 | − | ||
| 356 | − | // --- Terms ------------------------------------------------------------------- | |
| 357 | − | ||
| 358 | − | const KINDS: { value: Terms["kind"]; title: string; text: string }[] = [ | |
| 359 | − | { value: "standard", title: "Standard", text: "Published prices; the ceiling comes from trust." }, | |
| 360 | − | { value: "comped", title: "Comped", text: "Nothing charged. Usage still recorded at cost." }, | |
| 361 | − | { value: "custom", title: "Custom", text: "A discount, a custom ceiling, or both." }, | |
| 362 | − | ]; | |
| 363 | − | ||
| 364 | − | function TermsForm({ terms, pathname, error }: { terms: Terms; pathname: string; error: SectionError }) { | |
| 365 | − | const values = error?.values; | |
| 366 | − | const kind = values?.kind ?? terms.kind; | |
| 367 | − | return ( | |
| 368 | − | <Section | |
| 369 | − | id="terms" | |
| 370 | − | title="Terms" | |
| 371 | − | description={ | |
| 372 | − | terms.setBy ? ( | |
| 373 | − | <> | |
| 374 | − | Set by <span className="font-mono">{terms.setBy}</span> on <When at={terms.setAt} /> | |
| 375 | − | {terms.note && <> · “{terms.note}”</>} | |
| 376 | − | </> | |
| 377 | − | ) : ( | |
| 378 | − | "Standard terms, as every account starts." | |
| 379 | − | ) | |
| 380 | − | } | |
| 381 | − | > | |
| 382 | − | <form method="post" action={`${pathname}#review`} className="space-y-4"> | |
| 383 | − | <input type="hidden" name="intent" value="terms" /> | |
| 384 | − | {error && <Notice tone="error">{error.error}</Notice>} | |
| 385 | − | <fieldset> | |
| 386 | − | <legend className="mb-1.5 text-sm font-medium text-muted">Kind</legend> | |
| 387 | − | <div className="grid gap-2 sm:grid-cols-3"> | |
| 388 | − | {KINDS.map((option) => ( | |
| 389 | − | <label | |
| 390 | − | key={option.value} | |
| 391 | − | className="flex cursor-pointer gap-2.5 rounded-md border border-line bg-bg p-3 transition-colors hover:border-line-strong has-checked:border-merged/60 has-checked:bg-merged/8" | |
| 392 | − | > | |
| 393 | − | <input type="radio" name="kind" value={option.value} defaultChecked={kind === option.value} className="mt-0.5" required /> | |
| 394 | − | <span> | |
| 395 | − | <span className="block text-sm font-medium">{option.title}</span> | |
| 396 | − | <span className="mt-0.5 block text-xs text-muted">{option.text}</span> | |
| 397 | − | </span> | |
| 398 | − | </label> | |
| 399 | − | ))} | |
| 400 | − | </div> | |
| 401 | − | </fieldset> | |
| 402 | − | <div className="grid gap-4 sm:grid-cols-3"> | |
| 403 | − | <Field label="Discount %" hint="Custom only."> | |
| 404 | − | <Input name="discount" inputMode="numeric" pattern="\d{1,3}" placeholder="0" defaultValue={values?.discount ?? (terms.discountPercent ? String(terms.discountPercent) : "")} /> | |
| 405 | − | </Field> | |
| 406 | − | <Field label="Ceiling $" hint="Blank: trust decides."> | |
| 407 | − | <Input name="ceiling" inputMode="decimal" placeholder="By trust" defaultValue={values?.ceiling ?? dollarsField(terms.ceilingMicros)} /> | |
| 408 | − | </Field> | |
| 409 | − | <Field label="Until" hint="Blank: no end. UTC."> | |
| 410 | − | <Input type="date" name="until" defaultValue={values?.until ?? (terms.until ? terms.until.slice(0, 10) : "")} /> | |
| 411 | − | </Field> | |
| 412 | − | </div> | |
| 413 | − | <Field label="Note" hint="Required. Why, for whoever looks next."> | |
| 414 | − | <Textarea name="note" rows={2} required maxLength={500} defaultValue={values?.note ?? ""} placeholder="e.g. Design partner through launch" /> | |
| 415 | − | </Field> | |
| 416 | − | <div className="flex justify-end"> | |
| 417 | − | <Button type="submit">Review terms</Button> | |
| 418 | − | </div> | |
| 419 | − | </form> | |
| 420 | − | </Section> | |
| 421 | − | ); | |
| 422 | − | } | |
| 423 | − | ||
| 424 | − | // --- Enterprise membership ---------------------------------------------------- | |
| 425 | − | ||
| 426 | − | function MembersSection({ detail, pathname, error }: { detail: AccountDetail; pathname: string; error: SectionError }) { | |
| 427 | − | const members: Limit[] = detail.workspaces; | |
| 428 | − | const listed = new Set(members.map((member) => member.workspace)); | |
| 429 | − | // Any workspace the account names but no limit came back for. | |
| 430 | − | const missing = detail.summary.account.workspaces.filter((slug) => !listed.has(slug)); | |
| 431 | − | return ( | |
| 432 | − | <Section id="members" title="Workspaces" description="Billed together: one bill, one limit, one set of terms."> | |
| 433 | − | {error && ( | |
| 434 | − | <div className="mb-4"> | |
| 435 | − | <Notice tone="error">{error.error}</Notice> | |
| 436 | − | </div> | |
| 437 | − | )} | |
| 438 | − | {members.length + missing.length === 0 ? ( | |
| 439 | − | <EmptyState title="No workspaces yet">Add one below to bill it through this enterprise.</EmptyState> | |
| 440 | − | ) : ( | |
| 441 | − | <ul className="divide-y divide-line rounded-md border border-line"> | |
| 442 | − | {members.map((member) => ( | |
| 443 | − | <li key={member.workspace} className="flex flex-col gap-3 p-3 sm:flex-row sm:items-center"> | |
| 444 | − | <div className="flex min-w-0 grow items-center gap-2.5"> | |
| 445 | − | <Avatar name={member.workspace} size={22} /> | |
| 446 | − | <div className="min-w-0"> | |
| 447 | − | <Link to={`/accounts/${encodeURIComponent(member.workspace)}`} className="font-mono text-sm hover:underline hover:underline-offset-4"> | |
| 448 | − | {member.workspace} | |
| 449 | − | </Link> | |
| 450 | − | <div className="mt-1 flex flex-wrap gap-1.5"> | |
| 451 | − | <TrustBadge trust={member.trust} /> | |
| 452 | − | <StateBadge state={member.state} /> | |
| 453 | − | </div> | |
| 454 | − | {member.message && <p className="mt-1 text-xs text-muted">{member.message}</p>} | |
| 455 | − | </div> | |
| 456 | − | </div> | |
| 457 | − | <div className="flex items-center gap-3 sm:w-64"> | |
| 458 | − | <ExposureBar limit={member} wide /> | |
| 459 | − | <DetachButton workspace={member.workspace} pathname={pathname} /> | |
| 460 | − | </div> | |
| 461 | − | </li> | |
| 462 | − | ))} | |
| 463 | − | {missing.map((slug) => ( | |
| 464 | − | <li key={slug} className="flex items-center gap-3 p-3"> | |
| 465 | − | <span className="grow font-mono text-sm">{slug}</span> | |
| 466 | − | <DetachButton workspace={slug} pathname={pathname} /> | |
| 467 | − | </li> | |
| 468 | − | ))} | |
| 469 | − | </ul> | |
| 470 | − | )} | |
| 471 | − | <form method="post" action={`${pathname}#review`} className="mt-4 flex flex-col gap-2 sm:flex-row sm:items-end"> | |
| 472 | − | <input type="hidden" name="intent" value="attach" /> | |
| 473 | − | <Field label="Add a workspace" className="grow"> | |
| 474 | − | <Input name="workspace" required placeholder="workspace-slug" defaultValue={error?.values?.workspace ?? ""} className="font-mono" /> | |
| 475 | − | </Field> | |
| 476 | − | <Button type="submit" variant="quiet"> | |
| 477 | − | <Plus size={14} /> | |
| 478 | − | Add | |
| 479 | − | </Button> | |
| 480 | − | </form> | |
| 481 | − | </Section> | |
| 482 | − | ); | |
| 483 | − | } | |
| 484 | − | ||
| 485 | − | function DetachButton({ workspace, pathname }: { workspace: string; pathname: string }) { | |
| 486 | − | return ( | |
| 487 | − | <form method="post" action={`${pathname}#review`} className="shrink-0"> | |
| 488 | − | <input type="hidden" name="intent" value="detach" /> | |
| 489 | − | <input type="hidden" name="workspace" value={workspace} /> | |
| 490 | − | <button | |
| 491 | − | type="submit" | |
| 492 | − | aria-label={`Remove ${workspace}`} | |
| 493 | − | title={`Remove ${workspace}`} | |
| 494 | − | className="rounded-md border border-line p-2 text-muted transition-colors hover:border-danger/50 hover:text-danger" | |
| 495 | − | > | |
| 496 | − | <Trash2 size={14} /> | |
| 497 | − | </button> | |
| 498 | − | </form> | |
| 499 | − | ); | |
| 500 | − | } | |
| 501 | − | ||
| 502 | − | function EnterpriseSection({ | |
| 503 | − | billedElsewhere, | |
| 504 | − | limit, | |
| 505 | − | enterprises, | |
| 506 | − | pathname, | |
| 507 | − | error, | |
| 508 | − | }: { | |
| 509 | − | billedElsewhere: boolean; | |
| 510 | − | limit: Limit; | |
| 511 | − | enterprises: { id: string; name: string }[]; | |
| 512 | − | pathname: string; | |
| 513 | − | error: SectionError; | |
| 514 | − | }) { | |
| 515 | − | return ( | |
| 516 | − | <Section id="enterprise" title="Enterprise" description="Whether another account pays for this workspace."> | |
| 517 | − | {error && ( | |
| 518 | − | <div className="mb-4"> | |
| 519 | − | <Notice tone="error">{error.error}</Notice> | |
| 520 | − | </div> | |
| 521 | − | )} | |
| 522 | − | {billedElsewhere ? ( | |
| 523 | − | <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> | |
| 524 | − | <p className="text-sm text-muted"> | |
| 525 | − | Billed through{" "} | |
| 526 | − | <Link to={`/accounts/${encodeURIComponent(limit.account)}`} className="text-fg hover:underline hover:underline-offset-4"> | |
| 527 | − | {limit.accountName} | |
| 528 | − | </Link> | |
| 529 | − | , under its limit and terms. | |
| 530 | − | </p> | |
| 531 | − | <form method="post" action={`${pathname}#review`}> | |
| 532 | − | <input type="hidden" name="intent" value="detach" /> | |
| 533 | − | <Button type="submit" variant="danger"> | |
| 534 | − | <LogOut size={14} /> | |
| 535 | − | Move off | |
| 536 | − | </Button> | |
| 537 | − | </form> | |
| 538 | − | </div> | |
| 539 | − | ) : enterprises.length === 0 ? ( | |
| 540 | − | <p className="text-sm text-muted"> | |
| 541 | − | Pays for itself. There are no enterprises to move it onto yet;{" "} | |
| 542 | − | <Link to="/enterprises/new" className="text-merged hover:underline hover:underline-offset-4"> | |
| 543 | − | create one | |
| 544 | − | </Link> | |
| 545 | − | . | |
| 546 | − | </p> | |
| 547 | − | ) : ( | |
| 548 | − | <form method="post" action={`${pathname}#review`} className="flex flex-col gap-2 sm:flex-row sm:items-end"> | |
| 549 | − | <input type="hidden" name="intent" value="attach" /> | |
| 550 | − | <Field label="Pays for itself. Move onto" className="grow"> | |
| 551 | − | <Select name="target" required defaultValue={error?.values?.target ?? ""}> | |
| 552 | − | <option value="" disabled> | |
| 553 | − | Choose an enterprise | |
| 554 | − | </option> | |
| 555 | − | {enterprises.map((enterprise) => ( | |
| 556 | − | <option key={enterprise.id} value={enterprise.id}> | |
| 557 | − | {enterprise.name} ({enterprise.id}) | |
| 558 | − | </option> | |
| 559 | − | ))} | |
| 560 | − | </Select> | |
| 561 | − | </Field> | |
| 562 | − | <Button type="submit" variant="quiet"> | |
| 563 | − | <Building2 size={14} /> | |
| 564 | − | Move | |
| 565 | − | </Button> | |
| 566 | − | </form> | |
| 567 | − | )} | |
| 568 | − | </Section> | |
| 569 | − | ); | |
| 570 | − | } | |
| 571 | − | ||
| 572 | − | // --- Credit ------------------------------------------------------------------- | |
| 573 | − | ||
| 574 | − | function CreditForm({ workspaces, pathname, error }: { workspaces: string[]; pathname: string; error: SectionError }) { | |
| 575 | − | const values = error?.values; | |
| 576 | − | const single = workspaces.length === 1 ? workspaces[0] : null; | |
| 577 | − | return ( | |
| 578 | − | <Section id="credit" title="Issue credit" description="A refund or goodwill. Added to the workspace's balance at once."> | |
| 579 | − | {workspaces.length === 0 ? ( | |
| 580 | − | <p className="text-sm text-muted">Add a workspace first: credit goes to a workspace.</p> | |
| 581 | − | ) : ( | |
| 582 | − | <form method="post" action={`${pathname}#credit`} className="space-y-4"> | |
| 583 | − | <input type="hidden" name="intent" value="credit" /> | |
| 584 | − | {error && <Notice tone="error">{error.error}</Notice>} | |
| 585 | − | {single ? ( | |
| 586 | − | <input type="hidden" name="workspace" value={single} /> | |
| 587 | − | ) : ( | |
| 588 | − | <Field label="Workspace"> | |
| 589 | − | <Select name="workspace" required defaultValue={values?.workspace ?? ""}> | |
| 590 | − | <option value="" disabled> | |
| 591 | − | Choose a workspace | |
| 592 | − | </option> | |
| 593 | − | {workspaces.map((slug) => ( | |
| 594 | − | <option key={slug} value={slug}> | |
| 595 | − | {slug} | |
| 596 | − | </option> | |
| 597 | − | ))} | |
| 598 | − | </Select> | |
| 599 | − | </Field> | |
| 600 | − | )} | |
| 601 | − | <Field label="Amount $" hint="Up to $10,000 at a time."> | |
| 602 | − | <Input name="amount" inputMode="decimal" required placeholder="25.00" defaultValue={values?.amount ?? ""} /> | |
| 603 | − | </Field> | |
| 604 | − | <Field label="Note" hint="Required. Shown on the workspace's statement."> | |
| 605 | − | <Textarea name="note" rows={2} required maxLength={500} placeholder="e.g. Refund for the failed runs on Oct 2" defaultValue={values?.note ?? ""} /> | |
| 606 | − | </Field> | |
| 607 | − | <Field | |
| 608 | − | label="Confirm" | |
| 609 | − | hint={ | |
| 610 | − | <> | |
| 611 | − | Type the workspace's slug{single && <> (<span className="font-mono text-muted">{single}</span>)</>} to issue it. | |
| 612 | − | </> | |
| 613 | − | } | |
| 614 | − | > | |
| 615 | − | <Input name="confirmation" required placeholder={single ?? "workspace-slug"} className="font-mono" /> | |
| 616 | − | </Field> | |
| 617 | − | <div className="flex justify-end"> | |
| 618 | − | <Button type="submit" variant="lavender"> | |
| 619 | − | <Gift size={14} /> | |
| 620 | − | Issue credit | |
| 621 | − | </Button> | |
| 622 | − | </div> | |
| 623 | − | </form> | |
| 624 | − | )} | |
| 625 | − | </Section> | |
| 626 | − | ); | |
| 627 | − | } | |
| 628 | − | ||
| 629 | − | // --- Ledger and audit --------------------------------------------------------- | |
| 630 | − | ||
| 631 | − | const ENTRY_KIND: Record<string, string> = { top_up: "Top-up", usage: "Usage", credit: "Credit" }; | |
| 632 | − | ||
| 633 | − | function LedgerSection({ ledger }: { ledger: LedgerEntry[] }) { | |
| 634 | − | return ( | |
| 635 | − | <Section title="Ledger" description="Recent lines of the statement, newest first."> | |
| 636 | − | {ledger.length === 0 ? ( | |
| 637 | − | <EmptyState title="Nothing yet" /> | |
| 638 | − | ) : ( | |
| 639 | − | <div className="-mx-4 -my-4 overflow-x-auto sm:-mx-5 sm:-my-5"> | |
| 640 | − | <table className="w-full min-w-[36rem] text-sm"> | |
| 641 | − | <thead> | |
| 642 | − | <tr className="border-b border-line text-left text-xs text-muted"> | |
| 643 | − | <th className="px-4 py-2 font-medium sm:pl-5">When</th> | |
| 644 | − | <th className="px-4 py-2 font-medium">What</th> | |
| 645 | − | <th className="px-4 py-2 text-right font-medium sm:pr-5">Amount</th> | |
| 646 | − | </tr> | |
| 647 | − | </thead> | |
| 648 | − | <tbody> | |
| 649 | − | {ledger.map((entry) => ( | |
| 650 | − | <tr key={entry.id} className="border-b border-line align-top last:border-0"> | |
| 651 | − | <td className="px-4 py-2.5 text-xs whitespace-nowrap text-muted sm:pl-5"> | |
| 652 | − | <When at={entry.createdAt} time /> | |
| 653 | − | </td> | |
| 654 | − | <td className="px-4 py-2.5"> | |
| 655 | − | <div className="flex flex-wrap items-center gap-1.5"> | |
| 656 | − | <Badge tone={entry.amountMicros > 0 ? "mint" : "plain"}>{ENTRY_KIND[entry.kind] ?? entry.kind}</Badge> | |
| 657 | − | <span className="break-words">{entry.description}</span> | |
| 658 | − | </div> | |
| 659 | − | <p className="mt-0.5 font-mono text-xs text-faint"> | |
| 660 | − | {[ | |
| 661 | − | entry.repo && (entry.number != null ? `${entry.repo}#${entry.number}` : entry.repo), | |
| 662 | − | entry.task, | |
| 663 | − | entry.model, | |
| 664 | − | entry.billedTo === "workspace" ? "own provider" : null, | |
| 665 | − | entry.createdBy && `by ${entry.createdBy}`, | |
| 666 | − | ] | |
| 667 | − | .filter(Boolean) | |
| 668 | − | .join(" · ")} | |
| 669 | − | </p> | |
| 670 | − | </td> | |
| 671 | − | <td className={`tabular px-4 py-2.5 text-right whitespace-nowrap sm:pr-5 ${entry.amountMicros > 0 ? "text-accent" : "text-fg-soft"}`}> | |
| 672 | − | {usd(entry.amountMicros, { signed: true })} | |
| 673 | − | </td> | |
| 674 | − | </tr> | |
| 675 | − | ))} | |
| 676 | − | </tbody> | |
| 677 | − | </table> | |
| 678 | − | </div> | |
| 679 | − | )} | |
| 680 | − | </Section> | |
| 681 | − | ); | |
| 682 | − | } | |
| 683 | − | ||
| 684 | − | function AuditSection({ audit }: { audit: AccountDetail["audit"] }) { | |
| 685 | − | return ( | |
| 686 | − | <Section title="Audit log" description="Every change made in sudo, and by whom."> | |
| 687 | − | {audit.length === 0 ? ( | |
| 688 | − | <p className="flex items-center gap-2 text-sm text-muted"> | |
| 689 | − | <ScrollText size={14} /> | |
| 690 | − | No changes yet. | |
| 691 | − | </p> | |
| 692 | − | ) : ( | |
| 693 | − | <ol className="space-y-3"> | |
| 694 | − | {audit.map((entry) => ( | |
| 695 | − | <li key={entry.id} className="border-l-2 border-merged/40 pl-3"> | |
| 696 | − | <p className="flex flex-wrap items-center gap-x-2 text-sm"> | |
| 697 | − | <span className="font-mono font-medium text-merged">{entry.action}</span> | |
| 698 | − | <span className="text-xs text-faint"> | |
| 699 | − | <When at={entry.createdAt} time /> | |
| 700 | − | </span> | |
| 701 | − | </p> | |
| 702 | − | {entry.detail && <p className="mt-0.5 text-sm break-words text-fg-soft">{entry.detail}</p>} | |
| 703 | − | <p className="mt-0.5 flex items-center gap-1 font-mono text-xs text-faint"> | |
| 704 | − | <UserRound size={11} /> | |
| 705 | − | {entry.by} | |
| 706 | − | </p> | |
| 707 | − | </li> | |
| 708 | − | ))} | |
| 709 | − | </ol> | |
| 710 | − | )} | |
| 711 | − | </Section> | |
| 712 | − | ); | |
| 713 | − | } |
| 1 | − | import { Building2, ChevronRight, Search } from "lucide-react"; | |
| 2 | − | import { Link } from "react-router"; | |
| 3 | − | ||
| 4 | − | import type { AccountSummary } from "@g1t/contracts"; | |
| 5 | − | ||
| 6 | − | import type { Route } from "./+types/accounts"; | |
| 7 | − | import { | |
| 8 | − | Avatar, | |
| 9 | − | Button, | |
| 10 | − | ButtonLink, | |
| 11 | − | EmptyState, | |
| 12 | − | ExposureBar, | |
| 13 | − | KindBadge, | |
| 14 | − | Stat, | |
| 15 | − | TermsBadge, | |
| 16 | − | TrustBadge, | |
| 17 | − | } from "~/components/ui"; | |
| 18 | − | import { usd } from "~/lib/money"; | |
| 19 | − | import { admin } from "~/lib/services.server"; | |
| 20 | − | import { requireStaff } from "~/lib/staff"; | |
| 21 | − | ||
| 22 | − | export const meta: Route.MetaFunction = () => [{ title: "Accounts · sudo" }, { name: "robots", content: "noindex, nofollow" }]; | |
| 23 | − | ||
| 24 | − | const FILTERS = { | |
| 25 | − | attention: { label: "Stopped or warning", test: (row: AccountSummary) => row.limit.state !== "ok" }, | |
| 26 | − | terms: { label: "Comped or custom", test: (row: AccountSummary) => row.account.terms.kind !== "standard" }, | |
| 27 | − | enterprise: { label: "Enterprises", test: (row: AccountSummary) => row.account.kind === "enterprise" }, | |
| 28 | − | } as const; | |
| 29 | − | ||
| 30 | − | type Filter = keyof typeof FILTERS; | |
| 31 | − | ||
| 32 | − | const SEVERITY = { stopped: 0, warning: 1, ok: 2 } as const; | |
| 33 | − | ||
| 34 | − | export async function loader({ request, context }: Route.LoaderArgs) { | |
| 35 | − | requireStaff(context); | |
| 36 | − | const url = new URL(request.url); | |
| 37 | − | const q = (url.searchParams.get("q") ?? "").trim().slice(0, 100); | |
| 38 | − | const show = url.searchParams.getAll("show").filter((value): value is Filter => value in FILTERS); | |
| 39 | − | ||
| 40 | − | const all = await admin.accounts(q || undefined); | |
| 41 | − | const rows = all | |
| 42 | − | .filter((row) => show.every((filter) => FILTERS[filter].test(row))) | |
| 43 | − | .sort( | |
| 44 | − | (a, b) => | |
| 45 | − | SEVERITY[a.limit.state] - SEVERITY[b.limit.state] || | |
| 46 | − | b.limit.exposureMicros - a.limit.exposureMicros || | |
| 47 | − | a.account.name.localeCompare(b.account.name), | |
| 48 | − | ); | |
| 49 | − | ||
| 50 | − | const sum = (pick: (row: AccountSummary) => number) => all.reduce((total, row) => total + pick(row), 0); | |
| 51 | − | return { | |
| 52 | − | q, | |
| 53 | − | show, | |
| 54 | − | rows, | |
| 55 | − | total: all.length, | |
| 56 | − | totals: { | |
| 57 | − | charged: sum((row) => row.chargedMicros), | |
| 58 | − | cost: sum((row) => row.costMicros), | |
| 59 | − | paid: sum((row) => row.paidMicros), | |
| 60 | − | exposure: sum((row) => row.limit.exposureMicros), | |
| 61 | − | stopped: all.filter((row) => row.limit.state === "stopped").length, | |
| 62 | − | warning: all.filter((row) => row.limit.state === "warning").length, | |
| 63 | − | }, | |
| 64 | − | }; | |
| 65 | − | } | |
| 66 | − | ||
| 67 | − | function accountHref(row: AccountSummary) { | |
| 68 | − | return `/accounts/${encodeURIComponent(row.account.id)}`; | |
| 69 | − | } | |
| 70 | − | ||
| 71 | − | export default function Accounts({ loaderData }: Route.ComponentProps) { | |
| 72 | − | const { q, show, rows, total, totals } = loaderData; | |
| 73 | − | const margin = totals.charged - totals.cost; | |
| 74 | − | const filtered = q !== "" || show.length > 0; | |
| 75 | − | ||
| 76 | − | return ( | |
| 77 | − | <main className="mx-auto max-w-6xl px-4 py-8 sm:py-10"> | |
| 78 | − | <div className="flex flex-wrap items-end justify-between gap-4"> | |
| 79 | − | <div> | |
| 80 | − | <h1 className="text-2xl font-semibold tracking-tight">Accounts</h1> | |
| 81 | − | <p className="mt-1 text-sm text-muted">Every account that pays, and where it stands this month.</p> | |
| 82 | − | </div> | |
| 83 | − | <ButtonLink to="/enterprises/new" variant="lavender"> | |
| 84 | − | <Building2 size={15} /> | |
| 85 | − | New enterprise | |
| 86 | − | </ButtonLink> | |
| 87 | − | </div> | |
| 88 | − | ||
| 89 | − | <div className="mt-6 grid grid-cols-2 gap-3 lg:grid-cols-4"> | |
| 90 | − | <Stat label="Charged this month" value={usd(totals.charged)} hint={`${total} account${total === 1 ? "" : "s"}`} /> | |
| 91 | − | <Stat label="Cost to g1t" value={usd(totals.cost)} hint={`Margin ${usd(margin)}`} tone={margin < 0 ? "danger" : undefined} /> | |
| 92 | − | <Stat label="Unpaid exposure" value={usd(totals.exposure)} hint={`Paid ever ${usd(totals.paid)}`} /> | |
| 93 | − | <Stat | |
| 94 | − | label="Needs attention" | |
| 95 | − | value={`${totals.stopped} stopped`} | |
| 96 | − | hint={`${totals.warning} near the ceiling`} | |
| 97 | − | tone={totals.stopped > 0 ? "danger" : totals.warning > 0 ? "warn" : "mint"} | |
| 98 | − | /> | |
| 99 | − | </div> | |
| 100 | − | ||
| 101 | − | <form method="get" action="/" role="search" className="mt-6 flex flex-col gap-3 lg:flex-row lg:items-center"> | |
| 102 | − | <div className="relative grow"> | |
| 103 | − | <Search size={14} className="pointer-events-none absolute top-1/2 left-3 -translate-y-1/2 text-faint" /> | |
| 104 | − | <input | |
| 105 | − | type="search" | |
| 106 | − | name="q" | |
| 107 | − | defaultValue={q} | |
| 108 | − | placeholder="Search by workspace, account id or enterprise" | |
| 109 | − | aria-label="Search accounts" | |
| 110 | − | autoComplete="off" | |
| 111 | − | data-1p-ignore | |
| 112 | − | className="w-full rounded-md border border-line bg-bg py-2 pr-3 pl-8 text-sm outline-none transition-colors placeholder:text-faint hover:border-line-strong focus:border-merged/60" | |
| 113 | − | /> | |
| 114 | − | </div> | |
| 115 | − | <fieldset className="flex flex-wrap items-center gap-2"> | |
| 116 | − | <legend className="sr-only">Show only</legend> | |
| 117 | − | {(Object.keys(FILTERS) as Filter[]).map((key) => ( | |
| 118 | − | <label | |
| 119 | − | key={key} | |
| 120 | − | className="inline-flex cursor-pointer items-center gap-2 rounded-full border border-line px-3 py-1.5 text-xs text-muted transition-colors select-none hover:border-line-strong has-checked:border-merged/50 has-checked:bg-merged/10 has-checked:text-merged" | |
| 121 | − | > | |
| 122 | − | <input type="checkbox" name="show" value={key} defaultChecked={show.includes(key)} className="size-3.5" /> | |
| 123 | − | {FILTERS[key].label} | |
| 124 | − | </label> | |
| 125 | − | ))} | |
| 126 | − | <Button type="submit" variant="quiet" className="py-1.5"> | |
| 127 | − | Apply | |
| 128 | − | </Button> | |
| 129 | − | {filtered && ( | |
| 130 | − | <Link to="/" className="px-1 text-xs text-muted underline-offset-4 hover:text-fg hover:underline"> | |
| 131 | − | Clear | |
| 132 | − | </Link> | |
| 133 | − | )} | |
| 134 | − | </fieldset> | |
| 135 | − | </form> | |
| 136 | − | ||
| 137 | − | <p className="mt-4 text-xs text-faint"> | |
| 138 | − | {rows.length === total ? `${total} accounts` : `${rows.length} of ${total} accounts`} | |
| 139 | − | {q && ( | |
| 140 | − | <> | |
| 141 | − | {" "} | |
| 142 | − | matching <span className="font-mono text-muted">{q}</span> | |
| 143 | − | </> | |
| 144 | − | )} | |
| 145 | − | . Stopped and warning first, then by exposure. | |
| 146 | − | </p> | |
| 147 | − | ||
| 148 | − | {rows.length === 0 ? ( | |
| 149 | − | <div className="mt-3"> | |
| 150 | − | <EmptyState title={filtered ? "No accounts match" : "No accounts yet"}> | |
| 151 | − | {filtered ? "Try another search, or clear the filters." : "Accounts appear once a workspace exists."} | |
| 152 | − | </EmptyState> | |
| 153 | − | </div> | |
| 154 | − | ) : ( | |
| 155 | − | <> | |
| 156 | − | {/* Phones: one card per account. */} | |
| 157 | − | <ul className="mt-3 space-y-2 md:hidden"> | |
| 158 | − | {rows.map((row) => ( | |
| 159 | − | <li key={row.account.id}> | |
| 160 | − | <Link to={accountHref(row)} className="block rounded-lg border border-line bg-surface p-4 transition-colors hover:border-line-strong"> | |
| 161 | − | <div className="flex items-start justify-between gap-3"> | |
| 162 | − | <AccountName row={row} /> | |
| 163 | − | <ChevronRight size={16} className="mt-0.5 shrink-0 text-faint" /> | |
| 164 | − | </div> | |
| 165 | − | <div className="mt-3"> | |
| 166 | − | <ExposureBar limit={row.limit} wide /> | |
| 167 | − | </div> | |
| 168 | − | <dl className="tabular mt-3 grid grid-cols-3 gap-2 text-xs"> | |
| 169 | − | <Figure label="Charged" value={usd(row.chargedMicros)} /> | |
| 170 | − | <Figure label="Cost" value={usd(row.costMicros)} /> | |
| 171 | − | <Figure label="Paid ever" value={usd(row.paidMicros)} /> | |
| 172 | − | </dl> | |
| 173 | − | </Link> | |
| 174 | − | </li> | |
| 175 | − | ))} | |
| 176 | − | </ul> | |
| 177 | − | ||
| 178 | − | {/* Wider screens: a table. */} | |
| 179 | − | <div className="mt-3 hidden overflow-x-auto rounded-lg border border-line md:block"> | |
| 180 | − | <table className="w-full text-sm"> | |
| 181 | − | <thead> | |
| 182 | − | <tr className="border-b border-line bg-surface text-left text-xs text-muted"> | |
| 183 | − | <th className="px-4 py-2.5 font-medium">Account</th> | |
| 184 | − | <th className="px-4 py-2.5 font-medium">Trust</th> | |
| 185 | − | <th className="px-4 py-2.5 font-medium">Exposure / ceiling</th> | |
| 186 | − | <th className="px-4 py-2.5 text-right font-medium">Charged</th> | |
| 187 | − | <th className="px-4 py-2.5 text-right font-medium">Cost to g1t</th> | |
| 188 | − | <th className="px-4 py-2.5 text-right font-medium">Paid ever</th> | |
| 189 | − | </tr> | |
| 190 | − | </thead> | |
| 191 | − | <tbody> | |
| 192 | − | {rows.map((row) => ( | |
| 193 | − | <tr key={row.account.id} className="border-b border-line last:border-0 hover:bg-surface/60"> | |
| 194 | − | <td className="px-4 py-3"> | |
| 195 | − | <Link to={accountHref(row)} className="group block"> | |
| 196 | − | <AccountName row={row} /> | |
| 197 | − | </Link> | |
| 198 | − | </td> | |
| 199 | − | <td className="px-4 py-3"> | |
| 200 | − | <TrustBadge trust={row.limit.trust} /> | |
| 201 | − | </td> | |
| 202 | − | <td className="px-4 py-3"> | |
| 203 | − | <ExposureBar limit={row.limit} /> | |
| 204 | − | </td> | |
| 205 | − | <td className="tabular px-4 py-3 text-right">{usd(row.chargedMicros)}</td> | |
| 206 | − | <td className="tabular px-4 py-3 text-right text-muted">{usd(row.costMicros)}</td> | |
| 207 | − | <td className="tabular px-4 py-3 text-right text-muted">{usd(row.paidMicros)}</td> | |
| 208 | − | </tr> | |
| 209 | − | ))} | |
| 210 | − | </tbody> | |
| 211 | − | </table> | |
| 212 | − | </div> | |
| 213 | − | </> | |
| 214 | − | )} | |
| 215 | − | </main> | |
| 216 | − | ); | |
| 217 | − | } | |
| 218 | − | ||
| 219 | − | function AccountName({ row }: { row: AccountSummary }) { | |
| 220 | − | const { account } = row; | |
| 221 | − | const members = account.workspaces.length; | |
| 222 | − | return ( | |
| 223 | − | <div className="flex min-w-0 items-start gap-2.5"> | |
| 224 | − | <span className="mt-0.5"> | |
| 225 | − | <Avatar name={account.name} size={22} /> | |
| 226 | − | </span> | |
| 227 | − | <div className="min-w-0"> | |
| 228 | − | <p className="truncate font-medium group-hover:underline group-hover:underline-offset-4">{account.name}</p> | |
| 229 | − | <p className="truncate font-mono text-xs text-faint"> | |
| 230 | − | {account.id} | |
| 231 | − | {account.kind === "enterprise" && ` · ${members} workspace${members === 1 ? "" : "s"}`} | |
| 232 | − | </p> | |
| 233 | − | <div className="mt-1.5 flex flex-wrap gap-1.5"> | |
| 234 | − | <KindBadge kind={account.kind} /> | |
| 235 | − | <TermsBadge terms={account.terms} /> | |
| 236 | − | <span className="md:hidden"> | |
| 237 | − | <TrustBadge trust={row.limit.trust} /> | |
| 238 | − | </span> | |
| 239 | − | </div> | |
| 240 | − | </div> | |
| 241 | − | </div> | |
| 242 | − | ); | |
| 243 | − | } | |
| 244 | − | ||
| 245 | − | function Figure({ label, value }: { label: string; value: string }) { | |
| 246 | − | return ( | |
| 247 | − | <div> | |
| 248 | − | <dt className="text-faint">{label}</dt> | |
| 249 | − | <dd className="mt-0.5 text-fg-soft">{value}</dd> | |
| 250 | − | </div> | |
| 251 | − | ); | |
| 252 | − | } |
| 1 | + | import { ArrowLeft, Plus, Trash2 } from "lucide-react"; | |
| 2 | + | import { data, Link, redirect, useLocation } from "react-router"; | |
| 3 | + | ||
| 4 | + | import { type AdminOwner, type Limit, httpStatus } from "@g1t/contracts"; | |
| 5 | + | ||
| 6 | + | import type { Route } from "./+types/enterprise"; | |
| 7 | + | import { AuditSection, CreditForm, Figure, LedgerSection, ReviewPanel, TermsForm } from "~/components/billing"; | |
| 8 | + | import { Avatar, Badge, Button, EmptyState, ExposureBar, Field, Input, Notice, Section, StateBadge, TermsBadge, TrustBadge, When } from "~/components/ui"; | |
| 9 | + | import { type Subject, billingAction } from "~/lib/billing-actions.server"; | |
| 10 | + | import { usd } from "~/lib/money"; | |
| 11 | + | import { type ActionData, type SectionError, doneMessage } from "~/lib/review"; | |
| 12 | + | import { admin, identity } from "~/lib/services.server"; | |
| 13 | + | import { requireStaff } from "~/lib/staff"; | |
| 14 | + | import { legacyAccountPath } from "~/lib/workspaces"; | |
| 15 | + | ||
| 16 | + | export const meta: Route.MetaFunction = ({ loaderData }) => [ | |
| 17 | + | { title: `${loaderData?.detail.summary.account.name ?? "Enterprise"} · sudo` }, | |
| 18 | + | { name: "robots", content: "noindex, nofollow" }, | |
| 19 | + | ]; | |
| 20 | + | ||
| 21 | + | const ENTERPRISE_ID = /^ent_[a-z0-9_-]{1,80}$/; | |
| 22 | + | ||
| 23 | + | async function load(raw: string) { | |
| 24 | + | const id = raw.trim().toLowerCase(); | |
| 25 | + | if (!ENTERPRISE_ID.test(id)) { | |
| 26 | + | // A workspace's id or slug: its own page. | |
| 27 | + | const path = legacyAccountPath(id); | |
| 28 | + | if (path) throw redirect(path); | |
| 29 | + | throw data("That is not an enterprise.", { status: 404 }); | |
| 30 | + | } | |
| 31 | + | const result = await admin.account(id); | |
| 32 | + | if (!result.ok) throw data(result.error.message, { status: httpStatus(result.error) }); | |
| 33 | + | const detail = result.value; | |
| 34 | + | const { account } = detail.summary; | |
| 35 | + | if (account.kind !== "enterprise") throw data("That is not an enterprise.", { status: 404 }); | |
| 36 | + | const subject: Subject = { kind: "enterprise", accountId: account.id, name: account.name, terms: account.terms, workspaces: account.workspaces }; | |
| 37 | + | return { detail, subject }; | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | export async function loader({ request, params, context }: Route.LoaderArgs) { | |
| 41 | + | requireStaff(context); | |
| 42 | + | const { detail } = await load(params.id); | |
| 43 | + | const slugs = detail.summary.account.workspaces; | |
| 44 | + | const listed = await identity.workspaces(); | |
| 45 | + | const people = new Map(listed.map((workspace) => [workspace.slug, { name: workspace.name, owners: workspace.owners }])); | |
| 46 | + | // Members older than identity's list: look them up one by one. | |
| 47 | + | const missing = slugs.filter((slug) => !people.has(slug)).slice(0, 25); | |
| 48 | + | for (const found of await Promise.all(missing.map((slug) => identity.workspace(slug)))) { | |
| 49 | + | if (found) { | |
| 50 | + | people.set(found.slug, { | |
| 51 | + | name: found.name, | |
| 52 | + | owners: found.members.filter((member) => member.role === "owner").map(({ username, email }) => ({ username, email })), | |
| 53 | + | }); | |
| 54 | + | } | |
| 55 | + | } | |
| 56 | + | const limits = new Map(detail.workspaces.map((limit) => [limit.workspace, limit])); | |
| 57 | + | const shares = new Map((detail.summary.byWorkspace ?? []).map((share) => [share.workspace, share])); | |
| 58 | + | const members = slugs.map((slug) => ({ | |
| 59 | + | slug, | |
| 60 | + | name: people.get(slug)?.name ?? null, | |
| 61 | + | owners: people.get(slug)?.owners ?? ([] as AdminOwner[]), | |
| 62 | + | limit: limits.get(slug) ?? null, | |
| 63 | + | chargedMicros: shares.get(slug)?.chargedMicros ?? 0, | |
| 64 | + | })); | |
| 65 | + | return { detail, members, done: doneMessage(request.url) }; | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | export async function action({ request, params, context }: Route.ActionArgs) { | |
| 69 | + | const staff = requireStaff(context); | |
| 70 | + | // What is acted on comes from billing, not from the form. | |
| 71 | + | const { subject } = await load(params.id); | |
| 72 | + | return billingAction(request, staff, subject, `/enterprises/${encodeURIComponent(subject.accountId)}`); | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | type Member = Route.ComponentProps["loaderData"]["members"][number]; | |
| 76 | + | ||
| 77 | + | export default function Enterprise({ loaderData, actionData }: Route.ComponentProps) { | |
| 78 | + | const { detail, members, done } = loaderData; | |
| 79 | + | const { summary } = detail; | |
| 80 | + | const { account, limit } = summary; | |
| 81 | + | const { pathname } = useLocation(); | |
| 82 | + | const result = actionData as ActionData | undefined; | |
| 83 | + | const review = result && "review" in result ? result.review : null; | |
| 84 | + | const error = (section: string): SectionError => (result && "error" in result && result.section === section ? result : null); | |
| 85 | + | ||
| 86 | + | return ( | |
| 87 | + | <main id="top" className="mx-auto max-w-6xl scroll-mt-20 px-4 py-8 sm:py-10"> | |
| 88 | + | <Link to="/enterprises" className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-fg"> | |
| 89 | + | <ArrowLeft size={14} /> | |
| 90 | + | Enterprises | |
| 91 | + | </Link> | |
| 92 | + | ||
| 93 | + | <div className="mt-4 flex flex-wrap items-start justify-between gap-4"> | |
| 94 | + | <div className="flex min-w-0 items-start gap-3"> | |
| 95 | + | <Avatar name={account.name} size={40} /> | |
| 96 | + | <div className="min-w-0"> | |
| 97 | + | <h1 className="truncate text-2xl font-semibold tracking-tight">{account.name}</h1> | |
| 98 | + | <p className="mt-0.5 text-xs text-faint"> | |
| 99 | + | Enterprise since <When at={account.createdAt} /> · pays for {members.length} workspace{members.length === 1 ? "" : "s"} | |
| 100 | + | </p> | |
| 101 | + | <div className="mt-2 flex flex-wrap gap-1.5"> | |
| 102 | + | <Badge tone="lavender">Enterprise</Badge> | |
| 103 | + | <TermsBadge terms={account.terms} /> | |
| 104 | + | {account.terms.kind !== "comped" && <TrustBadge trust={limit.trust} />} | |
| 105 | + | <StateBadge state={limit.state} /> | |
| 106 | + | </div> | |
| 107 | + | </div> | |
| 108 | + | </div> | |
| 109 | + | <p className="text-xs text-faint"> | |
| 110 | + | Billing account id <span className="font-mono">{account.id}</span> | |
| 111 | + | </p> | |
| 112 | + | </div> | |
| 113 | + | ||
| 114 | + | <div className="mt-6 space-y-3"> | |
| 115 | + | {done && <Notice tone="ok">{done}</Notice>} | |
| 116 | + | {error("top") && <Notice tone="error">{error("top")?.error}</Notice>} | |
| 117 | + | {limit.message && <Notice tone={limit.state === "stopped" ? "error" : limit.state === "warning" ? "warn" : "info"}>{limit.message}</Notice>} | |
| 118 | + | {review && <ReviewPanel review={review} pathname={pathname} />} | |
| 119 | + | </div> | |
| 120 | + | ||
| 121 | + | <div className="mt-6 grid gap-3 sm:grid-cols-2 lg:grid-cols-4"> | |
| 122 | + | <div className="rounded-lg border border-line bg-surface px-4 py-3 sm:col-span-2"> | |
| 123 | + | <p className="mb-2 text-xs text-muted">Usage this month against the limit, all workspaces together</p> | |
| 124 | + | <ExposureBar limit={limit} wide /> | |
| 125 | + | <p className="mt-2 text-xs text-faint"> | |
| 126 | + | Limit from trust {usd(limit.trustCeilingMicros)} | |
| 127 | + | {account.terms.ceilingMicros != null && <> · custom limit {usd(account.terms.ceilingMicros)}</>} | |
| 128 | + | </p> | |
| 129 | + | </div> | |
| 130 | + | <Figure label="Charged this month" value={usd(summary.chargedMicros)} hint={`Cost to g1t ${usd(summary.costMicros)}`} /> | |
| 131 | + | <Figure label="Paid ever" value={usd(summary.paidMicros)} hint={`Margin this month ${usd(summary.chargedMicros - summary.costMicros)}`} /> | |
| 132 | + | </div> | |
| 133 | + | ||
| 134 | + | <div className="mt-6 grid gap-6 lg:grid-cols-[minmax(0,3fr)_minmax(0,2fr)]"> | |
| 135 | + | <div className="space-y-6"> | |
| 136 | + | <MembersSection members={members} pathname={pathname} error={error("members")} /> | |
| 137 | + | <TermsForm terms={account.terms} pathname={pathname} error={error("terms")} /> | |
| 138 | + | <LedgerSection ledger={detail.ledger} showWorkspace description="Recent lines for every workspace it pays for, newest first." /> | |
| 139 | + | </div> | |
| 140 | + | <div className="space-y-6"> | |
| 141 | + | <CreditForm workspaces={account.workspaces} pathname={pathname} error={error("credit")} /> | |
| 142 | + | <AuditSection audit={detail.audit} /> | |
| 143 | + | </div> | |
| 144 | + | </div> | |
| 145 | + | </main> | |
| 146 | + | ); | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | function MembersSection({ members, pathname, error }: { members: Member[]; pathname: string; error: SectionError }) { | |
| 150 | + | return ( | |
| 151 | + | <Section id="members" title="Workspaces" description="Billed together: one bill, one limit, one set of terms."> | |
| 152 | + | {error && ( | |
| 153 | + | <div className="mb-4"> | |
| 154 | + | <Notice tone="error">{error.error}</Notice> | |
| 155 | + | </div> | |
| 156 | + | )} | |
| 157 | + | {members.length === 0 ? ( | |
| 158 | + | <EmptyState title="No workspaces yet">Add one below to bill it to this enterprise.</EmptyState> | |
| 159 | + | ) : ( | |
| 160 | + | <ul className="divide-y divide-line rounded-md border border-line"> | |
| 161 | + | {members.map((member) => ( | |
| 162 | + | <li key={member.slug} className="flex flex-col gap-3 p-3 sm:flex-row sm:items-center"> | |
| 163 | + | <div className="flex min-w-0 grow items-start gap-2.5"> | |
| 164 | + | <Avatar name={member.slug} size={22} /> | |
| 165 | + | <div className="min-w-0"> | |
| 166 | + | <Link to={`/workspaces/${encodeURIComponent(member.slug)}`} className="font-medium hover:underline hover:underline-offset-4"> | |
| 167 | + | {member.name ?? member.slug} | |
| 168 | + | </Link> | |
| 169 | + | <p className="truncate text-xs text-faint"> | |
| 170 | + | <span className="font-mono">{member.slug}</span> | |
| 171 | + | {member.owners.length > 0 && ( | |
| 172 | + | <> | |
| 173 | + | {" · "} | |
| 174 | + | {member.owners.map((owner, index) => ( | |
| 175 | + | <span key={owner.username} title={owner.email ?? "No email"}> | |
| 176 | + | {index > 0 && ", "} | |
| 177 | + | {owner.username} | |
| 178 | + | </span> | |
| 179 | + | ))} | |
| 180 | + | </> | |
| 181 | + | )} | |
| 182 | + | </p> | |
| 183 | + | {member.limit?.message && <p className="mt-1 text-xs text-muted">{member.limit.message}</p>} | |
| 184 | + | </div> | |
| 185 | + | </div> | |
| 186 | + | <div className="flex items-center justify-between gap-3 sm:w-56 sm:justify-end"> | |
| 187 | + | <p className="tabular text-right text-xs text-muted"> | |
| 188 | + | Charged <span className="text-fg-soft">{usd(member.chargedMicros)}</span> | |
| 189 | + | {member.limit && <MemberState limit={member.limit} />} | |
| 190 | + | </p> | |
| 191 | + | <DetachButton workspace={member.slug} pathname={pathname} /> | |
| 192 | + | </div> | |
| 193 | + | </li> | |
| 194 | + | ))} | |
| 195 | + | </ul> | |
| 196 | + | )} | |
| 197 | + | <form method="post" action={`${pathname}#review`} className="mt-4 flex flex-col gap-2 sm:flex-row sm:items-end"> | |
| 198 | + | <input type="hidden" name="intent" value="attach" /> | |
| 199 | + | <Field label="Add a workspace" className="grow"> | |
| 200 | + | <Input name="workspace" required placeholder="workspace-slug" defaultValue={error?.values?.workspace ?? ""} className="font-mono" /> | |
| 201 | + | </Field> | |
| 202 | + | <Button type="submit" variant="quiet"> | |
| 203 | + | <Plus size={14} /> | |
| 204 | + | Add | |
| 205 | + | </Button> | |
| 206 | + | </form> | |
| 207 | + | </Section> | |
| 208 | + | ); | |
| 209 | + | } | |
| 210 | + | ||
| 211 | + | function MemberState({ limit }: { limit: Limit }) { | |
| 212 | + | if (limit.state === "ok") return null; | |
| 213 | + | return ( | |
| 214 | + | <span className="mt-1 block"> | |
| 215 | + | <StateBadge state={limit.state} /> | |
| 216 | + | </span> | |
| 217 | + | ); | |
| 218 | + | } | |
| 219 | + | ||
| 220 | + | function DetachButton({ workspace, pathname }: { workspace: string; pathname: string }) { | |
| 221 | + | return ( | |
| 222 | + | <form method="post" action={`${pathname}#review`} className="shrink-0"> | |
| 223 | + | <input type="hidden" name="intent" value="detach" /> | |
| 224 | + | <input type="hidden" name="workspace" value={workspace} /> | |
| 225 | + | <button | |
| 226 | + | type="submit" | |
| 227 | + | aria-label={`Remove ${workspace}`} | |
| 228 | + | title={`Remove ${workspace}`} | |
| 229 | + | className="rounded-md border border-line p-2 text-muted transition-colors hover:border-danger/50 hover:text-danger" | |
| 230 | + | > | |
| 231 | + | <Trash2 size={14} /> | |
| 232 | + | </button> | |
| 233 | + | </form> | |
| 234 | + | ); | |
| 235 | + | } |
| 1 | + | import { Building2, ChevronRight } from "lucide-react"; | |
| 2 | + | import { Link } from "react-router"; | |
| 3 | + | ||
| 4 | + | import type { AdminOwner } from "@g1t/contracts"; | |
| 5 | + | ||
| 6 | + | import type { Route } from "./+types/enterprises"; | |
| 7 | + | import { Avatar, ButtonLink, EmptyState, ExposureBar, Stat, StateBadge, TermsBadge, When } from "~/components/ui"; | |
| 8 | + | import { usd } from "~/lib/money"; | |
| 9 | + | import { admin, identity } from "~/lib/services.server"; | |
| 10 | + | import { requireStaff } from "~/lib/staff"; | |
| 11 | + | ||
| 12 | + | export const meta: Route.MetaFunction = () => [{ title: "Enterprises · sudo" }, { name: "robots", content: "noindex, nofollow" }]; | |
| 13 | + | ||
| 14 | + | export async function loader({ context }: Route.LoaderArgs) { | |
| 15 | + | requireStaff(context); | |
| 16 | + | const [accounts, workspaces] = await Promise.all([admin.accounts(), identity.workspaces()]); | |
| 17 | + | const owners = new Map<string, AdminOwner[]>(workspaces.map((workspace) => [workspace.slug, workspace.owners])); | |
| 18 | + | const enterprises = accounts | |
| 19 | + | .filter((row) => row.account.kind === "enterprise") | |
| 20 | + | .sort((a, b) => a.account.name.localeCompare(b.account.name)) | |
| 21 | + | .map((row) => ({ | |
| 22 | + | ...row, | |
| 23 | + | members: row.account.workspaces.map((slug) => ({ slug, owners: owners.get(slug) ?? [] })), | |
| 24 | + | })); | |
| 25 | + | const sum = (pick: (row: (typeof enterprises)[number]) => number) => enterprises.reduce((total, row) => total + pick(row), 0); | |
| 26 | + | return { | |
| 27 | + | enterprises, | |
| 28 | + | totals: { | |
| 29 | + | charged: sum((row) => row.chargedMicros), | |
| 30 | + | cost: sum((row) => row.costMicros), | |
| 31 | + | workspaces: sum((row) => row.account.workspaces.length), | |
| 32 | + | }, | |
| 33 | + | }; | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | export default function Enterprises({ loaderData }: Route.ComponentProps) { | |
| 37 | + | const { enterprises, totals } = loaderData; | |
| 38 | + | return ( | |
| 39 | + | <main className="mx-auto max-w-6xl px-4 py-8 sm:py-10"> | |
| 40 | + | <div className="flex flex-wrap items-end justify-between gap-4"> | |
| 41 | + | <div> | |
| 42 | + | <h1 className="text-2xl font-semibold tracking-tight">Enterprises</h1> | |
| 43 | + | <p className="mt-1 text-sm text-muted">Each pays for several workspaces: one bill, one limit, one set of terms.</p> | |
| 44 | + | </div> | |
| 45 | + | <ButtonLink to="/enterprises/new" variant="lavender"> | |
| 46 | + | <Building2 size={15} /> | |
| 47 | + | New enterprise | |
| 48 | + | </ButtonLink> | |
| 49 | + | </div> | |
| 50 | + | ||
| 51 | + | <div className="mt-6 grid grid-cols-2 gap-3 lg:grid-cols-3"> | |
| 52 | + | <Stat label="Enterprises" value={String(enterprises.length)} hint={`Paying for ${totals.workspaces} workspace${totals.workspaces === 1 ? "" : "s"}`} /> | |
| 53 | + | <Stat label="Charged this month" value={usd(totals.charged)} /> | |
| 54 | + | <Stat label="Cost to g1t" value={usd(totals.cost)} hint={`Margin ${usd(totals.charged - totals.cost)}`} tone={totals.charged < totals.cost ? "danger" : undefined} /> | |
| 55 | + | </div> | |
| 56 | + | ||
| 57 | + | {enterprises.length === 0 ? ( | |
| 58 | + | <div className="mt-6"> | |
| 59 | + | <EmptyState title="No enterprises yet">Create one to bill several workspaces together.</EmptyState> | |
| 60 | + | </div> | |
| 61 | + | ) : ( | |
| 62 | + | <ul className="mt-6 space-y-3"> | |
| 63 | + | {enterprises.map((row) => ( | |
| 64 | + | <li key={row.account.id}> | |
| 65 | + | <Link | |
| 66 | + | to={`/enterprises/${encodeURIComponent(row.account.id)}`} | |
| 67 | + | className="group grid gap-4 rounded-lg border border-line bg-surface p-4 transition-colors hover:border-line-strong md:grid-cols-[minmax(0,2fr)_minmax(0,2fr)_minmax(0,1fr)] md:items-start" | |
| 68 | + | > | |
| 69 | + | <div className="flex min-w-0 items-start gap-2.5"> | |
| 70 | + | <Avatar name={row.account.name} size={28} /> | |
| 71 | + | <div className="min-w-0"> | |
| 72 | + | <p className="truncate font-medium group-hover:underline group-hover:underline-offset-4">{row.account.name}</p> | |
| 73 | + | <p className="truncate text-xs text-faint"> | |
| 74 | + | Since <When at={row.account.createdAt} /> | |
| 75 | + | <span className="font-mono"> · {row.account.id}</span> | |
| 76 | + | </p> | |
| 77 | + | <div className="mt-1.5 flex flex-wrap gap-1.5"> | |
| 78 | + | <TermsBadge terms={row.account.terms} /> | |
| 79 | + | <StateBadge state={row.limit.state} /> | |
| 80 | + | </div> | |
| 81 | + | </div> | |
| 82 | + | </div> | |
| 83 | + | <div className="min-w-0"> | |
| 84 | + | <p className="text-xs text-muted"> | |
| 85 | + | {row.members.length} workspace{row.members.length === 1 ? "" : "s"} | |
| 86 | + | </p> | |
| 87 | + | <ul className="mt-1 space-y-0.5 text-sm"> | |
| 88 | + | {row.members.slice(0, 5).map((member) => ( | |
| 89 | + | <li key={member.slug} className="truncate"> | |
| 90 | + | <span className="font-mono">{member.slug}</span> | |
| 91 | + | {member.owners.length > 0 && ( | |
| 92 | + | <span className="text-xs text-faint"> · {member.owners.map((owner) => owner.username).join(", ")}</span> | |
| 93 | + | )} | |
| 94 | + | </li> | |
| 95 | + | ))} | |
| 96 | + | {row.members.length > 5 && <li className="text-xs text-faint">and {row.members.length - 5} more</li>} | |
| 97 | + | </ul> | |
| 98 | + | </div> | |
| 99 | + | <div className="flex items-start justify-between gap-3"> | |
| 100 | + | <div className="min-w-0 grow"> | |
| 101 | + | <ExposureBar limit={row.limit} wide /> | |
| 102 | + | <p className="tabular mt-2 text-xs text-faint"> | |
| 103 | + | Charged {usd(row.chargedMicros)} · cost {usd(row.costMicros)} | |
| 104 | + | </p> | |
| 105 | + | </div> | |
| 106 | + | <ChevronRight size={16} className="mt-0.5 shrink-0 text-faint" /> | |
| 107 | + | </div> | |
| 108 | + | </Link> | |
| 109 | + | </li> | |
| 110 | + | ))} | |
| 111 | + | </ul> | |
| 112 | + | )} | |
| 113 | + | </main> | |
| 114 | + | ); | |
| 115 | + | } |
| 1 | + | import { data, redirect } from "react-router"; | |
| 2 | + | ||
| 3 | + | import type { Route } from "./+types/legacy-accounts"; | |
| 4 | + | import { requireStaff } from "~/lib/staff"; | |
| 5 | + | import { legacyAccountPath } from "~/lib/workspaces"; | |
| 6 | + | ||
| 7 | + | /** | |
| 8 | + | * Old links: `/accounts` was the list, `/accounts/ws_<slug>` a workspace's | |
| 9 | + | * own account and `/accounts/ent_…` an enterprise. | |
| 10 | + | */ | |
| 11 | + | export async function loader({ request, params, context }: Route.LoaderArgs) { | |
| 12 | + | requireStaff(context); | |
| 13 | + | const id = (params["*"] ?? "").replace(/\/+$/, ""); | |
| 14 | + | if (!id) return redirect(`/${new URL(request.url).search}`, 301); | |
| 15 | + | const path = legacyAccountPath(id); | |
| 16 | + | if (!path) throw data("That is not a workspace or an enterprise.", { status: 404 }); | |
| 17 | + | return redirect(path, 301); | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | export default function LegacyAccounts() { | |
| 21 | + | return null; | |
| 22 | + | } |
| 4 | 4 | import type { Route } from "./+types/new-enterprise"; | |
| 5 | 5 | import { Avatar, Button, Field, Input, Notice, Section, Textarea } from "~/components/ui"; | |
| 6 | 6 | import { fields, parseSlugList, text } from "~/lib/forms"; | |
| 7 | − | import { admin } from "~/lib/services.server"; | |
| 7 | + | import { admin, identity } from "~/lib/services.server"; | |
| 8 | 8 | import { requireStaff } from "~/lib/staff"; | |
| 9 | 9 | ||
| 10 | 10 | export const meta: Route.MetaFunction = () => [{ title: "New enterprise · sudo" }, { name: "robots", content: "noindex, nofollow" }]; | |
| 31 | 31 | if (!workspaces.ok) return fail(workspaces.error); | |
| 32 | 32 | if (workspaces.value.length === 0) return fail("Name at least one workspace for it to pay for."); | |
| 33 | 33 | if (workspaces.value.length > 100) return fail("At most 100 workspaces at once."); | |
| 34 | + | const found = await Promise.all(workspaces.value.map((slug) => identity.workspace(slug))); | |
| 35 | + | const unknown = workspaces.value.filter((_, index) => !found[index]); | |
| 36 | + | if (unknown.length > 0) return fail(`No workspace called ${unknown.join(", ")}.`); | |
| 34 | 37 | ||
| 35 | 38 | // Moving workspaces onto it changes who pays for them: confirm first. | |
| 36 | 39 | if (text(form, "confirm") !== "yes") { | |
| 38 | 41 | } | |
| 39 | 42 | const result = await admin.createEnterprise(name, workspaces.value, staff.email); | |
| 40 | 43 | if (!result.ok) return fail(result.error.message); | |
| 41 | − | return redirect(`/accounts/${encodeURIComponent(result.value.id)}?done=created#top`); | |
| 44 | + | return redirect(`/enterprises/${encodeURIComponent(result.value.id)}?done=created#top`); | |
| 42 | 45 | } | |
| 43 | 46 | ||
| 44 | 47 | export default function NewEnterprise({ actionData }: Route.ComponentProps) { | |
| 49 | 52 | ||
| 50 | 53 | return ( | |
| 51 | 54 | <main className="mx-auto max-w-2xl px-4 py-8 sm:py-10"> | |
| 52 | − | <Link to="/" className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-fg"> | |
| 55 | + | <Link to="/enterprises" className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-fg"> | |
| 53 | 56 | <ArrowLeft size={14} /> | |
| 54 | − | Accounts | |
| 57 | + | Enterprises | |
| 55 | 58 | </Link> | |
| 56 | 59 | <h1 className="mt-4 text-2xl font-semibold tracking-tight">New enterprise</h1> | |
| 57 | 60 | <p className="mt-1 text-sm text-muted"> | |
| 58 | − | One account that pays for several workspaces, as GitHub Enterprise does: one bill, one limit, one set of terms. Set its terms | |
| 61 | + | One customer that pays for several workspaces, as GitHub Enterprise does: one bill, one limit, one set of terms. Set its terms | |
| 59 | 62 | once it exists. | |
| 60 | 63 | </p> | |
| 61 | 64 | ||
| 63 | 66 | <section id="review" className="mt-6 scroll-mt-20 rounded-lg border border-merged/40 bg-merged/5 p-4 sm:p-5"> | |
| 64 | 67 | <h2 className="font-semibold tracking-tight">Create {review.name}?</h2> | |
| 65 | 68 | <p className="mt-1 text-sm text-muted"> | |
| 66 | − | These workspaces will be billed through it from now on, under its limit and terms instead of their own: | |
| 69 | + | These workspaces will be billed to it from now on, under its limit and terms instead of their own: | |
| 67 | 70 | </p> | |
| 68 | 71 | <ul className="mt-3 flex flex-wrap gap-2"> | |
| 69 | 72 | {review.workspaces.map((slug) => ( |
| 1 | + | import { ArrowLeft, Building2, LogOut } from "lucide-react"; | |
| 2 | + | import { data, Link, useLocation } from "react-router"; | |
| 3 | + | ||
| 4 | + | import { type AccountSummary, type AdminAction, httpStatus } from "@g1t/contracts"; | |
| 5 | + | ||
| 6 | + | import type { Route } from "./+types/workspace"; | |
| 7 | + | import { | |
| 8 | + | AuditSection, | |
| 9 | + | BillingLinkSection, | |
| 10 | + | CreditForm, | |
| 11 | + | Figure, | |
| 12 | + | LedgerSection, | |
| 13 | + | Owners, | |
| 14 | + | ReviewPanel, | |
| 15 | + | TermsForm, | |
| 16 | + | } from "~/components/billing"; | |
| 17 | + | import { Avatar, Badge, Button, EmptyState, ExposureBar, Field, Notice, Section, Select, StateBadge, TermsBadge, TrustBadge, When } from "~/components/ui"; | |
| 18 | + | import { type Subject, billingAction } from "~/lib/billing-actions.server"; | |
| 19 | + | import { parseSlug } from "~/lib/forms"; | |
| 20 | + | import { usd } from "~/lib/money"; | |
| 21 | + | import { type ActionData, type SectionError, doneMessage } from "~/lib/review"; | |
| 22 | + | import { admin, identity } from "~/lib/services.server"; | |
| 23 | + | import { requireStaff } from "~/lib/staff"; | |
| 24 | + | import type { Enterprise } from "~/lib/workspaces"; | |
| 25 | + | ||
| 26 | + | export const meta: Route.MetaFunction = ({ loaderData }) => [ | |
| 27 | + | { title: `${loaderData?.name ?? "Workspace"} · sudo` }, | |
| 28 | + | { name: "robots", content: "noindex, nofollow" }, | |
| 29 | + | ]; | |
| 30 | + | ||
| 31 | + | /** Whether an audit line is about `slug`, in an enterprise's log. */ | |
| 32 | + | function mentions(action: AdminAction, slug: string): boolean { | |
| 33 | + | // A slug is letters, digits and hyphens, none special in a pattern. | |
| 34 | + | return new RegExp(`(^|[^a-z0-9-])${slug}($|[^a-z0-9-])`).test(action.detail.toLowerCase()); | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | async function load(raw: string) { | |
| 38 | + | const parsed = parseSlug(raw); | |
| 39 | + | if (!parsed.ok) throw data("That is not a workspace slug.", { status: 404 }); | |
| 40 | + | const slug = parsed.value; | |
| 41 | + | const [person, result] = await Promise.all([identity.workspace(slug), admin.account(slug)]); | |
| 42 | + | if (!result.ok) throw data(result.error.message, { status: httpStatus(result.error) }); | |
| 43 | + | const detail = result.value; | |
| 44 | + | const { account } = detail.summary; | |
| 45 | + | const billedTo: Enterprise | null = account.kind === "enterprise" ? { id: account.id, name: account.name } : null; | |
| 46 | + | const known = person != null || billedTo != null || account.createdAt !== "" || detail.ledger.length > 0 || detail.audit.length > 0; | |
| 47 | + | if (!known) throw data(`There is no workspace called ${slug}.`, { status: 404 }); | |
| 48 | + | const subject: Subject = { kind: "workspace", slug, accountId: account.id, terms: account.terms, billedTo }; | |
| 49 | + | return { slug, person, detail, billedTo, subject }; | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | export async function loader({ request, params, context }: Route.LoaderArgs) { | |
| 53 | + | requireStaff(context); | |
| 54 | + | const { slug, person, detail, billedTo, subject } = await load(params.slug); | |
| 55 | + | const { summary } = detail; | |
| 56 | + | // On an enterprise, the limit is the enterprise's and the figures are | |
| 57 | + | // this workspace's share of its bill. | |
| 58 | + | const limit = (billedTo && detail.workspaces.find((member) => member.workspace === slug)) || summary.limit; | |
| 59 | + | const share = billedTo ? summary.byWorkspace?.find((row) => row.workspace === slug) : null; | |
| 60 | + | const figures = billedTo | |
| 61 | + | ? { charged: share?.chargedMicros ?? 0, cost: share?.costMicros ?? 0, paid: share?.paidMicros ?? 0 } | |
| 62 | + | : { charged: summary.chargedMicros, cost: summary.costMicros, paid: summary.paidMicros }; | |
| 63 | + | const enterprises = billedTo | |
| 64 | + | ? [] | |
| 65 | + | : (await admin.accounts()) | |
| 66 | + | .filter((row: AccountSummary) => row.account.kind === "enterprise") | |
| 67 | + | .map((row) => ({ id: row.account.id, name: row.account.name, members: row.account.workspaces.length })); | |
| 68 | + | return { | |
| 69 | + | slug, | |
| 70 | + | name: person?.name ?? slug, | |
| 71 | + | person, | |
| 72 | + | billedTo, | |
| 73 | + | terms: subject.terms, | |
| 74 | + | limit, | |
| 75 | + | figures, | |
| 76 | + | enterprises, | |
| 77 | + | ledger: billedTo ? detail.ledger.filter((entry) => !entry.workspace || entry.workspace === slug) : detail.ledger, | |
| 78 | + | audit: billedTo ? detail.audit.filter((entry) => mentions(entry, slug)) : detail.audit, | |
| 79 | + | done: doneMessage(request.url), | |
| 80 | + | }; | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | export async function action({ request, params, context }: Route.ActionArgs) { | |
| 84 | + | const staff = requireStaff(context); | |
| 85 | + | // What is acted on comes from billing and identity, not from the form. | |
| 86 | + | const { slug, subject } = await load(params.slug); | |
| 87 | + | return billingAction(request, staff, subject, `/workspaces/${encodeURIComponent(slug)}`); | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | export default function Workspace({ loaderData, actionData }: Route.ComponentProps) { | |
| 91 | + | const { slug, name, person, billedTo, terms, limit, figures, enterprises, ledger, audit, done } = loaderData; | |
| 92 | + | const { pathname } = useLocation(); | |
| 93 | + | const result = actionData as ActionData | undefined; | |
| 94 | + | const review = result && "review" in result ? result.review : null; | |
| 95 | + | const link = result && "link" in result ? result.link : null; | |
| 96 | + | const error = (section: string): SectionError => (result && "error" in result && result.section === section ? result : null); | |
| 97 | + | const owners = person?.members.filter((member) => member.role === "owner") ?? []; | |
| 98 | + | ||
| 99 | + | return ( | |
| 100 | + | <main id="top" className="mx-auto max-w-6xl scroll-mt-20 px-4 py-8 sm:py-10"> | |
| 101 | + | <Link to="/" className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-fg"> | |
| 102 | + | <ArrowLeft size={14} /> | |
| 103 | + | Workspaces | |
| 104 | + | </Link> | |
| 105 | + | ||
| 106 | + | {/* Header */} | |
| 107 | + | <div className="mt-4 flex flex-wrap items-start justify-between gap-4"> | |
| 108 | + | <div className="flex min-w-0 items-start gap-3"> | |
| 109 | + | <Avatar name={slug} size={40} /> | |
| 110 | + | <div className="min-w-0"> | |
| 111 | + | <h1 className="truncate text-2xl font-semibold tracking-tight">{name}</h1> | |
| 112 | + | <p className="mt-0.5 text-xs text-faint"> | |
| 113 | + | <span className="font-mono">{slug}</span> | |
| 114 | + | {person && ( | |
| 115 | + | <> | |
| 116 | + | {" · "}created <When at={person.createdAt} /> | |
| 117 | + | </> | |
| 118 | + | )} | |
| 119 | + | </p> | |
| 120 | + | {person?.description && <p className="mt-1 text-sm text-muted">{person.description}</p>} | |
| 121 | + | <div className="mt-2 flex flex-wrap gap-1.5"> | |
| 122 | + | {billedTo && <Badge tone="lavender">Billed to {billedTo.name}</Badge>} | |
| 123 | + | <TermsBadge terms={terms} /> | |
| 124 | + | {terms.kind !== "comped" && <TrustBadge trust={limit.trust} />} | |
| 125 | + | <StateBadge state={limit.state} /> | |
| 126 | + | </div> | |
| 127 | + | </div> | |
| 128 | + | </div> | |
| 129 | + | {owners.length > 0 && ( | |
| 130 | + | <div className="text-xs"> | |
| 131 | + | <p className="mb-1.5 text-faint">Owners</p> | |
| 132 | + | <Owners owners={owners} /> | |
| 133 | + | </div> | |
| 134 | + | )} | |
| 135 | + | </div> | |
| 136 | + | ||
| 137 | + | <div className="mt-6 space-y-3"> | |
| 138 | + | {done && <Notice tone="ok">{done}</Notice>} | |
| 139 | + | {error("top") && <Notice tone="error">{error("top")?.error}</Notice>} | |
| 140 | + | {limit.message && <Notice tone={limit.state === "stopped" ? "error" : limit.state === "warning" ? "warn" : "info"}>{limit.message}</Notice>} | |
| 141 | + | {review && <ReviewPanel review={review} pathname={pathname} />} | |
| 142 | + | </div> | |
| 143 | + | ||
| 144 | + | {/* People */} | |
| 145 | + | <Section title="Members" description="Everyone in the workspace. Owners manage its members and billing." className="mt-6"> | |
| 146 | + | {!person ? ( | |
| 147 | + | <Notice tone="warn">Identity has no record of {slug}; only billing knows it. It may have been deleted.</Notice> | |
| 148 | + | ) : person.members.length === 0 ? ( | |
| 149 | + | <EmptyState title="No members" /> | |
| 150 | + | ) : ( | |
| 151 | + | <div className="-mx-4 -my-4 overflow-x-auto sm:-mx-5 sm:-my-5"> | |
| 152 | + | <table className="w-full min-w-120 text-sm"> | |
| 153 | + | <thead> | |
| 154 | + | <tr className="border-b border-line text-left text-xs text-muted"> | |
| 155 | + | <th className="px-4 py-2 font-medium sm:pl-5">Member</th> | |
| 156 | + | <th className="px-4 py-2 font-medium">Email</th> | |
| 157 | + | <th className="px-4 py-2 font-medium">Role</th> | |
| 158 | + | <th className="px-4 py-2 font-medium sm:pr-5">Joined</th> | |
| 159 | + | </tr> | |
| 160 | + | </thead> | |
| 161 | + | <tbody> | |
| 162 | + | {person.members.map((member) => ( | |
| 163 | + | <tr key={member.username} className="border-b border-line last:border-0"> | |
| 164 | + | <td className="px-4 py-2.5 sm:pl-5"> | |
| 165 | + | <span className="inline-flex items-center gap-2 font-mono"> | |
| 166 | + | <Avatar name={member.username} size={18} square={false} /> | |
| 167 | + | {member.username} | |
| 168 | + | </span> | |
| 169 | + | </td> | |
| 170 | + | <td className="px-4 py-2.5 break-all text-muted">{member.email ?? <span className="text-faint">—</span>}</td> | |
| 171 | + | <td className="px-4 py-2.5">{member.role === "owner" ? <Badge tone="lavender">Owner</Badge> : <Badge>Member</Badge>}</td> | |
| 172 | + | <td className="px-4 py-2.5 text-xs whitespace-nowrap text-muted sm:pr-5"> | |
| 173 | + | <When at={member.joined} /> | |
| 174 | + | </td> | |
| 175 | + | </tr> | |
| 176 | + | ))} | |
| 177 | + | </tbody> | |
| 178 | + | </table> | |
| 179 | + | </div> | |
| 180 | + | )} | |
| 181 | + | </Section> | |
| 182 | + | ||
| 183 | + | {/* Billing */} | |
| 184 | + | <h2 className="mt-10 text-lg font-semibold tracking-tight">Billing</h2> | |
| 185 | + | <div className="mt-3 grid gap-3 sm:grid-cols-2 lg:grid-cols-4"> | |
| 186 | + | <div className="rounded-lg border border-line bg-surface px-4 py-3 sm:col-span-2"> | |
| 187 | + | <p className="mb-2 text-xs text-muted"> | |
| 188 | + | Usage this month against the limit{billedTo && <> · shared with every workspace {billedTo.name} pays for</>} | |
| 189 | + | </p> | |
| 190 | + | <ExposureBar limit={limit} wide /> | |
| 191 | + | <p className="mt-2 text-xs text-faint"> | |
| 192 | + | {billedTo ? <>Limit via {billedTo.name}</> : <>Limit from trust {usd(limit.trustCeilingMicros)}</>} · owner's spend limit{" "} | |
| 193 | + | {usd(limit.spendLimitMicros)} | |
| 194 | + | {terms.ceilingMicros != null && <> · custom limit {usd(terms.ceilingMicros)}</>} | |
| 195 | + | </p> | |
| 196 | + | </div> | |
| 197 | + | <Figure label="Charged this month" value={usd(figures.charged)} hint={`Cost to g1t ${usd(figures.cost)}`} /> | |
| 198 | + | <Figure label="Paid ever" value={usd(figures.paid)} hint={`Margin this month ${usd(figures.charged - figures.cost)}`} /> | |
| 199 | + | </div> | |
| 200 | + | ||
| 201 | + | <div className="mt-6 grid gap-6 lg:grid-cols-[minmax(0,3fr)_minmax(0,2fr)]"> | |
| 202 | + | <div className="space-y-6"> | |
| 203 | + | <BilledToSection billedTo={billedTo} enterprises={enterprises} pathname={pathname} error={error("billed-to")} /> | |
| 204 | + | {billedTo ? ( | |
| 205 | + | <Section id="terms" title="Terms" description={`Charged on ${billedTo.name}'s terms while it pays for this workspace.`}> | |
| 206 | + | <div className="flex flex-wrap items-center justify-between gap-3"> | |
| 207 | + | <TermsBadge terms={terms} /> | |
| 208 | + | <Link to={`/enterprises/${encodeURIComponent(billedTo.id)}#terms`} className="text-sm text-merged hover:underline hover:underline-offset-4"> | |
| 209 | + | Change them on {billedTo.name} | |
| 210 | + | </Link> | |
| 211 | + | </div> | |
| 212 | + | </Section> | |
| 213 | + | ) : ( | |
| 214 | + | <TermsForm terms={terms} pathname={pathname} error={error("terms")} /> | |
| 215 | + | )} | |
| 216 | + | <LedgerSection | |
| 217 | + | ledger={ledger} | |
| 218 | + | description={billedTo ? `This workspace's lines among ${billedTo.name}'s latest 100.` : "Recent lines of the statement, newest first."} | |
| 219 | + | /> | |
| 220 | + | </div> | |
| 221 | + | ||
| 222 | + | <div className="space-y-6"> | |
| 223 | + | <BillingLinkSection link={link} pathname={pathname} error={error("billing-link")} /> | |
| 224 | + | <CreditForm workspaces={[slug]} pathname={pathname} error={error("credit")} /> | |
| 225 | + | <AuditSection | |
| 226 | + | audit={audit} | |
| 227 | + | description={ | |
| 228 | + | billedTo ? ( | |
| 229 | + | <> | |
| 230 | + | Changes about this workspace in {billedTo.name}'s log.{" "} | |
| 231 | + | <Link to={`/enterprises/${encodeURIComponent(billedTo.id)}`} className="text-merged hover:underline"> | |
| 232 | + | Full log | |
| 233 | + | </Link> | |
| 234 | + | </> | |
| 235 | + | ) : undefined | |
| 236 | + | } | |
| 237 | + | /> | |
| 238 | + | </div> | |
| 239 | + | </div> | |
| 240 | + | </main> | |
| 241 | + | ); | |
| 242 | + | } | |
| 243 | + | ||
| 244 | + | function BilledToSection({ | |
| 245 | + | billedTo, | |
| 246 | + | enterprises, | |
| 247 | + | pathname, | |
| 248 | + | error, | |
| 249 | + | }: { | |
| 250 | + | billedTo: Enterprise | null; | |
| 251 | + | enterprises: { id: string; name: string; members: number }[]; | |
| 252 | + | pathname: string; | |
| 253 | + | error: SectionError; | |
| 254 | + | }) { | |
| 255 | + | return ( | |
| 256 | + | <Section id="billed-to" title="Billed to" description="Whether the workspace pays for itself, or an enterprise pays for it."> | |
| 257 | + | {error && ( | |
| 258 | + | <div className="mb-4"> | |
| 259 | + | <Notice tone="error">{error.error}</Notice> | |
| 260 | + | </div> | |
| 261 | + | )} | |
| 262 | + | {billedTo ? ( | |
| 263 | + | <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> | |
| 264 | + | <p className="text-sm text-muted"> | |
| 265 | + | <Link to={`/enterprises/${encodeURIComponent(billedTo.id)}`} className="font-medium text-fg hover:underline hover:underline-offset-4"> | |
| 266 | + | {billedTo.name} | |
| 267 | + | </Link>{" "} | |
| 268 | + | pays for it, under its limit and terms. | |
| 269 | + | </p> | |
| 270 | + | <form method="post" action={`${pathname}#review`}> | |
| 271 | + | <input type="hidden" name="intent" value="detach" /> | |
| 272 | + | <Button type="submit" variant="danger"> | |
| 273 | + | <LogOut size={14} /> | |
| 274 | + | Move off | |
| 275 | + | </Button> | |
| 276 | + | </form> | |
| 277 | + | </div> | |
| 278 | + | ) : enterprises.length === 0 ? ( | |
| 279 | + | <p className="text-sm text-muted"> | |
| 280 | + | Itself. There are no enterprises to move it onto yet;{" "} | |
| 281 | + | <Link to="/enterprises/new" className="text-merged hover:underline hover:underline-offset-4"> | |
| 282 | + | create one | |
| 283 | + | </Link> | |
| 284 | + | . | |
| 285 | + | </p> | |
| 286 | + | ) : ( | |
| 287 | + | <form method="post" action={`${pathname}#review`} className="flex flex-col gap-2 sm:flex-row sm:items-end"> | |
| 288 | + | <input type="hidden" name="intent" value="attach" /> | |
| 289 | + | <Field label="Itself. Move onto" className="grow"> | |
| 290 | + | <Select name="target" required defaultValue={error?.values?.target ?? ""}> | |
| 291 | + | <option value="" disabled> | |
| 292 | + | Choose an enterprise | |
| 293 | + | </option> | |
| 294 | + | {enterprises.map((enterprise) => ( | |
| 295 | + | <option key={enterprise.id} value={enterprise.id}> | |
| 296 | + | {enterprise.name} ({enterprise.members} workspace{enterprise.members === 1 ? "" : "s"}) | |
| 297 | + | </option> | |
| 298 | + | ))} | |
| 299 | + | </Select> | |
| 300 | + | </Field> | |
| 301 | + | <Button type="submit" variant="quiet"> | |
| 302 | + | <Building2 size={14} /> | |
| 303 | + | Move | |
| 304 | + | </Button> | |
| 305 | + | </form> | |
| 306 | + | )} | |
| 307 | + | </Section> | |
| 308 | + | ); | |
| 309 | + | } |
| 1 | + | import { redirect } from "react-router"; | |
| 2 | + | ||
| 3 | + | import type { Route } from "./+types/workspaces-redirect"; | |
| 4 | + | import { requireStaff } from "~/lib/staff"; | |
| 5 | + | ||
| 6 | + | /** The workspaces list lives at `/`; `/workspaces` goes there too. */ | |
| 7 | + | export async function loader({ request, context }: Route.LoaderArgs) { | |
| 8 | + | requireStaff(context); | |
| 9 | + | return redirect(`/${new URL(request.url).search}`); | |
| 10 | + | } | |
| 11 | + | ||
| 12 | + | export default function WorkspacesRedirect() { | |
| 13 | + | return null; | |
| 14 | + | } |
| 1 | + | import { ChevronRight, Search } from "lucide-react"; | |
| 2 | + | import { Link } from "react-router"; | |
| 3 | + | ||
| 4 | + | import { ADMIN_WORKSPACES_LIMIT } from "@g1t/contracts"; | |
| 5 | + | ||
| 6 | + | import type { Route } from "./+types/workspaces"; | |
| 7 | + | import { Owners } from "~/components/billing"; | |
| 8 | + | import { Avatar, Badge, Button, EmptyState, ExposureBar, Stat, TermsBadge, TrustBadge, When } from "~/components/ui"; | |
| 9 | + | import { usd } from "~/lib/money"; | |
| 10 | + | import { admin, identity } from "~/lib/services.server"; | |
| 11 | + | import { requireStaff } from "~/lib/staff"; | |
| 12 | + | import { type WorkspaceRow, joinWorkspaces, matchesQuery } from "~/lib/workspaces"; | |
| 13 | + | ||
| 14 | + | export const meta: Route.MetaFunction = () => [{ title: "Workspaces · sudo" }, { name: "robots", content: "noindex, nofollow" }]; | |
| 15 | + | ||
| 16 | + | const FILTERS = { | |
| 17 | + | attention: { label: "Stopped or warning", test: (row: WorkspaceRow) => row.billing.limit != null && row.billing.limit.state !== "ok" }, | |
| 18 | + | terms: { label: "Comped or custom", test: (row: WorkspaceRow) => row.billing.terms.kind !== "standard" }, | |
| 19 | + | enterprise: { label: "On an enterprise", test: (row: WorkspaceRow) => row.billing.billedTo != null }, | |
| 20 | + | } as const; | |
| 21 | + | ||
| 22 | + | type Filter = keyof typeof FILTERS; | |
| 23 | + | ||
| 24 | + | const SEVERITY = { stopped: 0, warning: 1, ok: 2 } as const; | |
| 25 | + | ||
| 26 | + | export async function loader({ request, context }: Route.LoaderArgs) { | |
| 27 | + | requireStaff(context); | |
| 28 | + | const url = new URL(request.url); | |
| 29 | + | const q = (url.searchParams.get("q") ?? "").trim().slice(0, 100); | |
| 30 | + | const show = url.searchParams.getAll("show").filter((value): value is Filter => value in FILTERS); | |
| 31 | + | ||
| 32 | + | const [found, accounts] = await Promise.all([identity.workspaces(q || undefined), admin.accounts()]); | |
| 33 | + | let workspaces = found; | |
| 34 | + | // A search for an enterprise's name finds the workspaces it pays for, | |
| 35 | + | // which identity knows nothing about. | |
| 36 | + | const lower = q.toLowerCase(); | |
| 37 | + | const enterpriseMembers = new Set( | |
| 38 | + | q | |
| 39 | + | ? accounts | |
| 40 | + | .filter((row) => row.account.kind === "enterprise" && row.account.name.toLowerCase().includes(lower)) | |
| 41 | + | .flatMap((row) => row.account.workspaces) | |
| 42 | + | : [], | |
| 43 | + | ); | |
| 44 | + | if (enterpriseMembers.size > 0) { | |
| 45 | + | const listed = new Set(found.map((workspace) => workspace.slug)); | |
| 46 | + | const extra = (await identity.workspaces()).filter((workspace) => enterpriseMembers.has(workspace.slug) && !listed.has(workspace.slug)); | |
| 47 | + | workspaces = [...found, ...extra]; | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | const all = joinWorkspaces(workspaces, accounts).filter((row) => matchesQuery(row, q)); | |
| 51 | + | const rows = all | |
| 52 | + | .filter((row) => show.every((filter) => FILTERS[filter].test(row))) | |
| 53 | + | .sort( | |
| 54 | + | (a, b) => | |
| 55 | + | SEVERITY[a.billing.limit?.state ?? "ok"] - SEVERITY[b.billing.limit?.state ?? "ok"] || | |
| 56 | + | (b.createdAt ?? "").localeCompare(a.createdAt ?? ""), | |
| 57 | + | ); | |
| 58 | + | ||
| 59 | + | // Money is summed over billing's accounts, so an enterprise is counted once. | |
| 60 | + | const sum = (pick: (row: (typeof accounts)[number]) => number) => accounts.reduce((total, row) => total + pick(row), 0); | |
| 61 | + | return { | |
| 62 | + | q, | |
| 63 | + | show, | |
| 64 | + | rows, | |
| 65 | + | total: all.length, | |
| 66 | + | capped: found.length >= ADMIN_WORKSPACES_LIMIT, | |
| 67 | + | totals: { | |
| 68 | + | charged: sum((row) => row.chargedMicros), | |
| 69 | + | cost: sum((row) => row.costMicros), | |
| 70 | + | paid: sum((row) => row.paidMicros), | |
| 71 | + | exposure: sum((row) => row.limit.exposureMicros), | |
| 72 | + | onEnterprise: all.filter((row) => row.billing.billedTo).length, | |
| 73 | + | stopped: accounts.filter((row) => row.limit.state === "stopped").length, | |
| 74 | + | warning: accounts.filter((row) => row.limit.state === "warning").length, | |
| 75 | + | }, | |
| 76 | + | }; | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | function workspaceHref(row: WorkspaceRow) { | |
| 80 | + | return `/workspaces/${encodeURIComponent(row.slug)}`; | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | export default function Workspaces({ loaderData }: Route.ComponentProps) { | |
| 84 | + | const { q, show, rows, total, capped, totals } = loaderData; | |
| 85 | + | const margin = totals.charged - totals.cost; | |
| 86 | + | const filtered = q !== "" || show.length > 0; | |
| 87 | + | ||
| 88 | + | return ( | |
| 89 | + | <main className="mx-auto max-w-6xl px-4 py-8 sm:py-10"> | |
| 90 | + | <div> | |
| 91 | + | <h1 className="text-2xl font-semibold tracking-tight">Workspaces</h1> | |
| 92 | + | <p className="mt-1 text-sm text-muted">Every workspace, who owns it, and how it pays this month.</p> | |
| 93 | + | </div> | |
| 94 | + | ||
| 95 | + | <div className="mt-6 grid grid-cols-2 gap-3 lg:grid-cols-4"> | |
| 96 | + | <Stat label="Workspaces" value={String(total)} hint={`${totals.onEnterprise} billed to an enterprise`} /> | |
| 97 | + | <Stat label="Charged this month" value={usd(totals.charged)} hint={`Cost to g1t ${usd(totals.cost)} · margin ${usd(margin)}`} tone={margin < 0 ? "danger" : undefined} /> | |
| 98 | + | <Stat label="Unpaid usage this month" value={usd(totals.exposure)} hint={`Paid ever ${usd(totals.paid)}`} /> | |
| 99 | + | <Stat | |
| 100 | + | label="Needs attention" | |
| 101 | + | value={`${totals.stopped} stopped`} | |
| 102 | + | hint={`${totals.warning} near the limit`} | |
| 103 | + | tone={totals.stopped > 0 ? "danger" : totals.warning > 0 ? "warn" : "mint"} | |
| 104 | + | /> | |
| 105 | + | </div> | |
| 106 | + | ||
| 107 | + | <form method="get" action="/" role="search" className="mt-6 flex flex-col gap-3 lg:flex-row lg:items-center"> | |
| 108 | + | <div className="relative grow"> | |
| 109 | + | <Search size={14} className="pointer-events-none absolute top-1/2 left-3 -translate-y-1/2 text-faint" /> | |
| 110 | + | <input | |
| 111 | + | type="search" | |
| 112 | + | name="q" | |
| 113 | + | defaultValue={q} | |
| 114 | + | placeholder="Search by workspace, owner, email or enterprise" | |
| 115 | + | aria-label="Search workspaces" | |
| 116 | + | autoComplete="off" | |
| 117 | + | data-1p-ignore | |
| 118 | + | className="w-full rounded-md border border-line bg-bg py-2 pr-3 pl-8 text-sm outline-none transition-colors placeholder:text-faint hover:border-line-strong focus:border-merged/60" | |
| 119 | + | /> | |
| 120 | + | </div> | |
| 121 | + | <fieldset className="flex flex-wrap items-center gap-2"> | |
| 122 | + | <legend className="sr-only">Show only</legend> | |
| 123 | + | {(Object.keys(FILTERS) as Filter[]).map((key) => ( | |
| 124 | + | <label | |
| 125 | + | key={key} | |
| 126 | + | className="inline-flex cursor-pointer items-center gap-2 rounded-full border border-line px-3 py-1.5 text-xs text-muted transition-colors select-none hover:border-line-strong has-checked:border-merged/50 has-checked:bg-merged/10 has-checked:text-merged" | |
| 127 | + | > | |
| 128 | + | <input type="checkbox" name="show" value={key} defaultChecked={show.includes(key)} className="size-3.5" /> | |
| 129 | + | {FILTERS[key].label} | |
| 130 | + | </label> | |
| 131 | + | ))} | |
| 132 | + | <Button type="submit" variant="quiet" className="py-1.5"> | |
| 133 | + | Apply | |
| 134 | + | </Button> | |
| 135 | + | {filtered && ( | |
| 136 | + | <Link to="/" className="px-1 text-xs text-muted underline-offset-4 hover:text-fg hover:underline"> | |
| 137 | + | Clear | |
| 138 | + | </Link> | |
| 139 | + | )} | |
| 140 | + | </fieldset> | |
| 141 | + | </form> | |
| 142 | + | ||
| 143 | + | <p className="mt-4 text-xs text-faint"> | |
| 144 | + | {rows.length === total ? `${total} workspaces` : `${rows.length} of ${total} workspaces`} | |
| 145 | + | {q && ( | |
| 146 | + | <> | |
| 147 | + | {" "} | |
| 148 | + | matching <span className="font-mono text-muted">{q}</span> | |
| 149 | + | </> | |
| 150 | + | )} | |
| 151 | + | . Stopped and warning first, then newest. | |
| 152 | + | {capped && ` Only the newest ${ADMIN_WORKSPACES_LIMIT} are listed; search to find older ones.`} | |
| 153 | + | </p> | |
| 154 | + | ||
| 155 | + | {rows.length === 0 ? ( | |
| 156 | + | <div className="mt-3"> | |
| 157 | + | <EmptyState title={filtered ? "No workspaces match" : "No workspaces yet"}> | |
| 158 | + | {filtered ? "Try another search, or clear the filters." : "Workspaces appear here as people create them."} | |
| 159 | + | </EmptyState> | |
| 160 | + | </div> | |
| 161 | + | ) : ( | |
| 162 | + | <> | |
| 163 | + | {/* Phones: one card per workspace. */} | |
| 164 | + | <ul className="mt-3 space-y-2 md:hidden"> | |
| 165 | + | {rows.map((row) => ( | |
| 166 | + | <li key={row.slug}> | |
| 167 | + | <Link to={workspaceHref(row)} className="block rounded-lg border border-line bg-surface p-4 transition-colors hover:border-line-strong"> | |
| 168 | + | <div className="flex items-start justify-between gap-3"> | |
| 169 | + | <WorkspaceName row={row} /> | |
| 170 | + | <ChevronRight size={16} className="mt-0.5 shrink-0 text-faint" /> | |
| 171 | + | </div> | |
| 172 | + | <div className="mt-3 text-xs"> | |
| 173 | + | <Owners owners={row.owners} compact /> | |
| 174 | + | </div> | |
| 175 | + | <div className="mt-3"> | |
| 176 | + | <Usage row={row} wide /> | |
| 177 | + | </div> | |
| 178 | + | <dl className="tabular mt-3 grid grid-cols-3 gap-2 text-xs"> | |
| 179 | + | <Figure label="Charged" value={usd(row.billing.chargedMicros)} /> | |
| 180 | + | <Figure label="Cost to g1t" value={usd(row.billing.costMicros)} /> | |
| 181 | + | <Figure label="Members" value={row.memberCount == null ? "—" : String(row.memberCount)} /> | |
| 182 | + | </dl> | |
| 183 | + | </Link> | |
| 184 | + | </li> | |
| 185 | + | ))} | |
| 186 | + | </ul> | |
| 187 | + | ||
| 188 | + | {/* Wider screens: a table. */} | |
| 189 | + | <div className="mt-3 hidden overflow-x-auto rounded-lg border border-line md:block"> | |
| 190 | + | <table className="w-full text-sm"> | |
| 191 | + | <thead> | |
| 192 | + | <tr className="border-b border-line bg-surface text-left text-xs text-muted"> | |
| 193 | + | <th className="px-4 py-2.5 font-medium">Workspace</th> | |
| 194 | + | <th className="px-4 py-2.5 font-medium">Owners</th> | |
| 195 | + | <th className="px-4 py-2.5 text-right font-medium">Members</th> | |
| 196 | + | <th className="px-4 py-2.5 font-medium">Usage this month / limit</th> | |
| 197 | + | <th className="px-4 py-2.5 text-right font-medium">Charged</th> | |
| 198 | + | <th className="px-4 py-2.5 text-right font-medium">Cost to g1t</th> | |
| 199 | + | </tr> | |
| 200 | + | </thead> | |
| 201 | + | <tbody> | |
| 202 | + | {rows.map((row) => ( | |
| 203 | + | <tr key={row.slug} className="border-b border-line align-top last:border-0 hover:bg-surface/60"> | |
| 204 | + | <td className="px-4 py-3"> | |
| 205 | + | <Link to={workspaceHref(row)} className="group block"> | |
| 206 | + | <WorkspaceName row={row} /> | |
| 207 | + | </Link> | |
| 208 | + | </td> | |
| 209 | + | <td className="max-w-56 px-4 py-3 text-sm"> | |
| 210 | + | <Owners owners={row.owners} compact /> | |
| 211 | + | </td> | |
| 212 | + | <td className="tabular px-4 py-3 text-right text-muted">{row.memberCount ?? "—"}</td> | |
| 213 | + | <td className="px-4 py-3"> | |
| 214 | + | <Usage row={row} /> | |
| 215 | + | </td> | |
| 216 | + | <td className="tabular px-4 py-3 text-right">{usd(row.billing.chargedMicros)}</td> | |
| 217 | + | <td className="tabular px-4 py-3 text-right text-muted">{usd(row.billing.costMicros)}</td> | |
| 218 | + | </tr> | |
| 219 | + | ))} | |
| 220 | + | </tbody> | |
| 221 | + | </table> | |
| 222 | + | </div> | |
| 223 | + | </> | |
| 224 | + | )} | |
| 225 | + | </main> | |
| 226 | + | ); | |
| 227 | + | } | |
| 228 | + | ||
| 229 | + | function WorkspaceName({ row }: { row: WorkspaceRow }) { | |
| 230 | + | const { billing } = row; | |
| 231 | + | return ( | |
| 232 | + | <div className="flex min-w-0 items-start gap-2.5"> | |
| 233 | + | <span className="mt-0.5"> | |
| 234 | + | <Avatar name={row.slug} size={22} /> | |
| 235 | + | </span> | |
| 236 | + | <div className="min-w-0"> | |
| 237 | + | <p className="truncate font-medium group-hover:underline group-hover:underline-offset-4">{row.name}</p> | |
| 238 | + | <p className="truncate text-xs text-faint"> | |
| 239 | + | <span className="font-mono">{row.slug}</span> | |
| 240 | + | {row.createdAt && ( | |
| 241 | + | <> | |
| 242 | + | {" · "}created <When at={row.createdAt} /> | |
| 243 | + | </> | |
| 244 | + | )} | |
| 245 | + | </p> | |
| 246 | + | <div className="mt-1.5 flex flex-wrap gap-1.5"> | |
| 247 | + | {billing.billedTo && <Badge tone="lavender">Billed to {billing.billedTo.name}</Badge>} | |
| 248 | + | <TermsBadge terms={billing.terms} /> | |
| 249 | + | {billing.limit && billing.terms.kind !== "comped" && <TrustBadge trust={billing.limit.trust} />} | |
| 250 | + | {!row.known && <Badge tone="warn">Billing only</Badge>} | |
| 251 | + | </div> | |
| 252 | + | </div> | |
| 253 | + | </div> | |
| 254 | + | ); | |
| 255 | + | } | |
| 256 | + | ||
| 257 | + | /** The limit bar; a workspace on an enterprise shares the enterprise's. */ | |
| 258 | + | function Usage({ row, wide = false }: { row: WorkspaceRow; wide?: boolean }) { | |
| 259 | + | const { limit, billedTo } = row.billing; | |
| 260 | + | if (!limit) return <p className="text-xs text-faint">No usage yet</p>; | |
| 261 | + | return ( | |
| 262 | + | <div> | |
| 263 | + | <ExposureBar limit={limit} wide={wide} /> | |
| 264 | + | {billedTo && <p className="mt-1 text-xs text-faint">via {billedTo.name}</p>} | |
| 265 | + | </div> | |
| 266 | + | ); | |
| 267 | + | } | |
| 268 | + | ||
| 269 | + | function Figure({ label, value }: { label: string; value: string }) { | |
| 270 | + | return ( | |
| 271 | + | <div> | |
| 272 | + | <dt className="text-faint">{label}</dt> | |
| 273 | + | <dd className="mt-0.5 text-fg-soft">{value}</dd> | |
| 274 | + | </div> | |
| 275 | + | ); | |
| 276 | + | } |
| 4 | 4 | namespace Cloudflare { | |
| 5 | 5 | interface Env { | |
| 6 | 6 | BILLING: ServiceBinding; | |
| 7 | + | IDENTITY: ServiceBinding; | |
| 7 | 8 | ASSETS: Fetcher; | |
| 8 | 9 | ACCESS_TEAM_DOMAIN: string; | |
| 9 | 10 | ACCESS_AUD: string; |
| 13 | 13 | // Even the stylesheet goes through the worker, which checks the Access | |
| 14 | 14 | // token on every request before anything is served. | |
| 15 | 15 | "assets": { "binding": "ASSETS", "run_worker_first": true }, | |
| 16 | − | "services": [{ "binding": "BILLING", "service": "g1t-billing" }], | |
| 16 | + | // Billing's and identity's staff-only methods (admin_*). Nothing else | |
| 17 | + | // binds to identity's admin_* methods. | |
| 18 | + | "services": [ | |
| 19 | + | { "binding": "BILLING", "service": "g1t-billing" }, | |
| 20 | + | { "binding": "IDENTITY", "service": "g1t-identity" } | |
| 21 | + | ], | |
| 17 | 22 | "vars": { | |
| 18 | 23 | // The Zero Trust team domain, such as `g1t.cloudflareaccess.com`. | |
| 19 | 24 | "ACCESS_TEAM_DOMAIN": "syntaqx.cloudflareaccess.com", |
| 394 | 394 | className="scroll-mt-28 rounded-xl border border-line" | |
| 395 | 395 | > | |
| 396 | 396 | <header | |
| 397 | − | className={`sticky top-12 z-20 flex items-center gap-2.5 border-line bg-surface/95 px-3 py-2 backdrop-blur ${ | |
| 397 | + | className={`sticky top-14 z-20 flex items-center gap-2.5 border-line bg-surface/95 px-3 py-2 backdrop-blur ${ | |
| 398 | 398 | collapsed ? "rounded-xl" : "rounded-t-xl border-b" | |
| 399 | 399 | }`} | |
| 400 | 400 | > | |
| 707 | 707 | const allCollapsed = files.every((file) => collapsed.has(file.path)); | |
| 708 | 708 | return ( | |
| 709 | 709 | <div> | |
| 710 | − | <div className="sticky top-12 z-30 -mx-1 mb-3 flex flex-wrap items-center gap-x-4 gap-y-2 bg-bg/90 px-1 py-2 backdrop-blur"> | |
| 710 | + | <div className="sticky top-14 z-30 -mx-1 mb-3 flex flex-wrap items-center gap-x-4 gap-y-2 bg-bg/90 px-1 py-2 backdrop-blur"> | |
| 711 | 711 | <span className="text-sm text-muted"> | |
| 712 | 712 | <span className="font-medium text-fg">{files.length}</span> {files.length === 1 ? "file" : "files"} | |
| 713 | 713 | </span> |
| 598 | 598 | return ( | |
| 599 | 599 | <div className="flex h-full flex-col"> | |
| 600 | 600 | {/* The same height and rule as the top bar, so the two read as one line. */} | |
| 601 | − | <div className="flex h-12 shrink-0 items-center gap-1 border-b border-line px-2"> | |
| 601 | + | <div className="flex h-14 shrink-0 items-center gap-1.5 border-b border-line px-3"> | |
| 602 | 602 | <Link to="/" aria-label="g1t home" className="shrink-0 rounded-md p-1.5 hover:bg-raised"> | |
| 603 | 603 | <Mark className="size-5" /> | |
| 604 | 604 | </Link> | |
| 1046 | 1046 | )} | |
| 1047 | 1047 | ||
| 1048 | 1048 | <div className="flex min-h-screen min-w-0 flex-col lg:pl-64"> | |
| 1049 | − | <header className="sticky top-0 z-30 flex h-12 items-center gap-2 border-b border-line bg-bg/85 px-3 backdrop-blur sm:px-4"> | |
| 1049 | + | <header className="sticky top-0 z-30 flex h-14 items-center gap-3 border-b border-line bg-bg/85 px-4 backdrop-blur sm:px-6"> | |
| 1050 | 1050 | <button | |
| 1051 | 1051 | type="button" | |
| 1052 | 1052 | aria-label="Open menu" |
| 73 | 73 | const form = await request.formData(); | |
| 74 | 74 | const page = `${new URL(request.url).origin}/${params.owner.toLowerCase()}/-/billing`; | |
| 75 | 75 | const intent = form.get("intent"); | |
| 76 | + | if (intent === "portal") { | |
| 77 | + | // Card, invoices and billing details live on Stripe's own page. | |
| 78 | + | const started = await billing.billingPortal(user, params.owner, page); | |
| 79 | + | if (!started.ok) return { error: started.error.message }; | |
| 80 | + | throw redirect(started.value.url); | |
| 81 | + | } | |
| 76 | 82 | if (intent === "spend-limit" || intent === "no-spend-limit") { | |
| 77 | 83 | const amount = Number(form.get("limit")); | |
| 78 | 84 | if (intent === "spend-limit" && !(Number.isFinite(amount) && amount >= 0)) { | |
| 136 | 142 | <LimitCard limit={limit} owner={role === "owner"} busy={paying} error={actionData?.error} /> | |
| 137 | 143 | )} | |
| 138 | 144 | ||
| 145 | + | {account.status.enabled && limit?.trust !== "internal" && ( | |
| 146 | + | <section className="mb-10 rounded-xl border border-line bg-surface p-5"> | |
| 147 | + | <div className="flex flex-wrap items-center justify-between gap-3"> | |
| 148 | + | <div> | |
| 149 | + | <h2 className="font-medium">Card and invoices</h2> | |
| 150 | + | {account.card ? ( | |
| 151 | + | <p className="mt-1 text-sm"> | |
| 152 | + | <span className="capitalize">{account.card.brand}</span> ending{" "} | |
| 153 | + | <span className="font-mono">{account.card.last4}</span> | |
| 154 | + | <span className="text-muted"> | |
| 155 | + | {" "} | |
| 156 | + | · expires {String(account.card.expMonth).padStart(2, "0")}/{account.card.expYear} | |
| 157 | + | </span> | |
| 158 | + | </p> | |
| 159 | + | ) : ( | |
| 160 | + | <p className="mt-1 max-w-xl text-sm text-muted"> | |
| 161 | + | No card yet. With one on file, g1t charges it as the workspace nears its usage limit and when each | |
| 162 | + | month closes, so work never stops for a payment. Saving it charges nothing. | |
| 163 | + | </p> | |
| 164 | + | )} | |
| 165 | + | <p className="mt-2 max-w-xl text-xs text-faint"> | |
| 166 | + | Cards, invoices, receipts and the billing email and address are managed on Stripe's billing page. g1t | |
| 167 | + | never sees card numbers. | |
| 168 | + | </p> | |
| 169 | + | </div> | |
| 170 | + | {role === "owner" && ( | |
| 171 | + | <Form method="post"> | |
| 172 | + | <Button variant={account.card ? "quiet" : "accent"} type="submit" name="intent" value="portal" disabled={paying}> | |
| 173 | + | <CreditCard size={14} /> | |
| 174 | + | {account.card ? "Manage billing on Stripe" : "Add a card on Stripe"} | |
| 175 | + | </Button> | |
| 176 | + | </Form> | |
| 177 | + | )} | |
| 178 | + | </div> | |
| 179 | + | {account.card && ( | |
| 180 | + | <p className="mt-3 text-xs text-faint"> | |
| 181 | + | Charged near the usage limit, for what the workspace owes, and when each month closes. A declined card | |
| 182 | + | stops work until it is paid. | |
| 183 | + | </p> | |
| 184 | + | )} | |
| 185 | + | {!account.status.live && ( | |
| 186 | + | <p className="mt-3 text-xs text-faint"> | |
| 187 | + | Test mode: use card 4242 4242 4242 4242, any future date and code. Test cards are never charged | |
| 188 | + | automatically. | |
| 189 | + | </p> | |
| 190 | + | )} | |
| 191 | + | </section> | |
| 192 | + | )} | |
| 193 | + | ||
| 139 | 194 | <h2 className="font-medium">Plans</h2> | |
| 140 | 195 | <p className="mt-1 max-w-2xl text-sm text-muted"> | |
| 141 | 196 | Paid features are turned on per workspace with a monthly plan. They are never free, including while the rest of |
| 77 | 77 | /// What a run on the workspace's own model provider is charged: g1t's | |
| 78 | 78 | /// sandbox and orchestration, with the model paid for elsewhere. | |
| 79 | 79 | pub orchestration_fee_micros: i64, | |
| 80 | + | /// The card g1t charges as the workspace nears its limit and when a | |
| 81 | + | /// month closes, if one is on file. | |
| 82 | + | #[serde(default)] | |
| 83 | + | pub card: Option<Card>, | |
| 80 | 84 | } | |
| 81 | 85 | ||
| 86 | + | /// A saved card, as far as it is safe to show. | |
| 87 | + | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 88 | + | #[serde(rename_all = "camelCase")] | |
| 89 | + | pub struct Card { | |
| 90 | + | /// `visa`, `mastercard`, ... | |
| 91 | + | pub brand: String, | |
| 92 | + | pub last4: String, | |
| 93 | + | pub exp_month: u32, | |
| 94 | + | pub exp_year: u32, | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | /// `billing_portal`: Stripe's hosted billing page for the workspace, where | |
| 98 | + | /// an owner adds or replaces the card, sees invoices and receipts, and sets | |
| 99 | + | /// the billing email and address. g1t never handles card numbers. Owners | |
| 100 | + | /// only. Returns `Outcome<Checkout>` (its `url`); Stripe sends them back | |
| 101 | + | /// to `return_url`. | |
| 102 | + | #[derive(Debug, Serialize, Deserialize)] | |
| 103 | + | pub struct BillingPortalArgs { | |
| 104 | + | pub actor: User, | |
| 105 | + | pub workspace: String, | |
| 106 | + | pub return_url: String, | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | /// `admin_billing_link`: for staff to send a customer: their Stripe billing | |
| 110 | + | /// page. Returns `Outcome<BillingLink>`. | |
| 111 | + | #[derive(Debug, Serialize, Deserialize)] | |
| 112 | + | pub struct AdminBillingLinkArgs { | |
| 113 | + | pub workspace: String, | |
| 114 | + | pub by: String, | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 118 | + | #[serde(rename_all = "camelCase")] | |
| 119 | + | pub struct BillingLink { | |
| 120 | + | /// A one-time session on Stripe's billing page, signed in already. | |
| 121 | + | pub portal_url: String, | |
| 122 | + | /// The billing page's sign-in page, which does not expire: the | |
| 123 | + | /// customer signs in with the email Stripe has for them. | |
| 124 | + | pub login_url: Option<String>, | |
| 125 | + | pub customer_email: Option<String>, | |
| 126 | + | pub expires_note: String, | |
| 127 | + | } | |
| 128 | + | ||
| 82 | 129 | #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] | |
| 83 | 130 | #[serde(rename_all = "snake_case")] | |
| 84 | 131 | pub enum EntryKind { | |
| 112 | 159 | pub created_by: Option<String>, | |
| 113 | 160 | /// RFC 3339. | |
| 114 | 161 | pub created_at: String, | |
| 162 | + | /// The workspace the line belongs to, which tells an enterprise's | |
| 163 | + | /// lines apart. | |
| 164 | + | #[serde(default, skip_serializing_if = "Option::is_none")] | |
| 165 | + | pub workspace: Option<String>, | |
| 115 | 166 | } | |
| 116 | 167 | ||
| 117 | 168 | fn g1t() -> String { | |
| 593 | 644 | pub cost_micros: i64, | |
| 594 | 645 | /// Paid, ever. | |
| 595 | 646 | pub paid_micros: i64, | |
| 647 | + | /// The same figures for each of the account's workspaces that has | |
| 648 | + | /// any, so staff can see what one member of an enterprise used. | |
| 649 | + | #[serde(default)] | |
| 650 | + | pub by_workspace: Vec<WorkspaceFigures>, | |
| 651 | + | } | |
| 652 | + | ||
| 653 | + | /// One workspace's share of an [`AccountSummary`]. | |
| 654 | + | #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] | |
| 655 | + | #[serde(rename_all = "camelCase")] | |
| 656 | + | pub struct WorkspaceFigures { | |
| 657 | + | pub workspace: String, | |
| 658 | + | pub charged_micros: i64, | |
| 659 | + | pub cost_micros: i64, | |
| 660 | + | pub paid_micros: i64, | |
| 596 | 661 | } | |
| 597 | 662 | ||
| 598 | 663 | /// `admin_account`: one account in full. Returns `Outcome<AccountDetail>`. |
| 407 | 407 | /// The id and name g1t's agents act under. | |
| 408 | 408 | pub const AGENT_ID: &str = "usr_g1t_agent"; | |
| 409 | 409 | pub const AGENT_NAME: &str = "g1t-agent"; | |
| 410 | + | ||
| 411 | + | // --- Staff --------------------------------------------------------------- | |
| 412 | + | // | |
| 413 | + | // Staff-only methods, for sudo.g1t.sh. They take no viewer and check no | |
| 414 | + | // membership: only sudo calls them, over its service binding, after it has | |
| 415 | + | // verified a Cloudflare Access sign-in and its staff list. Nothing a | |
| 416 | + | // customer can reach should ever forward to them. | |
| 417 | + | ||
| 418 | + | /// `notify_owners`: emails a short notice, with one link, to each owner of | |
| 419 | + | /// a workspace with a confirmed address. Called by other services (billing | |
| 420 | + | /// warns owners near their usage limit), never on a person's behalf. | |
| 421 | + | /// Returns how many were sent. | |
| 422 | + | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 423 | + | pub struct NotifyOwnersArgs { | |
| 424 | + | pub workspace: String, | |
| 425 | + | pub subject: String, | |
| 426 | + | /// One or two sentences: what happened and what it means. | |
| 427 | + | pub intro: String, | |
| 428 | + | /// The button's words, such as `Open billing`. | |
| 429 | + | pub action: String, | |
| 430 | + | /// Where the button goes; must be on g1t.sh. | |
| 431 | + | pub link: String, | |
| 432 | + | /// Small print: why they got it. | |
| 433 | + | pub footer: String, | |
| 434 | + | } | |
| 435 | + | ||
| 436 | + | /// `admin_workspaces`: every workspace, newest first, at most | |
| 437 | + | /// [`ADMIN_WORKSPACES_LIMIT`], optionally only those whose slug, name or | |
| 438 | + | /// an owner's username or email contains `query`. Returns | |
| 439 | + | /// `Vec<AdminWorkspace>`. Staff only. | |
| 440 | + | #[derive(Debug, Default, Serialize, Deserialize)] | |
| 441 | + | pub struct AdminWorkspacesArgs { | |
| 442 | + | #[serde(default)] | |
| 443 | + | pub query: Option<String>, | |
| 444 | + | } | |
| 445 | + | ||
| 446 | + | /// The most workspaces one `admin_workspaces` call returns. | |
| 447 | + | pub const ADMIN_WORKSPACES_LIMIT: usize = 500; | |
| 448 | + | ||
| 449 | + | /// An owner of a workspace, as staff see them. | |
| 450 | + | #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] | |
| 451 | + | pub struct AdminOwner { | |
| 452 | + | pub username: String, | |
| 453 | + | pub email: Option<String>, | |
| 454 | + | } | |
| 455 | + | ||
| 456 | + | /// A workspace as staff see it: who owns it and how many belong to it. | |
| 457 | + | #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] | |
| 458 | + | #[serde(rename_all = "camelCase")] | |
| 459 | + | pub struct AdminWorkspace { | |
| 460 | + | pub slug: String, | |
| 461 | + | pub name: String, | |
| 462 | + | /// RFC 3339. | |
| 463 | + | pub created_at: String, | |
| 464 | + | pub owners: Vec<AdminOwner>, | |
| 465 | + | pub member_count: u32, | |
| 466 | + | } | |
| 467 | + | ||
| 468 | + | /// `admin_workspace`: one workspace with every member, or null. Takes | |
| 469 | + | /// `SlugArgs`; returns `Option<AdminWorkspaceDetail>`. Staff only. | |
| 470 | + | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 471 | + | #[serde(rename_all = "camelCase")] | |
| 472 | + | pub struct AdminWorkspaceDetail { | |
| 473 | + | pub slug: String, | |
| 474 | + | pub name: String, | |
| 475 | + | pub description: Option<String>, | |
| 476 | + | /// RFC 3339. | |
| 477 | + | pub created_at: String, | |
| 478 | + | /// Owners first, then by username. | |
| 479 | + | pub members: Vec<AdminMember>, | |
| 480 | + | } | |
| 481 | + | ||
| 482 | + | /// A member of a workspace, as staff see them. | |
| 483 | + | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 484 | + | pub struct AdminMember { | |
| 485 | + | pub username: String, | |
| 486 | + | pub email: Option<String>, | |
| 487 | + | pub role: crate::Role, | |
| 488 | + | /// When they joined the workspace. RFC 3339. | |
| 489 | + | pub joined: String, | |
| 490 | + | } |
| 34 | 34 | marginPercent: number; | |
| 35 | 35 | /** What a run on the workspace's own model provider is charged instead. */ | |
| 36 | 36 | orchestrationFeeMicros: number; | |
| 37 | + | /** The card charged near the limit and when a month closes, if one is saved. */ | |
| 38 | + | card?: { brand: string; last4: string; expMonth: number; expYear: number } | null; | |
| 37 | 39 | }; | |
| 38 | 40 | ||
| 39 | 41 | /** One line of a workspace's statement. */ | |
| 57 | 59 | createdBy: string | null; | |
| 58 | 60 | /** RFC 3339. */ | |
| 59 | 61 | createdAt: string; | |
| 62 | + | /** The workspace the line belongs to, which tells an enterprise's lines apart. */ | |
| 63 | + | workspace?: string | null; | |
| 60 | 64 | }; | |
| 61 | 65 | ||
| 62 | 66 | /** What lets a sandbox, and nothing else, report what its run cost. */ | |
| 102 | 106 | chargedMicros: number; | |
| 103 | 107 | costMicros: number; | |
| 104 | 108 | paidMicros: number; | |
| 109 | + | /** The same figures for each of the account's workspaces that has any. */ | |
| 110 | + | byWorkspace: WorkspaceFigures[]; | |
| 111 | + | }; | |
| 112 | + | ||
| 113 | + | /** One workspace's share of an `AccountSummary`. */ | |
| 114 | + | export type WorkspaceFigures = { workspace: string; chargedMicros: number; costMicros: number; paidMicros: number }; | |
| 115 | + | ||
| 116 | + | /** A customer's Stripe billing page, for staff to send them. */ | |
| 117 | + | export type BillingLink = { | |
| 118 | + | /** One-time and short-lived, signed in already. */ | |
| 119 | + | portalUrl: string; | |
| 120 | + | /** The page's sign-in, which does not expire: the customer signs in by email. */ | |
| 121 | + | loginUrl: string | null; | |
| 122 | + | customerEmail: string | null; | |
| 123 | + | expiresNote: string; | |
| 105 | 124 | }; | |
| 106 | 125 | ||
| 107 | 126 | export type AdminAction = { id: string; account: string; action: string; detail: string; by: string; createdAt: string }; | |
| 121 | 140 | createEnterprise(name: string, workspaces: string[], by: string): Promise<Result<PayingAccount>>; | |
| 122 | 141 | attach(workspace: string, account: string | null, by: string): Promise<Result<PayingAccount>>; | |
| 123 | 142 | credit(workspace: string, amountMicros: number, note: string, by: string): Promise<Result<LedgerEntry>>; | |
| 143 | + | /** The workspace's Stripe billing page, to send to the customer. Logged. */ | |
| 144 | + | billingLink(workspace: string, by: string): Promise<Result<BillingLink>>; | |
| 124 | 145 | } | |
| 125 | 146 | ||
| 126 | 147 | /** How much a workspace has earned g1t's trust with money. */ | |
| 246 | 267 | * `session`. | |
| 247 | 268 | */ | |
| 248 | 269 | checkout(actor: User, workspace: string, amountCents: number, returnUrl: string): Promise<Result<{ url: string }>>; | |
| 270 | + | /** | |
| 271 | + | * Stripe's hosted billing page for the workspace: card, invoices, billing | |
| 272 | + | * email and address. g1t never handles card numbers. Owners only. | |
| 273 | + | */ | |
| 274 | + | billingPortal(actor: User, workspace: string, returnUrl: string): Promise<Result<{ url: string }>>; | |
| 249 | 275 | /** Credits a payment once the processor says it was made. Safe to repeat. */ | |
| 250 | 276 | confirm(workspace: string, viewer: Viewer, session: string): Promise<Result<BillingAccount>>; | |
| 251 | 277 | /** |
| 3 | 3 | import type { DeploymentsApi } from "./deployments"; | |
| 4 | 4 | import type { ProjectsApi } from "./projects"; | |
| 5 | 5 | import type { EventsApi } from "./events"; | |
| 6 | − | import type { IdentityApi } from "./identity"; | |
| 6 | + | import type { IdentityAdminApi, IdentityApi } from "./identity"; | |
| 7 | 7 | import type { IntegrationsApi } from "./integrations"; | |
| 8 | 8 | import type { WebhooksApi } from "./webhooks"; | |
| 9 | 9 | import type { ReposApi } from "./repos"; | |
| 92 | 92 | }; | |
| 93 | 93 | } | |
| 94 | 94 | ||
| 95 | + | /** Staff-only identity. Only sudo binds to it; see `IdentityAdminApi`. */ | |
| 96 | + | export function identityAdminClient(service: ServiceBinding): IdentityAdminApi { | |
| 97 | + | const call = <T>(method: string, args: object) => rpc<T>(service, method, args); | |
| 98 | + | return { | |
| 99 | + | workspaces: (query) => call("admin_workspaces", { query: query ?? null }), | |
| 100 | + | workspace: (slug) => call("admin_workspace", { slug }), | |
| 101 | + | }; | |
| 102 | + | } | |
| 103 | + | ||
| 95 | 104 | export function reposClient(service: ServiceBinding): ReposApi { | |
| 96 | 105 | const call = <T>(method: string, args: object) => rpc<T>(service, method, args); | |
| 97 | 106 | return { | |
| 199 | 208 | call("cancel_subscription", { actor, workspace, feature, resume }), | |
| 200 | 209 | hasFeature: (workspace, feature) => call("has_feature", { workspace, feature }), | |
| 201 | 210 | chargeFeature: (charge) => call("charge_feature", charge), | |
| 211 | + | billingPortal: (actor, workspace, returnUrl) => call("billing_portal", { actor, workspace, return_url: returnUrl }), | |
| 202 | 212 | recordSandbox: (usage) => call("record_sandbox", usage), | |
| 203 | 213 | limit: (workspace, viewer) => call("limit", { workspace, viewer }), | |
| 204 | 214 | checkLimit: (workspace) => call("check_limit", { workspace }), | |
| 218 | 228 | createEnterprise: (name, workspaces, by) => call("admin_create_enterprise", { name, workspaces, by }), | |
| 219 | 229 | attach: (workspace, account, by) => call("admin_attach", { workspace, account, by }), | |
| 220 | 230 | credit: (workspace, amountMicros, note, by) => call("admin_credit", { workspace, amount_micros: amountMicros, note, by }), | |
| 231 | + | billingLink: (workspace, by) => call("admin_billing_link", { workspace, by }), | |
| 221 | 232 | }; | |
| 222 | 233 | } | |
| 223 | 234 |
| 44 | 44 | ||
| 45 | 45 | export type Member = { username: string; role: Role }; | |
| 46 | 46 | ||
| 47 | + | /** An owner of a workspace, as staff see them. */ | |
| 48 | + | export type AdminOwner = { username: string; email: string | null }; | |
| 49 | + | ||
| 50 | + | /** A workspace as staff see it. Mirrors `AdminWorkspace` in `crates/contracts/src/identity.rs`. */ | |
| 51 | + | export type AdminWorkspace = { | |
| 52 | + | slug: string; | |
| 53 | + | name: string; | |
| 54 | + | /** RFC 3339. */ | |
| 55 | + | createdAt: string; | |
| 56 | + | owners: AdminOwner[]; | |
| 57 | + | memberCount: number; | |
| 58 | + | }; | |
| 59 | + | ||
| 60 | + | /** A member of a workspace, as staff see them. */ | |
| 61 | + | export type AdminMember = { username: string; email: string | null; role: Role; /** RFC 3339. */ joined: string }; | |
| 62 | + | ||
| 63 | + | export type AdminWorkspaceDetail = { | |
| 64 | + | slug: string; | |
| 65 | + | name: string; | |
| 66 | + | description: string | null; | |
| 67 | + | /** RFC 3339. */ | |
| 68 | + | createdAt: string; | |
| 69 | + | /** Owners first, then by username. */ | |
| 70 | + | members: AdminMember[]; | |
| 71 | + | }; | |
| 72 | + | ||
| 73 | + | /** The most workspaces one `workspaces` call returns. */ | |
| 74 | + | export const ADMIN_WORKSPACES_LIMIT = 500; | |
| 75 | + | ||
| 76 | + | /** | |
| 77 | + | * Staff-only identity, for sudo.g1t.sh. It takes no viewer and checks no | |
| 78 | + | * membership: only sudo calls it, over its service binding, once Cloudflare | |
| 79 | + | * Access and its staff list have let someone in. Never call it on behalf of | |
| 80 | + | * a customer. | |
| 81 | + | */ | |
| 82 | + | export interface IdentityAdminApi { | |
| 83 | + | /** Every workspace, newest first, at most 500; `query` matches slug, name, or an owner's username or email. */ | |
| 84 | + | workspaces(query?: string): Promise<AdminWorkspace[]>; | |
| 85 | + | /** One workspace with all its members, or null. */ | |
| 86 | + | workspace(slug: string): Promise<AdminWorkspaceDetail | null>; | |
| 87 | + | } | |
| 88 | + | ||
| 47 | 89 | /** Who is asking. Every read and write in every service takes one. */ | |
| 48 | 90 | export type Viewer = User | null; | |
| 49 | 91 |
| 1 | + | -- Closing each month: what a workspace with a card on file owed when the | |
| 2 | + | -- month ended, charged to that card. Once per workspace per month. See | |
| 3 | + | -- `close_months` in src/limits.rs. | |
| 4 | + | CREATE TABLE month_closes ( | |
| 5 | + | workspace TEXT NOT NULL, | |
| 6 | + | -- YYYY-MM, the month that closed. | |
| 7 | + | month TEXT NOT NULL, | |
| 8 | + | -- paid, failed, nothing (owed nothing) or skipped (comped, or on an | |
| 9 | + | -- enterprise, which is invoiced). | |
| 10 | + | status TEXT NOT NULL, | |
| 11 | + | amount_micros INTEGER NOT NULL DEFAULT 0, | |
| 12 | + | payment_id TEXT, | |
| 13 | + | error TEXT, | |
| 14 | + | closed_at TEXT NOT NULL, | |
| 15 | + | PRIMARY KEY (workspace, month) | |
| 16 | + | ); |
| 1 | + | -- Telling owners before work stops: the highest warning sent this month | |
| 2 | + | -- (50, 80 or 100 percent of the limit), and when a declined card was last | |
| 3 | + | -- reported. See `warn_limits` in src/limits.rs. | |
| 4 | + | ALTER TABLE limits ADD COLUMN warned_month TEXT; | |
| 5 | + | ALTER TABLE limits ADD COLUMN warned_level INTEGER NOT NULL DEFAULT 0; | |
| 6 | + | ALTER TABLE limits ADD COLUMN declined_told_at TEXT; |
| 17 | 17 | use g1t_contracts::billing::{ | |
| 18 | 18 | AccountDetail, AccountKind, AccountSummary, AdminAccountArgs, AdminAccountsArgs, AdminAction, AdminAttachArgs, | |
| 19 | 19 | AdminCreateEnterpriseArgs, AdminCreditArgs, AdminSetTermsArgs, BillingAccount, EntryKind, LedgerEntry, Terms, | |
| 20 | − | TermsKind, | |
| 20 | + | TermsKind, WorkspaceFigures, | |
| 21 | 21 | }; | |
| 22 | 22 | use g1t_contracts::time::rfc3339; | |
| 23 | 23 | use g1t_contracts::{FailureCode, Outcome, new_id}; | |
| 230 | 230 | let limit = self.limit_of(&first).await?; | |
| 231 | 231 | #[derive(Deserialize)] | |
| 232 | 232 | struct Totals { | |
| 233 | + | workspace: String, | |
| 233 | 234 | charged: Option<i64>, | |
| 234 | 235 | cost: Option<i64>, | |
| 235 | 236 | } | |
| 236 | 237 | #[derive(Deserialize)] | |
| 237 | 238 | struct Paid { | |
| 239 | + | workspace: String, | |
| 238 | 240 | paid: Option<i64>, | |
| 239 | 241 | } | |
| 240 | 242 | let marks = vec!["?"; account.workspaces.len().max(1)].join(", "); | |
| 248 | 250 | let totals = self | |
| 249 | 251 | .db | |
| 250 | 252 | .prepare(format!( | |
| 251 | − | "SELECT -SUM(amount_micros) AS charged, SUM(cost_micros) AS cost FROM ledger | |
| 252 | − | WHERE kind = 'usage' AND workspace IN ({marks}) AND created_at >= ?" | |
| 253 | + | "SELECT workspace, -SUM(amount_micros) AS charged, SUM(cost_micros) AS cost FROM ledger | |
| 254 | + | WHERE kind = 'usage' AND workspace IN ({marks}) AND created_at >= ? GROUP BY workspace" | |
| 253 | 255 | )) | |
| 254 | 256 | .bind(&with_month)? | |
| 255 | − | .first::<Totals>(None) | |
| 256 | − | .await?; | |
| 257 | + | .all() | |
| 258 | + | .await? | |
| 259 | + | .results::<Totals>()?; | |
| 257 | 260 | let paid = self | |
| 258 | 261 | .db | |
| 259 | − | .prepare(format!("SELECT SUM(amount_micros) AS paid FROM ledger WHERE kind = 'top_up' AND workspace IN ({marks})")) | |
| 262 | + | .prepare(format!( | |
| 263 | + | "SELECT workspace, SUM(amount_micros) AS paid FROM ledger | |
| 264 | + | WHERE kind = 'top_up' AND workspace IN ({marks}) GROUP BY workspace" | |
| 265 | + | )) | |
| 260 | 266 | .bind(&values)? | |
| 261 | − | .first::<Paid>(None) | |
| 262 | − | .await?; | |
| 267 | + | .all() | |
| 268 | + | .await? | |
| 269 | + | .results::<Paid>()?; | |
| 270 | + | // Each workspace's share, in the account's order; the account's | |
| 271 | + | // figures are their sum. | |
| 272 | + | let mut by_workspace: Vec<WorkspaceFigures> = vec![]; | |
| 273 | + | for workspace in &account.workspaces { | |
| 274 | + | figures_for(&mut by_workspace, workspace); | |
| 275 | + | } | |
| 276 | + | for t in &totals { | |
| 277 | + | let f = figures_for(&mut by_workspace, &t.workspace); | |
| 278 | + | f.charged_micros += t.charged.unwrap_or(0); | |
| 279 | + | f.cost_micros += t.cost.unwrap_or(0); | |
| 280 | + | } | |
| 281 | + | for p in &paid { | |
| 282 | + | figures_for(&mut by_workspace, &p.workspace).paid_micros += p.paid.unwrap_or(0); | |
| 283 | + | } | |
| 263 | 284 | Ok(AccountSummary { | |
| 285 | + | charged_micros: by_workspace.iter().map(|f| f.charged_micros).sum(), | |
| 286 | + | cost_micros: by_workspace.iter().map(|f| f.cost_micros).sum(), | |
| 287 | + | paid_micros: by_workspace.iter().map(|f| f.paid_micros).sum(), | |
| 288 | + | by_workspace, | |
| 264 | 289 | account, | |
| 265 | 290 | limit, | |
| 266 | − | charged_micros: totals.as_ref().and_then(|t| t.charged).unwrap_or(0), | |
| 267 | − | cost_micros: totals.and_then(|t| t.cost).unwrap_or(0), | |
| 268 | − | paid_micros: paid.and_then(|p| p.paid).unwrap_or(0), | |
| 269 | 291 | }) | |
| 270 | 292 | } | |
| 271 | 293 | ||
| 484 | 506 | Ok(Outcome::Ok(self.account_of(&workspace).await?)) | |
| 485 | 507 | } | |
| 486 | 508 | ||
| 509 | + | pub(crate) async fn admin_billing_link( | |
| 510 | + | &self, | |
| 511 | + | a: g1t_contracts::billing::AdminBillingLinkArgs, | |
| 512 | + | ) -> Result<Outcome<g1t_contracts::billing::BillingLink>> { | |
| 513 | + | let workspace = a.workspace.trim().to_lowercase(); | |
| 514 | + | if workspace.is_empty() || a.by.trim().is_empty() { | |
| 515 | + | return Ok(Outcome::fail(FailureCode::Invalid, "Name the workspace, and who is asking.")); | |
| 516 | + | } | |
| 517 | + | let Some(stripe) = &self.stripe else { | |
| 518 | + | return Ok(Outcome::fail(FailureCode::Conflict, "Payments are not set up on this g1t.")); | |
| 519 | + | }; | |
| 520 | + | let customer = match self.customer_for(&workspace).await { | |
| 521 | + | Ok(customer) => customer, | |
| 522 | + | Err(error) => return Ok(Outcome::fail(FailureCode::Conflict, format!("Stripe could not be reached: {error}"))), | |
| 523 | + | }; | |
| 524 | + | let link = async { | |
| 525 | + | let configuration = stripe.portal_configuration().await?; | |
| 526 | + | let portal_url = stripe.portal_session(&customer, "https://g1t.sh/").await?; | |
| 527 | + | let customer_email = stripe.customer_email(&customer).await.ok().flatten(); | |
| 528 | + | Ok::<_, worker::Error>(g1t_contracts::billing::BillingLink { | |
| 529 | + | portal_url, | |
| 530 | + | login_url: configuration.login_page.and_then(|page| page.url), | |
| 531 | + | customer_email, | |
| 532 | + | expires_note: "The one-time link works for a short while and only once; the sign-in page does not expire." | |
| 533 | + | .to_owned(), | |
| 534 | + | }) | |
| 535 | + | } | |
| 536 | + | .await; | |
| 537 | + | match link { | |
| 538 | + | Ok(link) => { | |
| 539 | + | let account = self.account_of(&workspace).await?; | |
| 540 | + | self.audit(&account.id, "billing_link", &format!("Stripe billing link for {workspace}"), &a.by).await?; | |
| 541 | + | Ok(Outcome::Ok(link)) | |
| 542 | + | } | |
| 543 | + | Err(error) => Ok(Outcome::fail(FailureCode::Conflict, format!("Stripe's billing page could not be opened: {error}"))), | |
| 544 | + | } | |
| 545 | + | } | |
| 546 | + | ||
| 487 | 547 | pub(crate) async fn admin_credit(&self, a: AdminCreditArgs) -> Result<Outcome<LedgerEntry>> { | |
| 488 | 548 | let workspace = a.workspace.trim().to_lowercase(); | |
| 489 | 549 | if workspace.is_empty() || a.note.trim().is_empty() || a.by.trim().is_empty() { | |
| 512 | 572 | } | |
| 513 | 573 | } | |
| 514 | 574 | ||
| 575 | + | /// A workspace's figures in `list`, added at the end the first time. | |
| 576 | + | fn figures_for<'a>(list: &'a mut Vec<WorkspaceFigures>, workspace: &str) -> &'a mut WorkspaceFigures { | |
| 577 | + | let i = match list.iter().position(|f| f.workspace == workspace) { | |
| 578 | + | Some(i) => i, | |
| 579 | + | None => { | |
| 580 | + | list.push(WorkspaceFigures { workspace: workspace.to_owned(), ..Default::default() }); | |
| 581 | + | list.len() - 1 | |
| 582 | + | } | |
| 583 | + | }; | |
| 584 | + | &mut list[i] | |
| 585 | + | } | |
| 586 | + | ||
| 515 | 587 | #[cfg(test)] | |
| 516 | 588 | mod tests { | |
| 517 | 589 | use super::*; | |
| 534 | 606 | assert_eq!(describe(&custom), "custom (20% off, ceiling $50.00): Design partner"); | |
| 535 | 607 | assert_eq!(describe(&Terms::standard()), "standard"); | |
| 536 | 608 | } | |
| 609 | + | ||
| 610 | + | #[test] | |
| 611 | + | fn each_workspace_gets_one_share() { | |
| 612 | + | let mut list = vec![]; | |
| 613 | + | figures_for(&mut list, "acme").charged_micros += 5; | |
| 614 | + | figures_for(&mut list, "beta").cost_micros += 2; | |
| 615 | + | figures_for(&mut list, "acme").charged_micros += 7; | |
| 616 | + | assert_eq!(list.len(), 2); | |
| 617 | + | assert_eq!(list[0], WorkspaceFigures { workspace: "acme".into(), charged_micros: 12, ..Default::default() }); | |
| 618 | + | assert_eq!(list[1].cost_micros, 2); | |
| 619 | + | } | |
| 537 | 620 | } |
| 75 | 75 | created_by: Option<String>, | |
| 76 | 76 | created_at: String, | |
| 77 | 77 | billed_to: Option<String>, | |
| 78 | + | #[serde(default)] | |
| 79 | + | workspace: Option<String>, | |
| 78 | 80 | } | |
| 79 | 81 | ||
| 80 | 82 | impl From<LedgerRow> for LedgerEntry { | |
| 91 | 93 | billed_to: row.billed_to.unwrap_or_else(|| "g1t".to_owned()), | |
| 92 | 94 | created_by: row.created_by, | |
| 93 | 95 | created_at: row.created_at, | |
| 96 | + | workspace: row.workspace, | |
| 94 | 97 | } | |
| 95 | 98 | } | |
| 96 | 99 | } | |
| 175 | 178 | } | |
| 176 | 179 | ||
| 177 | 180 | async fn standing(&self, workspace: &str) -> Result<Account> { | |
| 181 | + | let row = self.row(workspace).await?; | |
| 182 | + | let card = match (&self.stripe, row.as_ref().and_then(|row| row.customer_id.as_deref())) { | |
| 183 | + | (Some(stripe), Some(customer)) => stripe.card(customer).await.ok().flatten().map(|card| Card { | |
| 184 | + | brand: card.brand, | |
| 185 | + | last4: card.last4, | |
| 186 | + | exp_month: card.exp_month, | |
| 187 | + | exp_year: card.exp_year, | |
| 188 | + | }), | |
| 189 | + | _ => None, | |
| 190 | + | }; | |
| 178 | 191 | Ok(Account { | |
| 179 | 192 | workspace: workspace.to_owned(), | |
| 180 | − | balance_micros: self | |
| 181 | − | .row(workspace) | |
| 182 | − | .await? | |
| 183 | − | .map_or(0, |row| row.balance_micros), | |
| 193 | + | balance_micros: row.map_or(0, |row| row.balance_micros), | |
| 184 | 194 | status: self.status(), | |
| 185 | 195 | margin_percent: self.margin_percent, | |
| 186 | 196 | orchestration_fee_micros: self.orchestration_fee_micros, | |
| 197 | + | card, | |
| 187 | 198 | }) | |
| 188 | 199 | } | |
| 189 | 200 | ||
| 201 | + | /// The workspace's customer at Stripe, made the first time one is needed. | |
| 202 | + | pub(crate) async fn customer_for(&self, workspace: &str) -> Result<String> { | |
| 203 | + | let Some(stripe) = &self.stripe else { | |
| 204 | + | return Err(worker::Error::RustError("payments are not set up".into())); | |
| 205 | + | }; | |
| 206 | + | if let Some(customer) = self.row(workspace).await?.and_then(|row| row.customer_id) { | |
| 207 | + | return Ok(customer); | |
| 208 | + | } | |
| 209 | + | let customer = stripe.create_customer(workspace).await?; | |
| 210 | + | self.db | |
| 211 | + | .prepare( | |
| 212 | + | "INSERT INTO accounts (workspace, balance_micros, customer_id, created_at) VALUES (?1, 0, ?2, ?3) | |
| 213 | + | ON CONFLICT (workspace) DO UPDATE SET customer_id = ?2", | |
| 214 | + | ) | |
| 215 | + | .bind(&[workspace.into(), customer.as_str().into(), rfc3339(now_ms()).into()])? | |
| 216 | + | .run() | |
| 217 | + | .await?; | |
| 218 | + | Ok(customer) | |
| 219 | + | } | |
| 220 | + | ||
| 221 | + | /// Stripe's hosted billing page for the workspace. Owners only. | |
| 222 | + | async fn billing_portal(&self, a: BillingPortalArgs) -> Result<Outcome<Checkout>> { | |
| 223 | + | let workspace = a.workspace.to_lowercase(); | |
| 224 | + | if a.actor.role_in(&workspace) != Some(Role::Owner) { | |
| 225 | + | return Ok(Outcome::fail(FailureCode::Forbidden, "Only an owner can manage the workspace's billing.")); | |
| 226 | + | } | |
| 227 | + | let Some(stripe) = &self.stripe else { | |
| 228 | + | return Ok(Outcome::fail(FailureCode::Conflict, "Payments are not set up on this g1t.")); | |
| 229 | + | }; | |
| 230 | + | let customer = match self.customer_for(&workspace).await { | |
| 231 | + | Ok(customer) => customer, | |
| 232 | + | Err(error) => return Ok(Outcome::fail(FailureCode::Conflict, format!("Stripe could not be reached: {error}"))), | |
| 233 | + | }; | |
| 234 | + | match stripe.portal_session(&customer, &a.return_url).await { | |
| 235 | + | Ok(url) => Ok(Outcome::Ok(Checkout { url })), | |
| 236 | + | Err(error) if stripe::is_missing(&error) => { | |
| 237 | + | // The customer was removed at Stripe: a new one next time. | |
| 238 | + | self.forget_customer(&workspace).await?; | |
| 239 | + | Ok(Outcome::fail(FailureCode::Conflict, "Stripe no longer had this workspace's customer. Try again.")) | |
| 240 | + | } | |
| 241 | + | Err(error) => Ok(Outcome::fail(FailureCode::Conflict, format!("Stripe's billing page could not be opened: {error}"))), | |
| 242 | + | } | |
| 243 | + | } | |
| 244 | + | ||
| 190 | 245 | /// Adds a ledger entry and moves the balance by the same amount, as | |
| 191 | 246 | /// one write. | |
| 192 | 247 | #[allow(clippy::too_many_arguments)] | |
| 905 | 960 | if let Err(error) = billing.autopay().await { | |
| 906 | 961 | worker::console_error!("paying at the limit failed: {error}"); | |
| 907 | 962 | } | |
| 963 | + | if let Err(error) = billing.close_months().await { | |
| 964 | + | worker::console_error!("closing the month failed: {error}"); | |
| 965 | + | } | |
| 966 | + | if let Ok(identity) = env.service("IDENTITY") { | |
| 967 | + | if let Err(error) = billing.warn_limits(&identity).await { | |
| 968 | + | worker::console_error!("warning owners failed: {error}"); | |
| 969 | + | } | |
| 970 | + | } | |
| 908 | 971 | // Once a day, and at once if the costs were never checked: check every | |
| 909 | 972 | // cost against what Cloudflare billed. | |
| 910 | 973 | if event.cron() == keeper::DAILY || billing.never_checked().await.unwrap_or(false) { | |
| 943 | 1006 | "check_limit" => reply(&billing.check_limit(args(body)?).await?), | |
| 944 | 1007 | "set_spend_limit" => reply(&billing.set_spend_limit(args(body)?).await?), | |
| 945 | 1008 | "prices" => reply(&billing.prices().await?), | |
| 1009 | + | "billing_portal" => reply(&billing.billing_portal(args(body)?).await?), | |
| 1010 | + | "admin_billing_link" => reply(&billing.admin_billing_link(args(body)?).await?), | |
| 946 | 1011 | "note_pending" => reply(&billing.note_pending(args(body)?).await?), | |
| 947 | 1012 | "admin_accounts" => reply(&billing.admin_accounts(args(body)?).await?), | |
| 948 | 1013 | "admin_account" => reply(&billing.admin_account(args(body)?).await?), |
| 145 | 145 | let used = used + pending; | |
| 146 | 146 | // Test-mode payments are not money: they pay nothing off. | |
| 147 | 147 | let live = self.stripe.as_ref().is_some_and(crate::stripe::Stripe::live); | |
| 148 | − | let exposure = (used - if live { paid_month } else { 0 }).max(0); | |
| 148 | + | // Charges from earlier months still unpaid carry over, so a new | |
| 149 | + | // month is not a fresh allowance for an account that never pays. | |
| 150 | + | // Credits g1t gave count as paid; test-mode payments do not. | |
| 151 | + | let mut before = members.clone(); | |
| 152 | + | before.push(month_start.as_str().into()); | |
| 153 | + | let carried = self | |
| 154 | + | .db | |
| 155 | + | .prepare(format!( | |
| 156 | + | "SELECT SUM(CASE WHEN kind = 'usage' THEN amount_micros | |
| 157 | + | WHEN kind = 'top_up' AND ({live} = 1 OR reference LIKE 'crd%') THEN amount_micros | |
| 158 | + | ELSE 0 END) AS paid | |
| 159 | + | FROM ledger WHERE workspace IN ({marks}) AND created_at < ?", | |
| 160 | + | live = u8::from(live) | |
| 161 | + | )) | |
| 162 | + | .bind(&before)? | |
| 163 | + | .first::<Paid>(None) | |
| 164 | + | .await? | |
| 165 | + | .and_then(|row| row.paid) | |
| 166 | + | .map_or(0, |balance| (-balance).max(0)); | |
| 167 | + | let exposure = (used - if live { paid_month } else { 0 }).max(0) + carried; | |
| 149 | 168 | ||
| 150 | 169 | let (trust, trust_ceiling) = match account.terms.kind { | |
| 151 | 170 | TermsKind::Comped => (Trust::Internal, None), | |
| 311 | 330 | { | |
| 312 | 331 | continue; | |
| 313 | 332 | } | |
| 314 | − | let cents = ((limit.exposure_micros + 9_999) / 10_000).max(AUTOPAY_MIN_CENTS); | |
| 315 | − | let key = format!("autopay/{}/{}/{}", candidate.workspace, &month_start[..7], limit.exposure_micros / 1_000_000); | |
| 333 | + | // What it owes: its charges less what it has paid, never the cost | |
| 334 | + | // of what was free to it. At least the minimum, which is credit | |
| 335 | + | // toward what comes next. | |
| 336 | + | let balance = self.row(&candidate.workspace).await?.map_or(0, |row| row.balance_micros); | |
| 337 | + | let owed = (-balance).max(0); | |
| 338 | + | if owed == 0 { | |
| 339 | + | continue; | |
| 340 | + | } | |
| 341 | + | let cents = ((owed + 9_999) / 10_000).max(AUTOPAY_MIN_CENTS); | |
| 342 | + | let key = format!("autopay/{}/{}/{}", candidate.workspace, &month_start[..7], owed / 1_000_000); | |
| 316 | 343 | let description = format!("g1t usage for {}, paid automatically near its limit", candidate.workspace); | |
| 317 | 344 | let now = rfc3339(now_ms()); | |
| 318 | 345 | match stripe.charge_saved_card(&customer, cents, &description, &key).await { | |
| 319 | 346 | Ok(payment) if payment.status == "succeeded" => { | |
| 347 | + | // A retried charge is the same payment: credited once. | |
| 348 | + | let seen = self | |
| 349 | + | .db | |
| 350 | + | .prepare("SELECT id FROM ledger WHERE reference = ?") | |
| 351 | + | .bind(&[payment.id.as_str().into()])? | |
| 352 | + | .first::<serde_json::Value>(None) | |
| 353 | + | .await?; | |
| 354 | + | if seen.is_some() { | |
| 355 | + | continue; | |
| 356 | + | } | |
| 320 | 357 | self.enter( | |
| 321 | 358 | &candidate.workspace, | |
| 322 | 359 | g1t_contracts::billing::EntryKind::TopUp, | |
| 354 | 391 | Ok(()) | |
| 355 | 392 | } | |
| 356 | 393 | ||
| 394 | + | /// Closes last month for each workspace with a card on file: charges | |
| 395 | + | /// what it owed when the month ended. Live payments only, once per | |
| 396 | + | /// workspace and month; a declined card stops work until it is paid. | |
| 397 | + | /// Comped workspaces owe nothing, and enterprises are invoiced. | |
| 398 | + | pub(crate) async fn close_months(&self) -> Result<()> { | |
| 399 | + | let Some(stripe) = self.stripe.as_ref().filter(|stripe| stripe.live()) else { | |
| 400 | + | return Ok(()); | |
| 401 | + | }; | |
| 402 | + | let now = rfc3339(now_ms()); | |
| 403 | + | let month_start = format!("{}-01", &now[..7]); | |
| 404 | + | let closing = previous_month(&now[..7]); | |
| 405 | + | #[derive(Deserialize)] | |
| 406 | + | struct Open { | |
| 407 | + | workspace: String, | |
| 408 | + | customer_id: String, | |
| 409 | + | balance: Option<i64>, | |
| 410 | + | } | |
| 411 | + | let open = self | |
| 412 | + | .db | |
| 413 | + | .prepare( | |
| 414 | + | "SELECT accounts.workspace AS workspace, accounts.customer_id AS customer_id, | |
| 415 | + | (SELECT SUM(amount_micros) FROM ledger | |
| 416 | + | WHERE ledger.workspace = accounts.workspace AND ledger.created_at < ?1) AS balance | |
| 417 | + | FROM accounts | |
| 418 | + | WHERE accounts.customer_id IS NOT NULL | |
| 419 | + | AND NOT EXISTS (SELECT 1 FROM month_closes | |
| 420 | + | WHERE month_closes.workspace = accounts.workspace AND month_closes.month = ?2) | |
| 421 | + | LIMIT 20", | |
| 422 | + | ) | |
| 423 | + | .bind(&[month_start.as_str().into(), closing.as_str().into()])? | |
| 424 | + | .all() | |
| 425 | + | .await? | |
| 426 | + | .results::<Open>()?; | |
| 427 | + | for account in open { | |
| 428 | + | let record = |status: &str, amount: i64, payment: Option<&str>, error: Option<&str>| { | |
| 429 | + | self.db | |
| 430 | + | .prepare( | |
| 431 | + | "INSERT OR IGNORE INTO month_closes (workspace, month, status, amount_micros, payment_id, error, closed_at) | |
| 432 | + | VALUES (?, ?, ?, ?, ?, ?, ?)", | |
| 433 | + | ) | |
| 434 | + | .bind(&[ | |
| 435 | + | account.workspace.as_str().into(), | |
| 436 | + | closing.as_str().into(), | |
| 437 | + | status.into(), | |
| 438 | + | (amount as f64).into(), | |
| 439 | + | crate::optional(payment), | |
| 440 | + | crate::optional(error), | |
| 441 | + | now.as_str().into(), | |
| 442 | + | ]) | |
| 443 | + | }; | |
| 444 | + | let payer = self.account_of(&account.workspace).await?; | |
| 445 | + | if payer.terms.kind == TermsKind::Comped || payer.id.starts_with("ent_") { | |
| 446 | + | record("skipped", 0, None, None)?.run().await?; | |
| 447 | + | continue; | |
| 448 | + | } | |
| 449 | + | let owed = (-account.balance.unwrap_or(0)).max(0); | |
| 450 | + | if owed < 10_000 { | |
| 451 | + | // Under a cent: nothing worth charging. | |
| 452 | + | record("nothing", 0, None, None)?.run().await?; | |
| 453 | + | continue; | |
| 454 | + | } | |
| 455 | + | let cents = (owed + 9_999) / 10_000; | |
| 456 | + | let key = format!("close/{}/{closing}", account.workspace); | |
| 457 | + | let description = format!("g1t usage for {} in {closing}", account.workspace); | |
| 458 | + | match stripe.charge_saved_card(&account.customer_id, cents, &description, &key).await { | |
| 459 | + | Ok(payment) if payment.status == "succeeded" => { | |
| 460 | + | let seen = self | |
| 461 | + | .db | |
| 462 | + | .prepare("SELECT id FROM ledger WHERE reference = ?") | |
| 463 | + | .bind(&[payment.id.as_str().into()])? | |
| 464 | + | .first::<serde_json::Value>(None) | |
| 465 | + | .await?; | |
| 466 | + | if seen.is_none() { | |
| 467 | + | self.enter( | |
| 468 | + | &account.workspace, | |
| 469 | + | g1t_contracts::billing::EntryKind::TopUp, | |
| 470 | + | payment.amount_received.max(cents) * 10_000, | |
| 471 | + | &format!("Usage for {closing}, charged to the card on file when the month closed"), | |
| 472 | + | &payment.id, | |
| 473 | + | None, | |
| 474 | + | None, | |
| 475 | + | None, | |
| 476 | + | Some(&account.customer_id), | |
| 477 | + | ) | |
| 478 | + | .await?; | |
| 479 | + | } | |
| 480 | + | record("paid", cents * 10_000, Some(&payment.id), None)?.run().await?; | |
| 481 | + | } | |
| 482 | + | outcome => { | |
| 483 | + | let error = match outcome { | |
| 484 | + | Ok(payment) => format!("the payment is {}", payment.status.replace('_', " ")), | |
| 485 | + | Err(error) => error.to_string().chars().take(200).collect(), | |
| 486 | + | }; | |
| 487 | + | self.db | |
| 488 | + | .prepare( | |
| 489 | + | "INSERT INTO limits (workspace, autopay_failed_at, autopay_error, updated_at) VALUES (?1, ?2, ?3, ?2) | |
| 490 | + | ON CONFLICT (workspace) DO UPDATE SET autopay_failed_at = ?2, autopay_error = ?3, updated_at = ?2", | |
| 491 | + | ) | |
| 492 | + | .bind(&[account.workspace.as_str().into(), now.as_str().into(), error.as_str().into()])? | |
| 493 | + | .run() | |
| 494 | + | .await?; | |
| 495 | + | record("failed", cents * 10_000, None, Some(&error))?.run().await?; | |
| 496 | + | } | |
| 497 | + | } | |
| 498 | + | } | |
| 499 | + | Ok(()) | |
| 500 | + | } | |
| 501 | + | ||
| 502 | + | /// Emails a workspace's owners as it passes 50%, 80% and 100% of its | |
| 503 | + | /// limit, once each a month, and when its card was declined, so that | |
| 504 | + | /// work never stops without warning. | |
| 505 | + | pub(crate) async fn warn_limits(&self, identity: &worker::Fetcher) -> Result<()> { | |
| 506 | + | if self.stripe.is_none() { | |
| 507 | + | return Ok(()); | |
| 508 | + | } | |
| 509 | + | let now = rfc3339(now_ms()); | |
| 510 | + | let month = &now[..7]; | |
| 511 | + | #[derive(Deserialize)] | |
| 512 | + | struct Candidate { | |
| 513 | + | workspace: String, | |
| 514 | + | } | |
| 515 | + | let candidates = self | |
| 516 | + | .db | |
| 517 | + | .prepare( | |
| 518 | + | "SELECT DISTINCT workspace FROM ledger WHERE kind = 'usage' AND created_at >= ?1 | |
| 519 | + | UNION SELECT workspace FROM limits WHERE autopay_failed_at IS NOT NULL", | |
| 520 | + | ) | |
| 521 | + | .bind(&[format!("{month}-01").into()])? | |
| 522 | + | .all() | |
| 523 | + | .await? | |
| 524 | + | .results::<Candidate>()?; | |
| 525 | + | #[derive(Deserialize)] | |
| 526 | + | struct Told { | |
| 527 | + | warned_month: Option<String>, | |
| 528 | + | warned_level: Option<i64>, | |
| 529 | + | autopay_failed_at: Option<String>, | |
| 530 | + | declined_told_at: Option<String>, | |
| 531 | + | } | |
| 532 | + | for Candidate { workspace } in candidates { | |
| 533 | + | let limit = self.limit_of(&workspace).await?; | |
| 534 | + | let told = self | |
| 535 | + | .db | |
| 536 | + | .prepare("SELECT warned_month, warned_level, autopay_failed_at, declined_told_at FROM limits WHERE workspace = ?") | |
| 537 | + | .bind(&[workspace.as_str().into()])? | |
| 538 | + | .first::<Told>(None) | |
| 539 | + | .await?; | |
| 540 | + | let billing = format!("https://g1t.sh/{workspace}/-/billing"); | |
| 541 | + | ||
| 542 | + | // A declined card, once per decline. | |
| 543 | + | if let Some(Told { autopay_failed_at: Some(failed), declined_told_at, .. }) = &told { | |
| 544 | + | if declined_told_at.as_deref().is_none_or(|at| at < failed.as_str()) { | |
| 545 | + | let sent = notify( | |
| 546 | + | identity, | |
| 547 | + | &workspace, | |
| 548 | + | &format!("g1t: the card for {workspace} was declined"), | |
| 549 | + | &limit.message.clone().unwrap_or_else(|| format!("g1t could not charge the card on file for {workspace}.")), | |
| 550 | + | "Update the card", | |
| 551 | + | &billing, | |
| 552 | + | ) | |
| 553 | + | .await; | |
| 554 | + | if sent { | |
| 555 | + | self.db | |
| 556 | + | .prepare("UPDATE limits SET declined_told_at = ? WHERE workspace = ?") | |
| 557 | + | .bind(&[now.as_str().into(), workspace.as_str().into()])? | |
| 558 | + | .run() | |
| 559 | + | .await?; | |
| 560 | + | } | |
| 561 | + | } | |
| 562 | + | } | |
| 563 | + | ||
| 564 | + | let Some(ceiling) = limit.ceiling_micros.filter(|c| *c > 0) else { continue }; | |
| 565 | + | let level = warning_level(limit.exposure_micros, ceiling); | |
| 566 | + | let already = told | |
| 567 | + | .as_ref() | |
| 568 | + | .filter(|t| t.warned_month.as_deref() == Some(month)) | |
| 569 | + | .and_then(|t| t.warned_level) | |
| 570 | + | .unwrap_or(0); | |
| 571 | + | if level <= already { | |
| 572 | + | continue; | |
| 573 | + | } | |
| 574 | + | let (subject, intro) = match level { | |
| 575 | + | 100 => ( | |
| 576 | + | format!("g1t: {workspace} reached its usage limit"), | |
| 577 | + | limit.message.clone().unwrap_or_else(|| format!("{workspace} reached its usage limit.")), | |
| 578 | + | ), | |
| 579 | + | _ => ( | |
| 580 | + | format!("g1t: {workspace} has used {level}% of its usage limit"), | |
| 581 | + | format!( | |
| 582 | + | "{workspace} has used {} of its {} usage limit this month. At the limit its sandboxes, builds and apps stop until it pays or the month turns. With a card on file, g1t charges it as the limit nears, so work keeps going.", | |
| 583 | + | dollars_plain(limit.exposure_micros), | |
| 584 | + | dollars_plain(ceiling), | |
| 585 | + | ), | |
| 586 | + | ), | |
| 587 | + | }; | |
| 588 | + | if notify(identity, &workspace, &subject, &intro, "Open billing", &billing).await { | |
| 589 | + | self.db | |
| 590 | + | .prepare( | |
| 591 | + | "INSERT INTO limits (workspace, warned_month, warned_level, updated_at) VALUES (?1, ?2, ?3, ?4) | |
| 592 | + | ON CONFLICT (workspace) DO UPDATE SET warned_month = ?2, warned_level = ?3, updated_at = ?4", | |
| 593 | + | ) | |
| 594 | + | .bind(&[workspace.as_str().into(), month.into(), (level as f64).into(), now.as_str().into()])? | |
| 595 | + | .run() | |
| 596 | + | .await?; | |
| 597 | + | } | |
| 598 | + | } | |
| 599 | + | Ok(()) | |
| 600 | + | } | |
| 601 | + | ||
| 357 | 602 | pub(crate) async fn check_limit(&self, a: CheckLimitArgs) -> Result<Outcome<Limit>> { | |
| 358 | 603 | Ok(Outcome::Ok(self.limit_of(&a.workspace).await?)) | |
| 359 | 604 | } | |
| 382 | 627 | } | |
| 383 | 628 | } | |
| 384 | 629 | ||
| 630 | + | /// Which warning a workspace has reached: 100, 80, 50 or none (0). | |
| 631 | + | pub(crate) fn warning_level(exposure: i64, ceiling: i64) -> i64 { | |
| 632 | + | if exposure >= ceiling { | |
| 633 | + | 100 | |
| 634 | + | } else if exposure * 5 >= ceiling * 4 { | |
| 635 | + | 80 | |
| 636 | + | } else if exposure * 2 >= ceiling { | |
| 637 | + | 50 | |
| 638 | + | } else { | |
| 639 | + | 0 | |
| 640 | + | } | |
| 641 | + | } | |
| 642 | + | ||
| 643 | + | /// Emails the workspace's owners through identity. False if nothing was sent. | |
| 644 | + | async fn notify(identity: &worker::Fetcher, workspace: &str, subject: &str, intro: &str, action: &str, link: &str) -> bool { | |
| 645 | + | let args = g1t_contracts::identity::NotifyOwnersArgs { | |
| 646 | + | workspace: workspace.to_owned(), | |
| 647 | + | subject: subject.to_owned(), | |
| 648 | + | intro: intro.to_owned(), | |
| 649 | + | action: action.to_owned(), | |
| 650 | + | link: link.to_owned(), | |
| 651 | + | footer: "You get this because you own this workspace on g1t. Usage limits are explained at https://docs.g1t.sh/guides/usage-and-billing/#usage-limits".to_owned(), | |
| 652 | + | }; | |
| 653 | + | match g1t_kit::call::<_, u32>(identity, "notify_owners", &args).await { | |
| 654 | + | Ok(sent) => sent > 0, | |
| 655 | + | Err(error) => { | |
| 656 | + | worker::console_error!("could not tell {workspace}'s owners: {error}"); | |
| 657 | + | false | |
| 658 | + | } | |
| 659 | + | } | |
| 660 | + | } | |
| 661 | + | ||
| 662 | + | /// `2026-09` for `2026-10`, and `2025-12` for `2026-01`. | |
| 663 | + | pub(crate) fn previous_month(month: &str) -> String { | |
| 664 | + | let year: i32 = month[..4].parse().unwrap_or(1970); | |
| 665 | + | let number: u32 = month[5..7].parse().unwrap_or(1); | |
| 666 | + | if number == 1 { | |
| 667 | + | format!("{}-12", year - 1) | |
| 668 | + | } else { | |
| 669 | + | format!("{year}-{:02}", number - 1) | |
| 670 | + | } | |
| 671 | + | } | |
| 672 | + | ||
| 385 | 673 | #[cfg(test)] | |
| 386 | 674 | mod tests { | |
| 387 | 675 | use super::*; | |
| 388 | 676 | ||
| 677 | + | #[test] | |
| 678 | + | fn warnings_come_at_half_four_fifths_and_the_limit() { | |
| 679 | + | assert_eq!(warning_level(0, 300), 0); | |
| 680 | + | assert_eq!(warning_level(149, 300), 0); | |
| 681 | + | assert_eq!(warning_level(150, 300), 50); | |
| 682 | + | assert_eq!(warning_level(240, 300), 80); | |
| 683 | + | assert_eq!(warning_level(300, 300), 100); | |
| 684 | + | } | |
| 685 | + | ||
| 686 | + | #[test] | |
| 687 | + | fn the_month_before_wraps_the_year() { | |
| 688 | + | assert_eq!(previous_month("2026-10"), "2026-09"); | |
| 689 | + | assert_eq!(previous_month("2026-01"), "2025-12"); | |
| 690 | + | } | |
| 691 | + | ||
| 389 | 692 | fn ceilings() -> Ceilings { | |
| 390 | 693 | Ceilings { new: 3_000_000, paid_min: 25_000_000, paid_max: 1_000_000_000 } | |
| 391 | 694 | } |
| 27 | 27 | pub subscription: Option<String>, | |
| 28 | 28 | } | |
| 29 | 29 | ||
| 30 | + | /// g1t's settings for Stripe's hosted billing page. | |
| 31 | + | #[derive(Debug, Deserialize)] | |
| 32 | + | pub struct PortalConfiguration { | |
| 33 | + | pub id: String, | |
| 34 | + | #[serde(default)] | |
| 35 | + | pub login_page: Option<LoginPage>, | |
| 36 | + | #[serde(default)] | |
| 37 | + | pub metadata: Option<std::collections::HashMap<String, String>>, | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | #[derive(Debug, Deserialize)] | |
| 41 | + | pub struct LoginPage { | |
| 42 | + | pub url: Option<String>, | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | /// A saved card's details. | |
| 46 | + | #[derive(Debug, Deserialize)] | |
| 47 | + | pub struct SavedCard { | |
| 48 | + | pub brand: String, | |
| 49 | + | pub last4: String, | |
| 50 | + | pub exp_month: u32, | |
| 51 | + | pub exp_year: u32, | |
| 52 | + | } | |
| 53 | + | ||
| 30 | 54 | /// A monthly plan. | |
| 31 | 55 | #[derive(Deserialize)] | |
| 32 | 56 | pub struct StripeSubscription { | |
| 184 | 208 | self.send(Method::Post, "/payment_intents", Some(form(&fields)), Some(key)).await | |
| 185 | 209 | } | |
| 186 | 210 | ||
| 211 | + | /// A customer for a workspace that has none yet. | |
| 212 | + | pub async fn create_customer(&self, workspace: &str) -> Result<String> { | |
| 213 | + | #[derive(Deserialize)] | |
| 214 | + | struct Customer { | |
| 215 | + | id: String, | |
| 216 | + | } | |
| 217 | + | let fields = [ | |
| 218 | + | ("name", workspace.to_owned()), | |
| 219 | + | ("metadata[workspace]", workspace.to_owned()), | |
| 220 | + | ]; | |
| 221 | + | let customer: Customer = self.call(Method::Post, "/customers", Some(form(&fields))).await?; | |
| 222 | + | Ok(customer.id) | |
| 223 | + | } | |
| 224 | + | ||
| 225 | + | /// A session on Stripe's hosted billing page (the customer portal) for | |
| 226 | + | /// the customer, coming back to `return_url`. | |
| 227 | + | pub async fn portal_session(&self, customer: &str, return_url: &str) -> Result<String> { | |
| 228 | + | #[derive(Deserialize)] | |
| 229 | + | struct Portal { | |
| 230 | + | url: String, | |
| 231 | + | } | |
| 232 | + | let configuration = self.portal_configuration().await?; | |
| 233 | + | let fields = [ | |
| 234 | + | ("customer", customer.to_owned()), | |
| 235 | + | ("return_url", return_url.to_owned()), | |
| 236 | + | ("configuration", configuration.id), | |
| 237 | + | ]; | |
| 238 | + | let portal: Portal = self.call(Method::Post, "/billing_portal/sessions", Some(form(&fields))).await?; | |
| 239 | + | Ok(portal.url) | |
| 240 | + | } | |
| 241 | + | ||
| 242 | + | /// g1t's billing page settings at Stripe, made the first time they are | |
| 243 | + | /// needed: cards, invoices, billing details, and a sign-in page. | |
| 244 | + | pub async fn portal_configuration(&self) -> Result<PortalConfiguration> { | |
| 245 | + | #[derive(Deserialize)] | |
| 246 | + | struct List { | |
| 247 | + | data: Vec<PortalConfiguration>, | |
| 248 | + | } | |
| 249 | + | let list: List = self | |
| 250 | + | .call(Method::Get, "/billing_portal/configurations?active=true&limit=20", None) | |
| 251 | + | .await?; | |
| 252 | + | if let Some(existing) = list | |
| 253 | + | .data | |
| 254 | + | .into_iter() | |
| 255 | + | .find(|c| c.metadata.as_ref().and_then(|m| m.get("g1t")).is_some()) | |
| 256 | + | { | |
| 257 | + | return Ok(existing); | |
| 258 | + | } | |
| 259 | + | let fields = [ | |
| 260 | + | ("business_profile[headline]", "g1t billing: your card, invoices and billing details".to_owned()), | |
| 261 | + | ("features[payment_method_update][enabled]", "true".to_owned()), | |
| 262 | + | ("features[invoice_history][enabled]", "true".to_owned()), | |
| 263 | + | ("features[customer_update][enabled]", "true".to_owned()), | |
| 264 | + | ("features[customer_update][allowed_updates][0]", "email".to_owned()), | |
| 265 | + | ("features[customer_update][allowed_updates][1]", "address".to_owned()), | |
| 266 | + | ("features[customer_update][allowed_updates][2]", "name".to_owned()), | |
| 267 | + | ("features[customer_update][allowed_updates][3]", "tax_id".to_owned()), | |
| 268 | + | ("login_page[enabled]", "true".to_owned()), | |
| 269 | + | ("metadata[g1t]", "billing".to_owned()), | |
| 270 | + | ]; | |
| 271 | + | self.call(Method::Post, "/billing_portal/configurations", Some(form(&fields))).await | |
| 272 | + | } | |
| 273 | + | ||
| 274 | + | /// The customer's email at Stripe, if they gave one. | |
| 275 | + | pub async fn customer_email(&self, customer: &str) -> Result<Option<String>> { | |
| 276 | + | #[derive(Deserialize)] | |
| 277 | + | struct Customer { | |
| 278 | + | email: Option<String>, | |
| 279 | + | } | |
| 280 | + | let found: Customer = self.call(Method::Get, &format!("/customers/{}", encode(customer)), None).await?; | |
| 281 | + | Ok(found.email) | |
| 282 | + | } | |
| 283 | + | ||
| 284 | + | /// The customer's card, if one is saved. | |
| 285 | + | pub async fn card(&self, customer: &str) -> Result<Option<SavedCard>> { | |
| 286 | + | #[derive(Deserialize)] | |
| 287 | + | struct Methods { | |
| 288 | + | data: Vec<Method_>, | |
| 289 | + | } | |
| 290 | + | #[derive(Deserialize)] | |
| 291 | + | struct Method_ { | |
| 292 | + | card: Option<SavedCard>, | |
| 293 | + | } | |
| 294 | + | let methods: Methods = self | |
| 295 | + | .call(Method::Get, &format!("/payment_methods?customer={}&type=card&limit=1", encode(customer)), None) | |
| 296 | + | .await?; | |
| 297 | + | Ok(methods.data.into_iter().next().and_then(|m| m.card)) | |
| 298 | + | } | |
| 299 | + | ||
| 187 | 300 | /// Starts a page on which `amount_cents` of credit is paid for by card. | |
| 188 | 301 | /// The card is kept for the workspace, so that topping up again, by | |
| 189 | 302 | /// hand or automatically, needs no retyping. |
| 18 | 18 | "migrations_dir": "migrations" | |
| 19 | 19 | } | |
| 20 | 20 | ], | |
| 21 | + | // Identity emails owners as their workspace nears its usage limit. | |
| 22 | + | "services": [{ "binding": "IDENTITY", "service": "g1t-identity" }], | |
| 21 | 23 | "vars": { | |
| 22 | 24 | // What is added to a run's cost, in percent. It pays the card | |
| 23 | 25 | // processor's fee and the sandbox the agent ran in. |
| 1 | + | //! Staff-only views of workspaces, for sudo.g1t.sh. | |
| 2 | + | //! | |
| 3 | + | //! These methods take no viewer and check no membership. Only sudo calls | |
| 4 | + | //! them, over its service binding, once it has verified a Cloudflare Access | |
| 5 | + | //! sign-in against its staff list; nothing a customer can reach forwards to | |
| 6 | + | //! them. They read, and never change, anything. | |
| 7 | + | //! | |
| 8 | + | //! Every workspace is listed: there is one kind, and a person's own space | |
| 9 | + | //! is simply a workspace with one member. A user with no workspace has | |
| 10 | + | //! nothing to list, as nothing can exist outside one. | |
| 11 | + | ||
| 12 | + | use std::collections::HashMap; | |
| 13 | + | ||
| 14 | + | use g1t_contracts::Role; | |
| 15 | + | use g1t_contracts::identity::*; | |
| 16 | + | use serde::Deserialize; | |
| 17 | + | use worker::Result; | |
| 18 | + | ||
| 19 | + | use crate::Identity; | |
| 20 | + | ||
| 21 | + | #[derive(Deserialize)] | |
| 22 | + | struct ListRow { | |
| 23 | + | id: String, | |
| 24 | + | slug: String, | |
| 25 | + | name: String, | |
| 26 | + | created_at: String, | |
| 27 | + | member_count: u32, | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | #[derive(Deserialize)] | |
| 31 | + | struct OwnerRow { | |
| 32 | + | workspace_id: String, | |
| 33 | + | username: String, | |
| 34 | + | email: Option<String>, | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | #[derive(Deserialize)] | |
| 38 | + | struct DetailRow { | |
| 39 | + | id: String, | |
| 40 | + | slug: String, | |
| 41 | + | name: String, | |
| 42 | + | description: Option<String>, | |
| 43 | + | created_at: String, | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | #[derive(Deserialize)] | |
| 47 | + | struct MemberRow { | |
| 48 | + | username: String, | |
| 49 | + | email: Option<String>, | |
| 50 | + | role: Role, | |
| 51 | + | joined: String, | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | /// A `LIKE` pattern matching `query` anywhere, lowercased, with `%`, `_` | |
| 55 | + | /// and the escape character itself taken literally. None for a blank query. | |
| 56 | + | fn like_pattern(query: Option<&str>) -> Option<String> { | |
| 57 | + | let query = query.map(str::trim).filter(|q| !q.is_empty())?; | |
| 58 | + | let mut pattern = String::from("%"); | |
| 59 | + | for c in query.to_lowercase().chars().take(100) { | |
| 60 | + | if matches!(c, '%' | '_' | '\\') { | |
| 61 | + | pattern.push('\\'); | |
| 62 | + | } | |
| 63 | + | pattern.push(c); | |
| 64 | + | } | |
| 65 | + | pattern.push('%'); | |
| 66 | + | Some(pattern) | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | /// The workspaces, in their order, each with its owners. | |
| 70 | + | fn with_owners(rows: Vec<ListRow>, owners: Vec<OwnerRow>) -> Vec<AdminWorkspace> { | |
| 71 | + | let mut by_workspace: HashMap<String, Vec<AdminOwner>> = HashMap::new(); | |
| 72 | + | for owner in owners { | |
| 73 | + | by_workspace.entry(owner.workspace_id).or_default().push(AdminOwner { | |
| 74 | + | username: owner.username, | |
| 75 | + | email: owner.email, | |
| 76 | + | }); | |
| 77 | + | } | |
| 78 | + | rows.into_iter() | |
| 79 | + | .map(|row| AdminWorkspace { | |
| 80 | + | owners: by_workspace.remove(&row.id).unwrap_or_default(), | |
| 81 | + | slug: row.slug, | |
| 82 | + | name: row.name, | |
| 83 | + | created_at: row.created_at, | |
| 84 | + | member_count: row.member_count, | |
| 85 | + | }) | |
| 86 | + | .collect() | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | impl Identity { | |
| 90 | + | /// The workspaces staff can see, newest first. Staff only: see the | |
| 91 | + | /// module's note. | |
| 92 | + | /// Emails each owner of the workspace with a confirmed address. Only | |
| 93 | + | /// other services call this; the link must stay on g1t.sh, so a notice | |
| 94 | + | /// can never point owners anywhere else. | |
| 95 | + | pub async fn notify_owners(&self, a: NotifyOwnersArgs) -> Result<u32> { | |
| 96 | + | if !a.link.starts_with("https://g1t.sh/") { | |
| 97 | + | return Ok(0); | |
| 98 | + | } | |
| 99 | + | #[derive(Deserialize)] | |
| 100 | + | struct Owner { | |
| 101 | + | email: Option<String>, | |
| 102 | + | } | |
| 103 | + | let owners = self | |
| 104 | + | .db | |
| 105 | + | .prepare( | |
| 106 | + | "SELECT u.email FROM workspace_members m | |
| 107 | + | JOIN users u ON u.id = m.user_id | |
| 108 | + | JOIN workspaces w ON w.id = m.workspace_id | |
| 109 | + | WHERE w.slug = ? AND m.role = 'owner' AND u.email_verified_at IS NOT NULL", | |
| 110 | + | ) | |
| 111 | + | .bind(&[a.workspace.to_lowercase().into()])? | |
| 112 | + | .all() | |
| 113 | + | .await? | |
| 114 | + | .results::<Owner>()?; | |
| 115 | + | let mut sent = 0; | |
| 116 | + | for email in owners.into_iter().filter_map(|owner| owner.email) { | |
| 117 | + | match crate::email::send_link(&self.env, &email, &a.subject, &a.intro, &a.action, &a.link, &a.footer).await { | |
| 118 | + | Ok(()) => sent += 1, | |
| 119 | + | Err(error) => worker::console_error!("could not email an owner of {}: {error}", a.workspace), | |
| 120 | + | } | |
| 121 | + | } | |
| 122 | + | Ok(sent) | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | pub async fn admin_workspaces(&self, a: AdminWorkspacesArgs) -> Result<Vec<AdminWorkspace>> { | |
| 126 | + | let pattern = like_pattern(a.query.as_deref()); | |
| 127 | + | // The same filter picks the workspaces and, below, their owners. | |
| 128 | + | let filter = if pattern.is_some() { | |
| 129 | + | "WHERE w.slug LIKE ?1 ESCAPE '\\' OR lower(w.name) LIKE ?1 ESCAPE '\\' | |
| 130 | + | OR EXISTS (SELECT 1 FROM workspace_members om JOIN users ou ON ou.id = om.user_id | |
| 131 | + | WHERE om.workspace_id = w.id AND om.role = 'owner' | |
| 132 | + | AND (lower(ou.username) LIKE ?1 ESCAPE '\\' OR lower(ou.email) LIKE ?1 ESCAPE '\\'))" | |
| 133 | + | } else { | |
| 134 | + | "" | |
| 135 | + | }; | |
| 136 | + | let chosen = format!( | |
| 137 | + | "SELECT w.id FROM workspaces w {filter} | |
| 138 | + | ORDER BY w.created_at DESC, w.slug LIMIT {ADMIN_WORKSPACES_LIMIT}" | |
| 139 | + | ); | |
| 140 | + | let binds: Vec<worker::wasm_bindgen::JsValue> = pattern.into_iter().map(Into::into).collect(); | |
| 141 | + | let rows = self | |
| 142 | + | .db | |
| 143 | + | .prepare(format!( | |
| 144 | + | "SELECT w.id, w.slug, w.name, w.created_at, | |
| 145 | + | (SELECT count(*) FROM workspace_members m WHERE m.workspace_id = w.id) AS member_count | |
| 146 | + | FROM workspaces w WHERE w.id IN ({chosen}) | |
| 147 | + | ORDER BY w.created_at DESC, w.slug" | |
| 148 | + | )) | |
| 149 | + | .bind(&binds)? | |
| 150 | + | .all() | |
| 151 | + | .await? | |
| 152 | + | .results::<ListRow>()?; | |
| 153 | + | let owners = self | |
| 154 | + | .db | |
| 155 | + | .prepare(format!( | |
| 156 | + | "SELECT m.workspace_id, u.username, u.email FROM workspace_members m | |
| 157 | + | JOIN users u ON u.id = m.user_id | |
| 158 | + | WHERE m.role = 'owner' AND m.workspace_id IN ({chosen}) | |
| 159 | + | ORDER BY m.created_at, u.username" | |
| 160 | + | )) | |
| 161 | + | .bind(&binds)? | |
| 162 | + | .all() | |
| 163 | + | .await? | |
| 164 | + | .results::<OwnerRow>()?; | |
| 165 | + | Ok(with_owners(rows, owners)) | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | /// One workspace with every member, or None. Staff only: see the | |
| 169 | + | /// module's note. | |
| 170 | + | pub async fn admin_workspace(&self, a: SlugArgs) -> Result<Option<AdminWorkspaceDetail>> { | |
| 171 | + | let Some(row) = self | |
| 172 | + | .db | |
| 173 | + | .prepare("SELECT id, slug, name, description, created_at FROM workspaces WHERE slug = ?") | |
| 174 | + | .bind(&[a.slug.trim().to_lowercase().into()])? | |
| 175 | + | .first::<DetailRow>(None) | |
| 176 | + | .await? | |
| 177 | + | else { | |
| 178 | + | return Ok(None); | |
| 179 | + | }; | |
| 180 | + | let members = self | |
| 181 | + | .db | |
| 182 | + | .prepare( | |
| 183 | + | "SELECT u.username, u.email, m.role, m.created_at AS joined FROM workspace_members m | |
| 184 | + | JOIN users u ON u.id = m.user_id | |
| 185 | + | WHERE m.workspace_id = ? | |
| 186 | + | ORDER BY m.role DESC, u.username", | |
| 187 | + | ) | |
| 188 | + | .bind(&[row.id.as_str().into()])? | |
| 189 | + | .all() | |
| 190 | + | .await? | |
| 191 | + | .results::<MemberRow>()? | |
| 192 | + | .into_iter() | |
| 193 | + | .map(|m| AdminMember { | |
| 194 | + | username: m.username, | |
| 195 | + | email: m.email, | |
| 196 | + | role: m.role, | |
| 197 | + | joined: m.joined, | |
| 198 | + | }) | |
| 199 | + | .collect(); | |
| 200 | + | Ok(Some(AdminWorkspaceDetail { | |
| 201 | + | slug: row.slug, | |
| 202 | + | name: row.name, | |
| 203 | + | description: row.description, | |
| 204 | + | created_at: row.created_at, | |
| 205 | + | members, | |
| 206 | + | })) | |
| 207 | + | } | |
| 208 | + | } | |
| 209 | + | ||
| 210 | + | #[cfg(test)] | |
| 211 | + | mod tests { | |
| 212 | + | use super::*; | |
| 213 | + | ||
| 214 | + | #[test] | |
| 215 | + | fn a_blank_query_matches_everything() { | |
| 216 | + | assert_eq!(like_pattern(None), None); | |
| 217 | + | assert_eq!(like_pattern(Some(" ")), None); | |
| 218 | + | } | |
| 219 | + | ||
| 220 | + | #[test] | |
| 221 | + | fn a_query_is_matched_literally_anywhere() { | |
| 222 | + | assert_eq!(like_pattern(Some(" Acme ")).as_deref(), Some("%acme%")); | |
| 223 | + | assert_eq!(like_pattern(Some("50%_off\\")).as_deref(), Some("%50\\%\\_off\\\\%")); | |
| 224 | + | } | |
| 225 | + | ||
| 226 | + | #[test] | |
| 227 | + | fn owners_go_to_their_workspaces_in_order() { | |
| 228 | + | let row = |id: &str, slug: &str| ListRow { | |
| 229 | + | id: id.into(), | |
| 230 | + | slug: slug.into(), | |
| 231 | + | name: slug.into(), | |
| 232 | + | created_at: "2026-10-04T00:00:00Z".into(), | |
| 233 | + | member_count: 2, | |
| 234 | + | }; | |
| 235 | + | let owner = |workspace_id: &str, username: &str| OwnerRow { | |
| 236 | + | workspace_id: workspace_id.into(), | |
| 237 | + | username: username.into(), | |
| 238 | + | email: Some(format!("{username}@example.com")), | |
| 239 | + | }; | |
| 240 | + | let listed = with_owners( | |
| 241 | + | vec![row("wsp_2", "newer"), row("wsp_1", "older"), row("wsp_3", "orphan")], | |
| 242 | + | vec![owner("wsp_1", "ada"), owner("wsp_2", "bob"), owner("wsp_1", "cy")], | |
| 243 | + | ); | |
| 244 | + | let slugs: Vec<_> = listed.iter().map(|w| w.slug.as_str()).collect(); | |
| 245 | + | assert_eq!(slugs, ["newer", "older", "orphan"]); | |
| 246 | + | let owners = |i: usize| listed[i].owners.iter().map(|o| o.username.as_str()).collect::<Vec<_>>(); | |
| 247 | + | assert_eq!(owners(0), ["bob"]); | |
| 248 | + | assert_eq!(owners(1), ["ada", "cy"]); | |
| 249 | + | assert!(owners(2).is_empty()); | |
| 250 | + | assert_eq!(listed[1].owners[0].email.as_deref(), Some("ada@example.com")); | |
| 251 | + | } | |
| 252 | + | } |
| 17 | 17 | } | |
| 18 | 18 | ||
| 19 | 19 | /// A short message with one link to follow. | |
| 20 | − | async fn send_link( | |
| 20 | + | pub async fn send_link( | |
| 21 | 21 | env: &Env, | |
| 22 | 22 | to: &str, | |
| 23 | 23 | subject: &str, |
| 3 | 3 | //! Reached only through service bindings; see `g1t_contracts::identity` for | |
| 4 | 4 | //! the methods and their arguments. | |
| 5 | 5 | ||
| 6 | + | mod admin; | |
| 6 | 7 | mod crypto; | |
| 7 | 8 | mod device; | |
| 8 | 9 | mod email; | |
| 573 | 574 | "create_agent_token" => reply(&identity.create_agent_token(args(body)?).await?), | |
| 574 | 575 | "agent_scope" => reply(&identity.agent_scope(args(body)?).await?), | |
| 575 | 576 | "remove_access_token" => reply(&identity.remove("access_tokens", args(body)?).await?), | |
| 577 | + | // Staff only: sudo.g1t.sh, over its service binding. See admin.rs. | |
| 578 | + | "notify_owners" => reply(&identity.notify_owners(args(body)?).await?), | |
| 579 | + | "admin_workspaces" => reply(&identity.admin_workspaces(args(body)?).await?), | |
| 580 | + | "admin_workspace" => reply(&identity.admin_workspace(args(body)?).await?), | |
| 576 | 581 | _ => Response::error("Unknown method", 404), | |
| 577 | 582 | } | |
| 578 | 583 | } |