Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running | 1 | /** |
| 2 | * The billing sections shared by a workspace's page and an enterprise's: | |
| 3 | * confirmations, terms, plan, pools and caps, credit, bank transfers, the | |
| 4 | * Stripe billing link, the ledger and the audit log. Plain forms; sudo ships no JavaScript. | |
| 5 | */ | |
| sudo: reset a test workspace's billing so it starts again as a new customer; refused on a live Stripe key, for comped workspaces and for an enterprise's | 6 | import { CreditCard, Gift, Landmark, RotateCcw, ScrollText, UserRound } from "lucide-react"; |
| Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running | 7 | import type { ReactNode } from "react"; |
| 8 | import { Link } from "react-router"; | |
| 9 | ||
| Billing: credits with a kind and expiry, discounts instead of comped, and safer charging | 10 | import type { AdminAction, AdminOwner, Allowances, BillingLink, CreditGrant, Credits, LedgerEntry, Terms } from "@g1t/contracts"; |
| Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running | 11 | |
| 12 | import { Avatar, Badge, Button, EmptyState, Field, Input, Notice, Section, Select, Textarea, When } from "~/components/ui"; | |
| Billing: credits with a kind and expiry, discounts instead of comped, and safer charging | 13 | import { CREDIT_KINDS, CREDIT_PRESETS, EXPIRIES, kindLabel } from "~/lib/credits"; |
| 14 | import { DISCOUNT_PRESETS, fullDiscount, percentOff, termsLabel } from "~/lib/terms"; | |
| Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running | 15 | import { actionLabel } from "~/lib/ledgers"; |
| 16 | import { dollarsField, usd } from "~/lib/money"; | |
| 17 | import { givenParts } from "~/lib/pricing"; | |
| 18 | import type { Review, SectionError } from "~/lib/review"; | |
| 19 | ||
| 20 | export function Hidden({ values }: { values: Record<string, string> }) { | |
| 21 | return ( | |
| 22 | <> | |
| 23 | {Object.entries(values).map(([name, value]) => ( | |
| 24 | <input key={name} type="hidden" name={name} value={value} /> | |
| 25 | ))} | |
| 26 | </> | |
| 27 | ); | |
| 28 | } | |
| 29 | ||
| 30 | export function Figure({ label, value, hint }: { label: string; value: string; hint?: ReactNode }) { | |
| 31 | return ( | |
| 32 | <div className="rounded-lg border border-line bg-surface px-4 py-3"> | |
| 33 | <p className="text-xs text-muted">{label}</p> | |
| 34 | <p className="tabular mt-1 text-lg font-semibold tracking-tight">{value}</p> | |
| 35 | {hint && <p className="mt-0.5 text-xs text-faint">{hint}</p>} | |
| 36 | </div> | |
| 37 | ); | |
| 38 | } | |
| 39 | ||
| 40 | /** Owners' usernames, each with their email beneath (and on hover). */ | |
| 41 | export function Owners({ owners, compact = false }: { owners: AdminOwner[]; compact?: boolean }) { | |
| 42 | if (owners.length === 0) return <span className="text-faint">No owner</span>; | |
| 43 | if (compact) { | |
| 44 | return ( | |
| 45 | <span className="flex flex-col gap-0.5"> | |
| 46 | {owners.map((owner) => ( | |
| 47 | <span key={owner.username} title={owner.email ?? "No email"} className="min-w-0 truncate"> | |
| 48 | <span className="font-mono text-fg-soft">{owner.username}</span> | |
| 49 | {owner.email && <span className="block truncate text-xs text-faint">{owner.email}</span>} | |
| 50 | </span> | |
| 51 | ))} | |
| 52 | </span> | |
| 53 | ); | |
| 54 | } | |
| 55 | return ( | |
| 56 | <ul className="flex flex-wrap gap-2"> | |
| 57 | {owners.map((owner) => ( | |
| 58 | <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"> | |
| 59 | <Avatar name={owner.username} size={14} square={false} /> | |
| 60 | <span className="font-mono">{owner.username}</span> | |
| 61 | {owner.email && <span className="text-faint">{owner.email}</span>} | |
| 62 | </li> | |
| 63 | ))} | |
| 64 | </ul> | |
| 65 | ); | |
| 66 | } | |
| 67 | ||
| 68 | // --- Confirmation ------------------------------------------------------------ | |
| 69 | ||
| 70 | function describeTerms(terms: Terms): [string, string][] { | |
| 71 | return [ | |
| Billing: credits with a kind and expiry, discounts instead of comped, and safer charging | 72 | ["Terms", termsLabel(terms)], |
| 73 | ["Discount", `${percentOff(terms)}%`], | |
| Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running | 74 | [ |
| 75 | "Limit", | |
| Billing: credits with a kind and expiry, discounts instead of comped, and safer charging | 76 | fullDiscount(terms) |
| Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running | 77 | ? terms.ceilingMicros == null |
| 78 | ? "The default monthly budget, at cost" | |
| 79 | : `${usd(terms.ceilingMicros)} a month, at cost` | |
| 80 | : terms.ceilingMicros == null | |
| 81 | ? "By trust" | |
| 82 | : usd(terms.ceilingMicros), | |
| 83 | ], | |
| 84 | ["Until", terms.until ? terms.until.slice(0, 10) : "No end"], | |
| 85 | ["Note", terms.note || "—"], | |
| 86 | ]; | |
| 87 | } | |
| 88 | ||
| 89 | /** Where a confirmed change lands, when its result is shown in place. */ | |
| 90 | const RESULT_ANCHOR: Partial<Record<Review["intent"], string>> = { "billing-link": "billing-link", invoice: "invoices" }; | |
| 91 | ||
| 92 | export function ReviewPanel({ review, pathname }: { review: Review; pathname: string }) { | |
| 93 | let title: string; | |
| 94 | let body: ReactNode; | |
| 95 | let danger = false; | |
| 96 | let confirm = "Confirm"; | |
| 97 | if (review.intent === "terms") { | |
| 98 | const before = describeTerms(review.before); | |
| 99 | const after = describeTerms(review.after); | |
| 100 | title = "Confirm the new terms"; | |
| Billing: credits with a kind and expiry, discounts instead of comped, and safer charging | 101 | danger = fullDiscount(review.after); |
| Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running | 102 | body = ( |
| 103 | <> | |
| 104 | <div className="overflow-x-auto"> | |
| 105 | <table className="w-full text-sm"> | |
| 106 | <thead> | |
| 107 | <tr className="text-left text-xs text-muted"> | |
| 108 | <th className="py-1.5 pr-4 font-medium" /> | |
| 109 | <th className="py-1.5 pr-4 font-medium">Now</th> | |
| 110 | <th className="py-1.5 font-medium">After</th> | |
| 111 | </tr> | |
| 112 | </thead> | |
| 113 | <tbody> | |
| 114 | {after.map(([label, value], index) => ( | |
| 115 | <tr key={label} className="border-t border-line align-top"> | |
| 116 | <td className="py-1.5 pr-4 text-muted">{label}</td> | |
| People wear their own avatars wherever g1t knows them only by name, and a workspace is never shown as a person. The activity feeds, recent changes, home's most active faces, issue and pull request authors, assignees, comments and the audit log drew a person from their username alone, so everyone got their letter avatar instead of the picture they uploaded. An avatar given no picture now draws the letter at once and lays the person's own picture over it once it loads, from /-/avatar/user/<username> (routes/avatar-user.ts): a redirect to the picture on the usercontent origin, or not found, read from identity's people only, so a workspace's icon can never stand in for someone. Kept five minutes, public like the profile. Agents, the product tour's people, "someone" and workspaces' square avatars are never looked up (image null), so an account with the same name never lends them its face. | 117 | <td className="py-1.5 pr-4 wrap-break-word text-faint">{before[index][1]}</td> |
| 118 | <td className={`py-1.5 wrap-break-word ${value !== before[index][1] ? "font-medium text-fg" : "text-muted"}`}>{value}</td> | |
| Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running | 119 | </tr> |
| 120 | ))} | |
| 121 | </tbody> | |
| 122 | </table> | |
| 123 | </div> | |
| Billing: credits with a kind and expiry, discounts instead of comped, and safer charging | 124 | {fullDiscount(review.after) && ( |
| Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running | 125 | <p className="mt-3 text-sm text-warn"> |
| Billing: credits with a kind and expiry, discounts instead of comped, and safer charging | 126 | A 100% discount: nothing will be charged{review.after.until ? ` until ${review.after.until.slice(0, 10)}` : ""}. The workspace's |
| 127 | statement still shows its usage at price, with the discount beside it. | |
| Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running | 128 | </p> |
| 129 | )} | |
| 130 | </> | |
| 131 | ); | |
| 132 | } else if (review.intent === "attach") { | |
| 133 | title = `Bill ${review.workspace} to ${review.targetName}?`; | |
| 134 | body = ( | |
| 135 | <p className="text-sm text-muted"> | |
| 136 | From now on <span className="font-mono text-fg">{review.workspace}</span>'s usage is billed to{" "} | |
| 137 | <span className="text-fg">{review.targetName}</span> and counts against its limit and terms, not its own. | |
| 138 | </p> | |
| 139 | ); | |
| 140 | } else if (review.intent === "detach") { | |
| 141 | title = `Move ${review.workspace} off ${review.from}?`; | |
| 142 | danger = true; | |
| 143 | body = ( | |
| 144 | <p className="text-sm text-muted"> | |
| 145 | <span className="font-mono text-fg">{review.workspace}</span> goes back to paying for itself, on its own terms and the limit | |
| 146 | its trust gives it. It may stop at once if its own limit is lower than what it owes. | |
| 147 | </p> | |
| 148 | ); | |
| 149 | } else if (review.intent === "billing-email") { | |
| 150 | title = `Send ${review.name}'s invoices to ${review.after}?`; | |
| 151 | body = ( | |
| 152 | <p className="text-sm text-muted"> | |
| 153 | Stripe emails its invoices to <span className="font-mono text-fg">{review.after}</span> | |
| 154 | {review.before ? ( | |
| 155 | <> | |
| 156 | {" "} | |
| 157 | instead of <span className="font-mono">{review.before}</span> | |
| 158 | </> | |
| 159 | ) : null} | |
| 160 | , from the next invoice on.{!review.before && " This also sets the enterprise up as a customer on Stripe."} | |
| 161 | </p> | |
| 162 | ); | |
| 163 | } else if (review.intent === "invoice") { | |
| 164 | title = `Invoice ${review.name} now?`; | |
| 165 | confirm = "Send the invoice"; | |
| 166 | body = ( | |
| 167 | <ul className="list-disc space-y-1 pl-5 text-sm text-muted"> | |
| 168 | <li>Makes one Stripe invoice, with a line for each of its {review.workspaces} workspace{review.workspaces === 1 ? "" : "s"} for what it owes now.</li> | |
| 169 | <li>Net 30: due in 30 days.</li> | |
| 170 | <li> | |
| 171 | Stripe emails it to <span className="font-mono text-fg">{review.email ?? "the invoice email (none set yet)"}</span>, with a link to | |
| 172 | pay on Stripe's page. | |
| 173 | </li> | |
| 174 | </ul> | |
| 175 | ); | |
| 176 | } else { | |
| 177 | title = `Make a Stripe billing link for ${review.workspace}?`; | |
| 178 | confirm = "Make the link"; | |
| 179 | body = ( | |
| 180 | <p className="text-sm text-muted"> | |
| 181 | Opens a one-time session on Stripe's billing page for <span className="font-mono text-fg">{review.workspace}</span>'s | |
| 182 | customer, where they update their card and see their invoices. Anyone with the link can use it until it expires, so send it | |
| 183 | only to the workspace's owner. Making it is recorded with your email. | |
| 184 | </p> | |
| 185 | ); | |
| 186 | } | |
| 187 | return ( | |
| Merge branch 'worktree-agent-ad7c6d88d93adc817' | 188 | <section id="review" className={`scroll-mt-20 rounded-lg border p-4 sm:p-5 ${danger ? "border-warn/40 bg-warn/5" : "border-accent/40 bg-accent/5"}`}> |
| Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running | 189 | <h2 className="font-semibold tracking-tight">{title}</h2> |
| 190 | <div className="mt-3">{body}</div> | |
| 191 | <form method="post" action={`${pathname}#${RESULT_ANCHOR[review.intent] ?? "top"}`} className="mt-4 flex flex-wrap items-center gap-2"> | |
| 192 | <Hidden values={review.fields} /> | |
| 193 | <input type="hidden" name="intent" value={review.intent} /> | |
| 194 | <input type="hidden" name="confirm" value="yes" /> | |
| 195 | <Button type="submit" variant={danger ? "danger" : "lavender"}> | |
| 196 | {confirm} | |
| 197 | </Button> | |
| 198 | <Link to={pathname} className="px-2 text-sm text-muted hover:text-fg"> | |
| 199 | Cancel | |
| 200 | </Link> | |
| 201 | </form> | |
| 202 | </section> | |
| 203 | ); | |
| 204 | } | |
| 205 | ||
| 206 | // --- Terms ------------------------------------------------------------------- | |
| 207 | ||
| Billing: credits with a kind and expiry, discounts instead of comped, and safer charging | 208 | /** |
| 209 | * The account's terms: a discount (0, 25, 50, 100% or a custom percent), | |
| 210 | * a limit, an end date and why. A 100% discount charges nothing; the | |
| 211 | * workspace's statement still shows its usage at price. The custom percent | |
| 212 | * shows only for Custom, by CSS alone (`.terms-form` in app.css). | |
| 213 | */ | |
| Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running | 214 | export function TermsForm({ terms, pathname, error }: { terms: Terms; pathname: string; error: SectionError }) { |
| 215 | const values = error?.values; | |
| Billing: credits with a kind and expiry, discounts instead of comped, and safer charging | 216 | const current = percentOff(terms); |
| 217 | const preset = values?.preset ?? ((DISCOUNT_PRESETS as readonly number[]).includes(current) ? String(current) : "custom"); | |
| Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running | 218 | return ( |
| 219 | <Section | |
| 220 | id="terms" | |
| 221 | title="Terms" | |
| 222 | description={ | |
| 223 | terms.setBy ? ( | |
| 224 | <> | |
| 225 | Set by <span className="font-mono">{terms.setBy}</span> on <When at={terms.setAt} /> | |
| 226 | {terms.note && <> · “{terms.note}”</>} | |
| 227 | </> | |
| 228 | ) : ( | |
| 229 | "Standard, as everyone starts." | |
| 230 | ) | |
| 231 | } | |
| 232 | > | |
| Billing: credits with a kind and expiry, discounts instead of comped, and safer charging | 233 | <form method="post" action={`${pathname}#review`} className="terms-form space-y-4"> |
| Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running | 234 | <input type="hidden" name="intent" value="terms" /> |
| 235 | {error && <Notice tone="error">{error.error}</Notice>} | |
| 236 | <fieldset> | |
| Billing: credits with a kind and expiry, discounts instead of comped, and safer charging | 237 | <legend className="mb-1.5 text-sm font-medium text-muted">Discount</legend> |
| 238 | <div className="flex flex-wrap overflow-hidden rounded-md border border-line bg-bg"> | |
| 239 | {DISCOUNT_PRESETS.map((percent) => ( | |
| 240 | <Segment key={percent} name="preset" value={String(percent)} checked={preset === String(percent)}> | |
| 241 | {percent === 0 ? "None" : `${percent}%`} | |
| 242 | </Segment> | |
| Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running | 243 | ))} |
| Billing: credits with a kind and expiry, discounts instead of comped, and safer charging | 244 | <Segment name="preset" value="custom" checked={preset === "custom"}> |
| 245 | Custom | |
| 246 | </Segment> | |
| Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running | 247 | </div> |
| Billing: credits with a kind and expiry, discounts instead of comped, and safer charging | 248 | <p className="mt-1.5 text-xs text-faint">Off every usage charge. 100%: nothing is charged, and the statement shows usage at price with the discount beside it.</p> |
| Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running | 249 | </fieldset> |
| Billing: credits with a kind and expiry, discounts instead of comped, and safer charging | 250 | <div className="when-custom"> |
| 251 | <Field label="Custom discount %" hint="A whole percent, 1 to 100."> | |
| 252 | <Input name="discount" inputMode="numeric" pattern="\d{1,3}" placeholder="30" defaultValue={values?.discount ?? (preset === "custom" ? String(current) : "")} /> | |
| Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running | 253 | </Field> |
| Billing: credits with a kind and expiry, discounts instead of comped, and safer charging | 254 | </div> |
| 255 | <div className="grid gap-4 sm:grid-cols-2"> | |
| 256 | <Field label="Limit $" hint="Unpaid usage allowed; blank: trust decides. At 100%: g1t's monthly budget for it, at cost; blank: the default ($150)."> | |
| Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running | 257 | <Input name="ceiling" inputMode="decimal" placeholder="By trust" defaultValue={values?.ceiling ?? dollarsField(terms.ceilingMicros)} /> |
| 258 | </Field> | |
| 259 | <Field label="Until" hint="Blank: no end. UTC."> | |
| 260 | <Input type="date" name="until" defaultValue={values?.until ?? (terms.until ? terms.until.slice(0, 10) : "")} /> | |
| 261 | </Field> | |
| 262 | </div> | |
| Billing: credits with a kind and expiry, discounts instead of comped, and safer charging | 263 | <Field label="Reason" hint="Required. Why, for whoever looks next; at 100%, shown as the reason for the discount."> |
| 264 | <Textarea name="note" rows={2} required maxLength={500} defaultValue={values?.note ?? ""} placeholder="e.g. g1t's own workspace" /> | |
| Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running | 265 | </Field> |
| 266 | <div className="flex justify-end"> | |
| 267 | <Button type="submit">Review terms</Button> | |
| 268 | </div> | |
| 269 | </form> | |
| 270 | </Section> | |
| 271 | ); | |
| 272 | } | |
| 273 | ||
| 274 | // --- Plan, pools and caps ------------------------------------------------------- | |
| 275 | ||
| 276 | /** What staff have set beyond the terms, one line each; defaults left out. */ | |
| 277 | export function allowanceLines(allowances: Allowances | undefined, comped: boolean): string[] { | |
| 278 | const lines: string[] = []; | |
| Billing: credits with a kind and expiry, discounts instead of comped, and safer charging | 279 | if (comped) lines.push("The g1t plan, without its price (100% discount)"); |
| Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running | 280 | else if (allowances?.plan) lines.push("The g1t plan, without its price"); |
| 281 | if (!allowances) return lines; | |
| 282 | if (allowances.ossRepoMicros != null) lines.push(`Open-source pool: ${usd(allowances.ossRepoMicros)} a month for each public repository`); | |
| 283 | if (allowances.trialMicros != null) lines.push(`Trial: ${usd(allowances.trialMicros)}, outside the monthly pool`); | |
| 284 | if (allowances.auditRetentionDays != null) lines.push(`Audit log: ${allowances.auditRetentionDays} days, in place of the plan's`); | |
| 285 | if (allowances.hold) lines.push(`Held: ${allowances.hold}`); | |
| 286 | return lines; | |
| 287 | } | |
| 288 | ||
| 289 | /** | |
| 290 | * The g1t plan without its price, the account's share of g1t's pools, and | |
| The site no longer has g1t's own agents. g1t is where the coding agents you already use do their work, so the Agents mode is gone from the rail, with every page under /<workspace>/-/agents: personas, faces, skills, abilities, routines, computers, sessions and agent spend. Customize (skills, connectors, plugins, marketplaces), the context hub, model providers, the AI Gateway page, the Spend page and pill, Ask g1t, and the repository's Agents, Plans and agent settings pages go too, and their addresses now answer 404. | 291 | * staff's overrides of what owners set: the days of audit log kept (such as for an organization that pays |
| Billing: credits with a kind and expiry, discounts instead of comped, and safer charging | 292 | * for longer), and a hold on new compute. Accounts on a 100% discount have the plan |
| Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running | 293 | * anyway. |
| 294 | */ | |
| 295 | export function AllowancesForm({ | |
| 296 | allowances, | |
| 297 | comped, | |
| 298 | pathname, | |
| 299 | error, | |
| 300 | }: { | |
| 301 | allowances: Allowances | undefined; | |
| 302 | comped: boolean; | |
| 303 | pathname: string; | |
| 304 | error: SectionError; | |
| 305 | }) { | |
| 306 | const values = error?.values; | |
| 307 | const current: Allowances = allowances ?? { plan: false, ossRepoMicros: null, trialMicros: null }; | |
| 308 | const lines = allowanceLines(allowances, comped); | |
| 309 | return ( | |
| 310 | <Section | |
| 311 | id="allowances" | |
| 312 | title="Plan, pools and caps" | |
| 313 | description={ | |
| 314 | comped | |
| Billing: credits with a kind and expiry, discounts instead of comped, and safer charging | 315 | ? "A 100% discount: the g1t plan is on without its price whatever is set here. The pools and caps still apply." |
| Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running | 316 | : "The g1t plan without its price, this account's share of g1t's pools, and overrides of what owners set." |
| 317 | } | |
| 318 | > | |
| 319 | <div className="mb-4 rounded-md border border-line bg-bg px-3.5 py-2.5 text-sm"> | |
| 320 | <p className="text-xs text-faint">Set now</p> | |
| 321 | {lines.length === 0 ? ( | |
| 322 | <p className="mt-0.5 text-muted">Nothing: the defaults.</p> | |
| 323 | ) : ( | |
| 324 | <ul className="mt-1 space-y-0.5"> | |
| 325 | {lines.map((line) => ( | |
| 326 | <li key={line} className={line.startsWith("Held:") ? "text-danger" : "text-fg-soft"}> | |
| 327 | {line} | |
| 328 | </li> | |
| 329 | ))} | |
| 330 | </ul> | |
| 331 | )} | |
| 332 | </div> | |
| 333 | <form method="post" action={`${pathname}#allowances`} className="space-y-4"> | |
| 334 | <input type="hidden" name="intent" value="allowances" /> | |
| 335 | {error && <Notice tone="error">{error.error}</Notice>} | |
| 336 | <label className="flex cursor-pointer items-start gap-2.5 text-sm"> | |
| 337 | <input type="checkbox" name="plan" defaultChecked={values ? values.plan === "on" : current.plan} className="mt-0.5" /> | |
| 338 | <span> | |
| 339 | <span className="block font-medium">The g1t plan, without its price</span> | |
| 340 | <span className="block text-xs text-muted"> | |
| 341 | Its included usage, storage and caps, with no $20 a month. Usage past it is charged as usual. | |
| 342 | </span> | |
| 343 | </span> | |
| 344 | </label> | |
| 345 | <div className="grid gap-4 sm:grid-cols-2"> | |
| 346 | <Field label="Open-source share $" hint="Each public repository, a month. Blank: the default ($2)."> | |
| 347 | <Input name="oss" inputMode="decimal" placeholder="Default" defaultValue={values?.oss ?? dollarsField(current.ossRepoMicros)} /> | |
| 348 | </Field> | |
| 349 | <Field label="Trial $" hint="Each workspace, outside the monthly pool. Blank: the default ($5)."> | |
| 350 | <Input name="trial" inputMode="decimal" placeholder="Default" defaultValue={values?.trial ?? dollarsField(current.trialMicros)} /> | |
| 351 | </Field> | |
| 352 | <Field label="Audit log days" hint="Kept, in place of the plan's, longer or shorter, up to 400. Blank: the plan's (7 free, 90 on the plan)."> | |
| 353 | <Input | |
| 354 | name="auditDays" | |
| 355 | inputMode="numeric" | |
| 356 | pattern="\d{1,3}" | |
| 357 | placeholder="Plan's" | |
| 358 | defaultValue={values?.auditDays ?? (current.auditRetentionDays != null ? String(current.auditRetentionDays) : "")} | |
| 359 | /> | |
| 360 | </Field> | |
| 361 | <Field label="Hold" hint="Pauses new compute and tells the owners why. Blank: no hold."> | |
| 362 | <Input name="hold" maxLength={200} placeholder="No hold" defaultValue={values?.hold ?? current.hold ?? ""} /> | |
| 363 | </Field> | |
| 364 | </div> | |
| 365 | <Field label="Note" hint="Required. Why, for whoever looks next."> | |
| 366 | <Textarea | |
| 367 | name="note" | |
| 368 | rows={2} | |
| 369 | required | |
| 370 | maxLength={500} | |
| 371 | defaultValue={values?.note ?? ""} | |
| 372 | placeholder="e.g. Open-source foundation, larger share through 2027" | |
| 373 | /> | |
| 374 | </Field> | |
| 375 | <div className="flex justify-end"> | |
| 376 | <Button type="submit">Save plan, pools and caps</Button> | |
| 377 | </div> | |
| 378 | </form> | |
| 379 | </Section> | |
| 380 | ); | |
| 381 | } | |
| 382 | ||
| 383 | // --- Bank transfer --------------------------------------------------------------- | |
| 384 | ||
| 385 | /** | |
| 386 | * Money that reached g1t outside Stripe's page, such as a bank transfer | |
| 387 | * for a $1,000+ prepayment, entered as a payment. | |
| 388 | */ | |
| 389 | export function PaymentForm({ workspace, pathname, error }: { workspace: string; pathname: string; error: SectionError }) { | |
| 390 | const values = error?.values; | |
| 391 | return ( | |
| 392 | <Section | |
| 393 | id="payment" | |
| 394 | title="Record a bank transfer" | |
| 395 | description="Money that reached g1t's bank without Stripe's page, such as a $1,000+ prepayment. It goes on the statement as a payment and counts toward the limit at once." | |
| 396 | > | |
| 397 | <form method="post" action={`${pathname}#payment`} className="space-y-4"> | |
| 398 | <input type="hidden" name="intent" value="payment" /> | |
| 399 | {error && <Notice tone="error">{error.error}</Notice>} | |
| 400 | <div className="grid gap-4 sm:grid-cols-2"> | |
| 401 | <Field label="Amount $" hint="As it arrived, up to $100,000."> | |
| 402 | <Input name="amount" inputMode="decimal" required placeholder="1,500.00" defaultValue={values?.amount ?? ""} /> | |
| 403 | </Field> | |
| 404 | <Field label="Reference" hint="As the bank shows it. Each is recorded once."> | |
| 405 | <Input name="reference" required maxLength={100} className="font-mono" defaultValue={values?.reference ?? ""} /> | |
| 406 | </Field> | |
| 407 | </div> | |
| 408 | <Field label="Note" hint="Required. Shown on the statement after “Paid by bank transfer”."> | |
| 409 | <Textarea | |
| 410 | name="note" | |
| 411 | rows={2} | |
| 412 | required | |
| 413 | maxLength={500} | |
| 414 | placeholder="e.g. Prepayment for October, invoice INV-0042" | |
| 415 | defaultValue={values?.note ?? ""} | |
| 416 | /> | |
| 417 | </Field> | |
| 418 | <Field | |
| 419 | label="Confirm" | |
| 420 | hint={ | |
| 421 | <> | |
| 422 | Type the workspace's slug (<span className="font-mono text-muted">{workspace}</span>) to record it. | |
| 423 | </> | |
| 424 | } | |
| 425 | > | |
| 426 | <Input name="confirmation" required placeholder={workspace} className="font-mono" /> | |
| 427 | </Field> | |
| 428 | <div className="flex justify-end"> | |
| 429 | <Button type="submit" variant="lavender"> | |
| 430 | <Landmark size={14} /> | |
| 431 | Record the payment | |
| 432 | </Button> | |
| 433 | </div> | |
| 434 | </form> | |
| 435 | </Section> | |
| 436 | ); | |
| 437 | } | |
| 438 | ||
| 439 | // --- Credit ------------------------------------------------------------------- | |
| 440 | ||
| Billing: credits with a kind and expiry, discounts instead of comped, and safer charging | 441 | /** A radio drawn as one button of a segmented row; still a plain radio without CSS. */ |
| 442 | function Segment({ name, value, checked, children }: { name: string; value: string; checked: boolean; children: ReactNode }) { | |
| 443 | return ( | |
| Merge branch 'worktree-agent-ad7c6d88d93adc817' | 444 | <label className="flex-1 cursor-pointer border-r border-line px-3 py-1.5 text-center text-sm whitespace-nowrap text-muted transition-colors last:border-r-0 hover:bg-raised hover:text-fg has-checked:bg-accent has-checked:font-medium has-checked:text-bg has-focus-visible:outline-2 has-focus-visible:-outline-offset-2 has-focus-visible:outline-accent"> |
| Billing: credits with a kind and expiry, discounts instead of comped, and safer charging | 445 | <input type="radio" name={name} value={value} defaultChecked={checked} className="sr-only" /> |
| 446 | {children} | |
| 447 | </label> | |
| 448 | ); | |
| 449 | } | |
| 450 | ||
| 451 | /** | |
| 452 | * Credit for a workspace: a preset amount or a custom one, a kind, an | |
| 453 | * expiry, and a note. Fields that belong to one choice (the custom amount, | |
| 454 | * a refund's details, an expiry date) show only for it, by CSS alone | |
| 455 | * (`.credit-form` in app.css); without `:has()` every field shows and the | |
| 456 | * ones that do not apply are ignored. Over $100 the slug is typed out too. | |
| 457 | */ | |
| Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running | 458 | export function CreditForm({ workspaces, pathname, error }: { workspaces: string[]; pathname: string; error: SectionError }) { |
| 459 | const values = error?.values; | |
| 460 | const single = workspaces.length === 1 ? workspaces[0] : null; | |
| Billing: credits with a kind and expiry, discounts instead of comped, and safer charging | 461 | const preset = values?.preset ?? "25"; |
| 462 | const kind = values?.kind ?? "promotional"; | |
| 463 | const expires = values?.expires ?? "none"; | |
| Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running | 464 | return ( |
| Billing: credits with a kind and expiry, discounts instead of comped, and safer charging | 465 | <Section |
| 466 | id="credit" | |
| 467 | title="Give credit" | |
| 468 | description="Added to the balance at once and spent before anything paid in advance. The owners are emailed." | |
| 469 | > | |
| Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running | 470 | {workspaces.length === 0 ? ( |
| 471 | <p className="text-sm text-muted">Add a workspace first: credit goes to a workspace.</p> | |
| 472 | ) : ( | |
| Billing: credits with a kind and expiry, discounts instead of comped, and safer charging | 473 | <form method="post" action={`${pathname}#credit`} className="credit-form space-y-4"> |
| Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running | 474 | <input type="hidden" name="intent" value="credit" /> |
| 475 | {error && <Notice tone="error">{error.error}</Notice>} | |
| 476 | {single ? ( | |
| 477 | <input type="hidden" name="workspace" value={single} /> | |
| 478 | ) : ( | |
| 479 | <Field label="Workspace"> | |
| 480 | <Select name="workspace" required defaultValue={values?.workspace ?? ""}> | |
| 481 | <option value="" disabled> | |
| 482 | Choose a workspace | |
| 483 | </option> | |
| 484 | {workspaces.map((slug) => ( | |
| 485 | <option key={slug} value={slug}> | |
| 486 | {slug} | |
| 487 | </option> | |
| 488 | ))} | |
| 489 | </Select> | |
| 490 | </Field> | |
| 491 | )} | |
| Billing: credits with a kind and expiry, discounts instead of comped, and safer charging | 492 | <fieldset> |
| 493 | <legend className="mb-1.5 text-sm font-medium text-muted">Amount</legend> | |
| 494 | <div className="flex flex-wrap overflow-hidden rounded-md border border-line bg-bg"> | |
| 495 | {CREDIT_PRESETS.map((dollars) => ( | |
| 496 | <Segment key={dollars} name="preset" value={String(dollars)} checked={preset === String(dollars)}> | |
| 497 | ${dollars} | |
| 498 | </Segment> | |
| 499 | ))} | |
| 500 | <Segment name="preset" value="custom" checked={preset === "custom"}> | |
| 501 | Custom | |
| 502 | </Segment> | |
| 503 | </div> | |
| 504 | </fieldset> | |
| 505 | <div className="when-custom space-y-4"> | |
| 506 | <Field label="Custom amount $" hint="Up to $10,000 at a time."> | |
| 507 | <Input name="amount" inputMode="decimal" placeholder="15.00" defaultValue={values?.amount ?? ""} /> | |
| 508 | </Field> | |
| 509 | </div> | |
| 510 | <fieldset> | |
| 511 | <legend className="mb-1.5 text-sm font-medium text-muted">Kind</legend> | |
| 512 | <div className="grid gap-2"> | |
| 513 | {CREDIT_KINDS.map((option) => ( | |
| 514 | <label | |
| 515 | key={option.value} | |
| Merge branch 'worktree-agent-ad7c6d88d93adc817' | 516 | className="flex cursor-pointer gap-2.5 rounded-md border border-line bg-bg px-3 py-2.5 transition-colors hover:border-line-strong has-checked:border-accent/60 has-checked:bg-accent/8" |
| Billing: credits with a kind and expiry, discounts instead of comped, and safer charging | 517 | > |
| 518 | <input type="radio" name="kind" value={option.value} defaultChecked={kind === option.value} className="mt-0.5" required /> | |
| 519 | <span> | |
| 520 | <span className="block text-sm font-medium">{option.title}</span> | |
| 521 | <span className="mt-0.5 block text-xs text-muted">{option.text}</span> | |
| 522 | </span> | |
| 523 | </label> | |
| 524 | ))} | |
| 525 | </div> | |
| 526 | </fieldset> | |
| 527 | <div className="when-refund"> | |
| 528 | <div className="grid gap-4 sm:grid-cols-[minmax(0,2fr)_minmax(0,1fr)]"> | |
| 529 | <Field label="Refund for" hint="Refunds only. On the statement and in the email."> | |
| 530 | <Input name="refundFor" maxLength={200} placeholder="the failed runs on Oct 2" defaultValue={values?.refundFor ?? ""} /> | |
| 531 | </Field> | |
| 532 | <Field label="Day refunded" hint="Comes off what was paid that day. Blank: today."> | |
| 533 | <Input type="date" name="refundDay" defaultValue={values?.refundDay ?? ""} /> | |
| 534 | </Field> | |
| 535 | </div> | |
| 536 | </div> | |
| 537 | <fieldset className="when-not-refund"> | |
| 538 | <legend className="mb-1.5 text-sm font-medium text-muted">Expires</legend> | |
| 539 | <div className="flex flex-wrap overflow-hidden rounded-md border border-line bg-bg"> | |
| 540 | {EXPIRIES.map((option) => ( | |
| 541 | <Segment key={option.value} name="expires" value={option.value} checked={expires === option.value}> | |
| 542 | {option.label} | |
| 543 | </Segment> | |
| 544 | ))} | |
| 545 | </div> | |
| 546 | <p className="mt-1.5 text-xs text-faint">Unused credit stops counting then. A refund never expires.</p> | |
| 547 | </fieldset> | |
| 548 | <div className="when-date when-not-refund"> | |
| 549 | <Field label="Expires on" hint="At the end of the day, UTC."> | |
| 550 | <Input type="date" name="expiresOn" defaultValue={values?.expiresOn ?? ""} /> | |
| 551 | </Field> | |
| 552 | </div> | |
| 553 | <Field label="Note" hint="Required. On the workspace's statement and in the owners' email."> | |
| 554 | <Textarea name="note" rows={2} required maxLength={500} placeholder="e.g. Welcome to g1t" defaultValue={values?.note ?? ""} /> | |
| Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running | 555 | </Field> |
| Billing: credits with a kind and expiry, discounts instead of comped, and safer charging | 556 | <div className="when-custom"> |
| 557 | <Field | |
| 558 | label="Confirm" | |
| 559 | hint={ | |
| 560 | <> | |
| 561 | Over $100 only: type the workspace's slug{single && <> (<span className="font-mono text-muted">{single}</span>)</>}. | |
| 562 | </> | |
| 563 | } | |
| 564 | > | |
| 565 | <Input name="confirmation" placeholder={single ?? "workspace-slug"} className="font-mono" /> | |
| 566 | </Field> | |
| 567 | </div> | |
| Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running | 568 | <div className="flex justify-end"> |
| 569 | <Button type="submit" variant="lavender"> | |
| 570 | <Gift size={14} /> | |
| Billing: credits with a kind and expiry, discounts instead of comped, and safer charging | 571 | Give credit |
| Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running | 572 | </Button> |
| 573 | </div> | |
| 574 | </form> | |
| 575 | )} | |
| 576 | </Section> | |
| 577 | ); | |
| 578 | } | |
| 579 | ||
| Billing: credits with a kind and expiry, discounts instead of comped, and safer charging | 580 | const GRANT_STATE: Record<CreditGrant["state"], { label: string; tone: "mint" | "plain" | "warn" | "danger" }> = { |
| 581 | open: { label: "Open", tone: "mint" }, | |
| 582 | used: { label: "Used", tone: "plain" }, | |
| 583 | expired: { label: "Expired", tone: "warn" }, | |
| 584 | revoked: { label: "Revoked", tone: "danger" }, | |
| 585 | }; | |
| 586 | ||
| 587 | /** | |
| 588 | * Credits g1t gave, newest first: amount, kind, note, who, when, used, | |
| 589 | * left and expiry, and on an open one a form to take back what is left. | |
| 590 | */ | |
| 591 | export function CreditList({ | |
| 592 | grants, | |
| 593 | pathname, | |
| 594 | error, | |
| 595 | showWorkspace = false, | |
| 596 | }: { | |
| 597 | grants: CreditGrant[]; | |
| 598 | pathname: string; | |
| 599 | error?: SectionError; | |
| 600 | showWorkspace?: boolean; | |
| 601 | }) { | |
| 602 | if (grants.length === 0) return <EmptyState title="No credits given">Credit given from sudo shows here, with what is left of it.</EmptyState>; | |
| 603 | return ( | |
| 604 | <ul className="-my-1 divide-y divide-line"> | |
| 605 | {grants.map((grant) => { | |
| 606 | const state = GRANT_STATE[grant.state]; | |
| 607 | const failed = error && error.values?.id === grant.id ? error.error : null; | |
| 608 | return ( | |
| 609 | <li key={grant.id} id={`grant-${grant.id}`} className="scroll-mt-20 py-3"> | |
| 610 | <div className="flex flex-wrap items-center gap-x-2 gap-y-1"> | |
| 611 | <span className="tabular font-semibold">{usd(grant.amountMicros, { cents: true })}</span> | |
| 612 | <Badge tone="lavender">{kindLabel(grant.kind)}</Badge> | |
| 613 | <Badge tone={state.tone}>{state.label}</Badge> | |
| 614 | {showWorkspace && ( | |
| Merge branch 'worktree-agent-ad7c6d88d93adc817' | 615 | <Link to={`/workspaces/${encodeURIComponent(grant.workspace)}#credits`} className="font-mono text-xs text-accent hover:underline"> |
| Billing: credits with a kind and expiry, discounts instead of comped, and safer charging | 616 | {grant.workspace} |
| 617 | </Link> | |
| 618 | )} | |
| 619 | </div> | |
| 620 | <p className="tabular mt-1 text-sm text-fg-soft"> | |
| 621 | Used {usd(grant.usedMicros, { cents: true })} · left {usd(grant.leftMicros, { cents: true })} | |
| 622 | {grant.expiresAt ? ( | |
| 623 | <> | |
| 624 | {" "} | |
| 625 | · {grant.state === "expired" ? "expired" : "expires"} <When at={grant.expiresAt} /> | |
| 626 | </> | |
| 627 | ) : grant.kind === "refund" ? ( | |
| 628 | " · never expires" | |
| 629 | ) : ( | |
| 630 | " · no expiry" | |
| 631 | )} | |
| 632 | </p> | |
| People wear their own avatars wherever g1t knows them only by name, and a workspace is never shown as a person. The activity feeds, recent changes, home's most active faces, issue and pull request authors, assignees, comments and the audit log drew a person from their username alone, so everyone got their letter avatar instead of the picture they uploaded. An avatar given no picture now draws the letter at once and lays the person's own picture over it once it loads, from /-/avatar/user/<username> (routes/avatar-user.ts): a redirect to the picture on the usercontent origin, or not found, read from identity's people only, so a workspace's icon can never stand in for someone. Kept five minutes, public like the profile. Agents, the product tour's people, "someone" and workspaces' square avatars are never looked up (image null), so an account with the same name never lends them its face. | 633 | <p className="mt-0.5 text-sm wrap-break-word"> |
| Billing: credits with a kind and expiry, discounts instead of comped, and safer charging | 634 | {grant.kind === "refund" && grant.refundFor && <span className="text-muted">For {grant.refundFor}{grant.refundDay ? ` (${grant.refundDay})` : ""}: </span>} |
| 635 | {grant.note} | |
| 636 | </p> | |
| 637 | <p className="mt-0.5 flex flex-wrap items-center gap-x-1 font-mono text-xs text-faint"> | |
| 638 | <UserRound size={11} /> | |
| 639 | {grant.createdBy} · <When at={grant.createdAt} time /> | |
| 640 | </p> | |
| 641 | {grant.closedAt && grant.state === "revoked" && ( | |
| 642 | <p className="mt-1 text-xs text-muted"> | |
| 643 | {usd(grant.closedMicros ?? 0, { cents: true })} taken back by {grant.closedBy} on <When at={grant.closedAt} /> | |
| 644 | {grant.closedNote ? `: “${grant.closedNote}”` : ""} | |
| 645 | </p> | |
| 646 | )} | |
| 647 | {grant.closedAt && grant.state === "expired" && (grant.closedMicros ?? 0) > 0 && ( | |
| 648 | <p className="mt-1 text-xs text-muted">{usd(grant.closedMicros ?? 0, { cents: true })} unused when it expired.</p> | |
| 649 | )} | |
| 650 | {grant.state === "open" && ( | |
| 651 | <details className="mt-2" open={failed != null}> | |
| 652 | <summary className="cursor-pointer text-xs text-danger hover:underline">Revoke unused</summary> | |
| 653 | <form method="post" action={`${pathname}#grant-${grant.id}`} className="mt-2 flex flex-col gap-2 sm:flex-row sm:items-end"> | |
| 654 | <input type="hidden" name="intent" value="revoke-credit" /> | |
| 655 | <input type="hidden" name="id" value={grant.id} /> | |
| 656 | {showWorkspace && <input type="hidden" name="workspace" value={grant.workspace} />} | |
| 657 | <Field label="Why" hint={`Takes ${usd(grant.leftMicros, { cents: true })} off the balance. Kept in the audit log.`} className="flex-1"> | |
| 658 | <Input name="reason" required maxLength={500} placeholder="e.g. Given to the wrong workspace" defaultValue={failed ? (error?.values?.reason ?? "") : ""} /> | |
| 659 | </Field> | |
| 660 | <Button type="submit" variant="danger" className="shrink-0"> | |
| 661 | Revoke {usd(grant.leftMicros, { cents: true })} | |
| 662 | </Button> | |
| 663 | </form> | |
| 664 | {failed && ( | |
| 665 | <div className="mt-2"> | |
| 666 | <Notice tone="error">{failed}</Notice> | |
| 667 | </div> | |
| 668 | )} | |
| 669 | </details> | |
| 670 | )} | |
| 671 | </li> | |
| 672 | ); | |
| 673 | })} | |
| 674 | </ul> | |
| 675 | ); | |
| 676 | } | |
| 677 | ||
| 678 | /** A workspace's credits from g1t: what is left to spend, and each grant. */ | |
| 679 | export function CreditsSection({ | |
| 680 | credits, | |
| 681 | unavailable, | |
| 682 | pathname, | |
| 683 | error, | |
| 684 | }: { | |
| 685 | credits: Credits | null; | |
| 686 | unavailable: string | null; | |
| 687 | pathname: string; | |
| 688 | error: SectionError; | |
| 689 | }) { | |
| 690 | return ( | |
| 691 | <Section | |
| 692 | id="credits" | |
| 693 | title="Credits" | |
| 694 | description={ | |
| 695 | credits | |
| 696 | ? `${usd(credits.leftMicros, { cents: true })} left to spend. Spent before anything paid in advance, the soonest-expiring first.` | |
| 697 | : "Credits from g1t, with what is left of each." | |
| 698 | } | |
| 699 | > | |
| 700 | {unavailable ? <Notice tone="warn">Billing did not answer for credits: {unavailable}</Notice> : <CreditList grants={credits?.grants ?? []} pathname={pathname} error={error} />} | |
| 701 | </Section> | |
| 702 | ); | |
| 703 | } | |
| 704 | ||
| sudo: reset a test workspace's billing so it starts again as a new customer; refused on a live Stripe key, for comped workspaces and for an enterprise's | 705 | // --- Reset (testing) ----------------------------------------------------------- |
| 706 | ||
| 707 | /** | |
| 708 | * Wipes a test workspace's billing so it starts again as a new customer. | |
| Billing: credits with a kind and expiry, discounts instead of comped, and safer charging | 709 | * Only while billing runs on Stripe's test key; billing refuses 100% discounts and |
| sudo: reset a test workspace's billing so it starts again as a new customer; refused on a live Stripe key, for comped workspaces and for an enterprise's | 710 | * enterprise workspaces. The workspace, its members and repositories stay. |
| 711 | */ | |
| 712 | export function ResetBillingForm({ workspace, pathname, error }: { workspace: string; pathname: string; error: SectionError }) { | |
| 713 | const values = error?.values; | |
| 714 | return ( | |
| 715 | <Section | |
| 716 | id="reset" | |
| 717 | title="Reset billing (testing)" | |
| Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97 | 718 | description="Wipes this workspace's billing: its ledger and balance, plan, limits, trial, invoices, holds, signals and cost rows. What its usage cost g1t is kept, and counted on Costs & margin as given away (testing resets). The workspace, its members and its repositories stay. Only while billing is on Stripe's test key." |
| sudo: reset a test workspace's billing so it starts again as a new customer; refused on a live Stripe key, for comped workspaces and for an enterprise's | 719 | > |
| 720 | <form method="post" action={`${pathname}#reset`} className="space-y-4"> | |
| 721 | <input type="hidden" name="intent" value="reset" /> | |
| 722 | {error && <Notice tone="error">{error.error}</Notice>} | |
| 723 | <Field label="Why" hint="Required. Kept in the audit log."> | |
| 724 | <Textarea name="note" rows={2} required maxLength={500} placeholder="e.g. Test workspace, starting the customer walk-through again" defaultValue={values?.note ?? ""} /> | |
| 725 | </Field> | |
| 726 | <Field | |
| 727 | label="Confirm" | |
| 728 | hint={ | |
| 729 | <> | |
| 730 | Type the workspace's slug (<span className="font-mono text-muted">{workspace}</span>) to wipe its billing. It cannot be undone. | |
| 731 | </> | |
| 732 | } | |
| 733 | > | |
| 734 | <Input name="confirmation" required placeholder={workspace} className="font-mono" /> | |
| 735 | </Field> | |
| 736 | <div className="flex justify-end"> | |
| 737 | <Button type="submit" variant="danger"> | |
| 738 | <RotateCcw size={14} /> | |
| 739 | Reset billing | |
| 740 | </Button> | |
| 741 | </div> | |
| 742 | </form> | |
| 743 | </Section> | |
| 744 | ); | |
| 745 | } | |
| 746 | ||
| Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running | 747 | // --- Stripe billing link ----------------------------------------------------- |
| 748 | ||
| 749 | /** | |
| 750 | * Card details, invoices and receipts live on Stripe's own billing page. | |
| 751 | * sudo never shows a card field: staff send the customer a link instead. | |
| 752 | */ | |
| 753 | export function BillingLinkSection({ | |
| 754 | link, | |
| 755 | pathname, | |
| 756 | error, | |
| 757 | }: { | |
| 758 | link: BillingLink | null; | |
| 759 | pathname: string; | |
| 760 | error: SectionError; | |
| 761 | }) { | |
| 762 | return ( | |
| 763 | <Section | |
| 764 | id="billing-link" | |
| 765 | title="Stripe billing page" | |
| 766 | description="Where the customer updates their card and sees invoices. g1t never takes card numbers." | |
| 767 | > | |
| 768 | {error && ( | |
| 769 | <div className="mb-4"> | |
| 770 | <Notice tone="error">{error.error}</Notice> | |
| 771 | </div> | |
| 772 | )} | |
| 773 | {link ? ( | |
| 774 | <div className="space-y-4"> | |
| 775 | <Notice tone="info">Send this to the customer. g1t never takes card numbers; they enter them on Stripe.</Notice> | |
| 776 | <Field label="One-time link" hint={link.expiresNote}> | |
| 777 | <Input readOnly value={link.portalUrl} className="font-mono text-xs" aria-label="One-time Stripe billing link" /> | |
| 778 | </Field> | |
| 779 | {link.loginUrl && ( | |
| 780 | <Field label="Permanent sign-in page" hint={`The customer signs in with ${link.customerEmail ?? "the email Stripe has for them"}.`}> | |
| 781 | <Input readOnly value={link.loginUrl} className="font-mono text-xs" aria-label="Stripe billing sign-in page" /> | |
| 782 | </Field> | |
| 783 | )} | |
| 784 | {!link.loginUrl && link.customerEmail && <p className="text-xs text-faint">Stripe has {link.customerEmail} for this customer.</p>} | |
| 785 | </div> | |
| 786 | ) : ( | |
| 787 | <form method="post" action={`${pathname}#review`} className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> | |
| 788 | <input type="hidden" name="intent" value="billing-link" /> | |
| 789 | <p className="text-sm text-muted"> | |
| 790 | Never take card details over the phone or by email. Make a link to Stripe's billing page and send it to the owner. | |
| 791 | </p> | |
| 792 | <Button type="submit" variant="quiet" className="shrink-0"> | |
| 793 | <CreditCard size={14} /> | |
| 794 | Make a link | |
| 795 | </Button> | |
| 796 | </form> | |
| 797 | )} | |
| 798 | </Section> | |
| 799 | ); | |
| 800 | } | |
| 801 | ||
| 802 | // --- Ledger and audit --------------------------------------------------------- | |
| 803 | ||
| 804 | const ENTRY_KIND: Record<string, string> = { top_up: "Top-up", usage: "Usage", credit: "Credit" }; | |
| 805 | ||
| 806 | export function LedgerSection({ | |
| 807 | ledger, | |
| 808 | showWorkspace = false, | |
| 809 | description = "Recent lines of the statement, newest first.", | |
| 810 | }: { | |
| 811 | ledger: LedgerEntry[]; | |
| 812 | showWorkspace?: boolean; | |
| 813 | description?: ReactNode; | |
| 814 | }) { | |
| 815 | return ( | |
| 816 | <Section title="Ledger" description={description}> | |
| 817 | {ledger.length === 0 ? ( | |
| 818 | <EmptyState title="Nothing yet" /> | |
| 819 | ) : ( | |
| 820 | <div className="-mx-4 -my-4 overflow-x-auto sm:-mx-5 sm:-my-5"> | |
| People wear their own avatars wherever g1t knows them only by name, and a workspace is never shown as a person. The activity feeds, recent changes, home's most active faces, issue and pull request authors, assignees, comments and the audit log drew a person from their username alone, so everyone got their letter avatar instead of the picture they uploaded. An avatar given no picture now draws the letter at once and lays the person's own picture over it once it loads, from /-/avatar/user/<username> (routes/avatar-user.ts): a redirect to the picture on the usercontent origin, or not found, read from identity's people only, so a workspace's icon can never stand in for someone. Kept five minutes, public like the profile. Agents, the product tour's people, "someone" and workspaces' square avatars are never looked up (image null), so an account with the same name never lends them its face. | 821 | <table className="w-full min-w-xl text-sm"> |
| Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running | 822 | <thead> |
| 823 | <tr className="border-b border-line text-left text-xs text-muted"> | |
| 824 | <th className="px-4 py-2 font-medium sm:pl-5">When</th> | |
| 825 | <th className="px-4 py-2 font-medium">What</th> | |
| 826 | <th className="px-4 py-2 text-right font-medium sm:pr-5">Amount</th> | |
| 827 | </tr> | |
| 828 | </thead> | |
| 829 | <tbody> | |
| 830 | {ledger.map((entry) => ( | |
| 831 | <tr key={entry.id} className="border-b border-line align-top last:border-0"> | |
| 832 | <td className="px-4 py-2.5 text-xs whitespace-nowrap text-muted sm:pl-5"> | |
| 833 | <When at={entry.createdAt} time /> | |
| 834 | </td> | |
| 835 | <td className="px-4 py-2.5"> | |
| 836 | <div className="flex flex-wrap items-center gap-1.5"> | |
| Billing: credits with a kind and expiry, discounts instead of comped, and safer charging | 837 | <Badge tone={entry.creditKind ? "lavender" : entry.amountMicros > 0 ? "mint" : "plain"}> |
| 838 | {entry.creditKind ? `Credit · ${kindLabel(entry.creditKind)}` : (ENTRY_KIND[entry.kind] ?? entry.kind)} | |
| 839 | </Badge> | |
| Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running | 840 | {showWorkspace && entry.workspace && ( |
| Merge branch 'worktree-agent-ad7c6d88d93adc817' | 841 | <Link to={`/workspaces/${encodeURIComponent(entry.workspace)}`} className="font-mono text-xs text-accent hover:underline"> |
| Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running | 842 | {entry.workspace} |
| 843 | </Link> | |
| 844 | )} | |
| People wear their own avatars wherever g1t knows them only by name, and a workspace is never shown as a person. The activity feeds, recent changes, home's most active faces, issue and pull request authors, assignees, comments and the audit log drew a person from their username alone, so everyone got their letter avatar instead of the picture they uploaded. An avatar given no picture now draws the letter at once and lays the person's own picture over it once it loads, from /-/avatar/user/<username> (routes/avatar-user.ts): a redirect to the picture on the usercontent origin, or not found, read from identity's people only, so a workspace's icon can never stand in for someone. Kept five minutes, public like the profile. Agents, the product tour's people, "someone" and workspaces' square avatars are never looked up (image null), so an account with the same name never lends them its face. | 845 | <span className="wrap-break-word">{entry.description}</span> |
| Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running | 846 | {givenParts(entry).map((part) => ( |
| 847 | <Badge key={part.label} tone="lavender"> | |
| 848 | Given · {part.label} {usd(part.micros)} | |
| 849 | </Badge> | |
| 850 | ))} | |
| 851 | </div> | |
| 852 | <p className="mt-0.5 font-mono text-xs text-faint"> | |
| 853 | {[ | |
| 854 | entry.repo && (entry.number != null ? `${entry.repo}#${entry.number}` : entry.repo), | |
| 855 | entry.task, | |
| 856 | entry.model, | |
| 857 | entry.billedTo === "workspace" ? "own provider" : null, | |
| 858 | entry.createdBy && `by ${entry.createdBy}`, | |
| 859 | ] | |
| 860 | .filter(Boolean) | |
| 861 | .join(" · ")} | |
| 862 | </p> | |
| 863 | </td> | |
| Merge branch 'worktree-agent-ad7c6d88d93adc817' | 864 | <td className={`tabular px-4 py-2.5 text-right whitespace-nowrap sm:pr-5 ${entry.amountMicros > 0 ? "text-success" : "text-fg-soft"}`}> |
| Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running | 865 | {usd(entry.amountMicros, { signed: true })} |
| 866 | </td> | |
| 867 | </tr> | |
| 868 | ))} | |
| 869 | </tbody> | |
| 870 | </table> | |
| 871 | </div> | |
| 872 | )} | |
| 873 | </Section> | |
| 874 | ); | |
| 875 | } | |
| 876 | ||
| 877 | ||
| 878 | ||
| 879 | export function AuditSection({ audit, description = "Every change made in sudo, and by whom." }: { audit: AdminAction[]; description?: ReactNode }) { | |
| 880 | return ( | |
| 881 | <Section title="Audit log" description={description}> | |
| 882 | {audit.length === 0 ? ( | |
| 883 | <p className="flex items-center gap-2 text-sm text-muted"> | |
| 884 | <ScrollText size={14} /> | |
| 885 | No changes yet. | |
| 886 | </p> | |
| 887 | ) : ( | |
| 888 | <ol className="space-y-3"> | |
| 889 | {audit.map((entry) => ( | |
| Merge branch 'worktree-agent-ad7c6d88d93adc817' | 890 | <li key={entry.id} className="border-l-2 border-accent/40 pl-3"> |
| Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running | 891 | <p className="flex flex-wrap items-center gap-x-2 text-sm"> |
| Merge branch 'worktree-agent-ad7c6d88d93adc817' | 892 | <span className="font-medium text-accent">{actionLabel(entry.action)}</span> |
| Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running | 893 | <span className="text-xs text-faint"> |
| 894 | <When at={entry.createdAt} time /> | |
| 895 | </span> | |
| 896 | </p> | |
| People wear their own avatars wherever g1t knows them only by name, and a workspace is never shown as a person. The activity feeds, recent changes, home's most active faces, issue and pull request authors, assignees, comments and the audit log drew a person from their username alone, so everyone got their letter avatar instead of the picture they uploaded. An avatar given no picture now draws the letter at once and lays the person's own picture over it once it loads, from /-/avatar/user/<username> (routes/avatar-user.ts): a redirect to the picture on the usercontent origin, or not found, read from identity's people only, so a workspace's icon can never stand in for someone. Kept five minutes, public like the profile. Agents, the product tour's people, "someone" and workspaces' square avatars are never looked up (image null), so an account with the same name never lends them its face. | 897 | {entry.detail && <p className="mt-0.5 text-sm wrap-break-word text-fg-soft">{entry.detail}</p>} |
| Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running | 898 | <p className="mt-0.5 flex items-center gap-1 font-mono text-xs text-faint"> |
| 899 | <UserRound size={11} /> | |
| 900 | {entry.by} | |
| 901 | </p> | |
| 902 | </li> | |
| 903 | ))} | |
| 904 | </ol> | |
| 905 | )} | |
| 906 | </Section> | |
| 907 | ); | |
| 908 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.