Skip to content

Compare changes

Choose two branches to see what one has that the other does not, then open a pull request for it.

Open a pull request

1 commit

15 files+1688−2990/15 viewed
+66−43
143143
144144 ## Usage limits
145145
146−Everything a workspace uses costs g1t money at Cloudflare or a model
147−provider before the workspace pays for it. So, as Fly and Cloudflare do
148−with new accounts, every workspace has a limit on usage not yet paid for.
149−When it is reached, the workspace's work stops until it pays, or the
150−month turns:
146+Every workspace has two limits. One protects you from a surprise bill;
147+the other protects g1t from usage that is never paid for. Both are on
148+**Settings → Billing → Limits**.
151149
152−- **No new sandboxes.** Assigning an agent, planning, asking for a
153− review and workflow jobs are refused with `402 payment_required` and the
154− reason; acceptance checks and the merge queue wait. Runs already under
155− way finish.
156−- **No new builds**, and **deployed apps pause**: they answer with a page
157− saying so (`402`) and run nothing. Once the workspace is under its limit
158− again, g1t rebuilds each one from the commit it was serving, by itself.
150+### Your monthly spend limit
159151
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, plus
162−any charges left unpaid from earlier months: a new month is not a fresh
163−allowance. Even
164−usage that is free to you, such as the free minutes, counts at its cost:
165−the limit is about what g1t has spent on a workspace's behalf.
152+What the workspace may spend in a month (UTC). At it, work stops until the
153+month turns or an owner raises it. Owners choose one of:
166154
167−| Workspace | Limit |
155+- **Automatic** (the default): $200, or twice last month's spend,
156+ whichever is more. It keeps up as you grow: a workspace that spent $900
157+ last month can spend $1,800 this month without anyone changing a thing.
158+- **Fixed**: an amount you set.
159+- **None**: work never stops for spend.
160+
161+You are emailed at 50%, 80% and 100% of it.
162+
163+### What g1t lets go unpaid
164+
165+Usage is charged after it runs, so at any moment some of it is not yet
166+paid for. g1t lets that reach a ceiling that grows with your history, the
167+way Cloudflare and Fly do:
168+
169+| | Ceiling on what is unpaid |
168170 | --- | --- |
169171 | **New**: has not paid g1t yet | $3: the free allowances and a little more |
170−| **Paid**: has paid g1t | twice what it has paid, from $25 up to $1,000 |
171−| **Reviewed** | what g1t set for it, after talking with you |
172−| **Comped** | none: g1t covers it |
172+| **Paid** | twice what you have paid, from $25 up to $1,000 |
173+| **Established**: three steady months | three times your monthly spend, up to $10,000, by itself |
174+| **Reviewed** | what g1t set with you; contact us |
173175
174−The limit is there to stop accounts that will never pay, not to slow down
175−ones that do. So:
176+With a card on file, **g1t charges it as you near the ceiling** (80%):
177+an invoice for what you owe, paid at once, after which the ceiling is
178+yours again. So a workspace that pays keeps going, however much it uses;
179+the ceiling only stops one that does not.
176180
177−- **With a card on file, work does not stop.** As a workspace nears its
178− limit (80%), g1t charges its card for what it owes. That payment lowers
179− what is owed and raises the limit, since the limit grows with what a
180− workspace has paid. A workspace that pays as it goes keeps going.
181−- **A declined card stops work** until it is paid, with a message saying
182− so, and the pull requests that were waiting say **Needs you**. Paying
183− under Billing with another card clears it at once.
184−- **Your own spend limit means stop.** An owner can set a lower monthly
185− limit under **Settings → Billing → Usage limit**. At that one, g1t stops
186− work and does not charge the card past it.
181+How the ceiling grows:
182+
183+- A payment counts once it has **cleared for 7 days**, the time in which
184+ most bad cards are caught. Payments with prepaid cards pay, but do not
185+ raise the ceiling, nor do credits g1t gives or payments in test mode.
186+- **Established** comes by itself after three months in a row of real spend
187+ ($20 or more each), every monthly invoice paid, nothing declined in 90
188+ days and nothing ever disputed. From then the ceiling follows your
189+ spend.
190+- Past $10,000, or for terms of your own, **contact us**: we set it with
191+ you, often with an enterprise account and invoices.
192+
193+What counts as unpaid is each item at what it cost g1t or what it is
194+charged, whichever is more, less what was paid this month, plus anything
195+left unpaid from earlier months: a new month is not a fresh allowance.
196+
197+A **declined card** stops work until it is paid, as does a payment
198+disputed with the card's bank. Paying under Billing with another card
199+clears it at once.
200+
201+## Invoices
202+
203+Every charge is a real invoice from g1t, kept on Stripe's billing page
204+with its PDF and emailed as a receipt:
205+
206+- **When each month closes**, an invoice for what the workspace owes,
207+ itemised: agents on g1t's models, runs on your own model provider,
208+ sandbox time, and deployments past the plan. Credit you paid in advance
209+ is taken off as *Paid in advance*; anything left unpaid from before is
210+ added.
211+- **When the workspace nears its ceiling** mid-month, the same, sooner.
187212
188−Payments in test mode are not money: they neither lower what is owed nor
189−raise the limit, and automatic charges only happen with live payments.
190−Credits g1t gives, such as refunds, lower what is owed but do not raise
191−the limit. To go past $1,000, write to support.
213+Each is charged to the card on file. The Billing page lists them, with
214+links to view each on Stripe and download its PDF.
192215
193216 ## Enterprises and custom terms
194217
223246
224247 With a card on file:
225248
226−- **Near the usage limit** (80%), g1t charges it for what the workspace
227− owes, at least $5, so work does not stop.
228−- **When each month closes**, g1t charges it for what the workspace owed
229− at the end of the month, and the statement shows the payment as *Usage
230− for 2026-10, charged to the card on file when the month closed*.
249+- **Near the ceiling** on what is unpaid (80%), g1t sends an
250+ [invoice](#invoices) for what the workspace owes and charges it, so work
251+ does not stop.
252+- **When each month closes**, the month's [invoice](#invoices) is charged
253+ to it.
231254 - **If it is declined**, work stops until the workspace pays, and the
232255 Billing page and the API say why. Replace the card or add credit to pay.
233256
+2−2
394394 className="scroll-mt-28 rounded-xl border border-line"
395395 >
396396 <header
397− className={`sticky top-14 z-20 flex items-center gap-2.5 border-line bg-surface/95 px-3 py-2 backdrop-blur ${
397+ className={`sticky top-16 z-20 flex items-center gap-2.5 border-line bg-surface/95 px-3 py-2 backdrop-blur ${
398398 collapsed ? "rounded-xl" : "rounded-t-xl border-b"
399399 }`}
400400 >
707707 const allCollapsed = files.every((file) => collapsed.has(file.path));
708708 return (
709709 <div>
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">
710+ <div className="sticky top-16 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">
711711 <span className="text-sm text-muted">
712712 <span className="font-medium text-fg">{files.length}</span> {files.length === 1 ? "file" : "files"}
713713 </span>
+7−7
598598 return (
599599 <div className="flex h-full flex-col">
600600 {/* The same height and rule as the top bar, so the two read as one line. */}
601− <div className="flex h-14 shrink-0 items-center gap-1.5 border-b border-line px-3">
602− <Link to="/" aria-label="g1t home" className="shrink-0 rounded-md p-1.5 hover:bg-raised">
603− <Mark className="size-5" />
601+ <div className="flex h-16 shrink-0 items-center gap-2 border-b border-line px-3">
602+ <Link to="/" aria-label="g1t home" className="shrink-0 rounded-md p-2 hover:bg-raised">
603+ <Mark className="size-6" />
604604 </Link>
605605 <span className="shrink-0 text-line-strong" aria-hidden="true">
606606 /
611611 <button
612612 type="button"
613613 onClick={onFind}
614− className="flex h-8 w-full items-center gap-2 rounded-md bg-surface px-2.5 text-[0.8125rem] text-faint ring-1 ring-line transition-colors hover:text-muted hover:ring-line-strong"
614+ className="flex h-9 w-full items-center gap-2 rounded-md bg-surface px-2.5 text-[0.8125rem] text-faint ring-1 ring-line transition-colors hover:text-muted hover:ring-line-strong"
615615 >
616616 <Search size={14} />
617617 <span className="grow text-left">Find…</span>
10461046 )}
10471047
10481048 <div className="flex min-h-screen min-w-0 flex-col lg:pl-64">
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">
1049+ <header className="sticky top-0 z-30 flex h-16 items-center gap-3 border-b border-line bg-bg/85 px-4 backdrop-blur sm:px-6">
10501050 <button
10511051 type="button"
10521052 aria-label="Open menu"
10591059 <div className="ml-auto flex items-center gap-1.5">
10601060 <a
10611061 href="https://docs.g1t.sh/"
1062− className="hidden rounded-md px-2 py-1 text-[0.8125rem] text-muted transition-colors hover:bg-raised hover:text-fg sm:block"
1062+ className="hidden rounded-md px-2.5 py-1.5 text-sm text-muted transition-colors hover:bg-raised hover:text-fg sm:block"
10631063 >
10641064 Docs
10651065 </a>
10661066 <DropdownMenu>
10671067 <DropdownMenuTrigger
10681068 aria-label="Create"
1069− className="flex h-8 items-center gap-1.5 rounded-md bg-fg px-2.5 text-[0.8125rem] font-medium text-bg outline-none transition-colors hover:bg-white"
1069+ className="flex h-9 items-center gap-1.5 rounded-md bg-fg px-3 text-sm font-medium text-bg outline-none transition-colors hover:bg-white"
10701070 >
10711071 <Plus size={14} />
10721072 New
+182−60
88 type Feature,
99 type FeatureState,
1010 type Limit,
11+ type WorkspaceInvoice,
1112 } from "@g1t/contracts";
1213
1314 import type { Route } from "./+types/billing";
4748 await billing.confirm(slug, viewer, session);
4849 throw redirect(`/${slug}/-/billing?added=1`);
4950 }
50− const [account, ledger, features, deployUsage, limit] = await Promise.all([
51+ const [account, ledger, features, deployUsage, limit, invoices] = await Promise.all([
5152 billing.account(slug, viewer),
5253 billing.ledger(slug, viewer),
5354 billing.features(slug, viewer),
5455 deployments.usage(slug, viewer),
5556 billing.limit(slug, viewer),
57+ billing.invoices(slug, viewer).catch(() => null),
5658 ]);
5759 return {
5860 slug,
6264 features: unwrap(features),
6365 deployUsage: deployUsage.ok ? deployUsage.value : null,
6466 limit: limit.ok ? limit.value : null,
67+ invoices: invoices?.ok ? invoices.value : [],
6568 added: url.searchParams.has("added"),
6669 subscribed: url.searchParams.has("subscribed"),
6770 };
7982 if (!started.ok) return { error: started.error.message };
8083 throw redirect(started.value.url);
8184 }
82− if (intent === "spend-limit" || intent === "no-spend-limit") {
85+ if (intent === "spend-limit") {
86+ // automatic, fixed (with an amount), or none.
87+ const mode = String(form.get("mode") ?? "automatic");
8388 const amount = Number(form.get("limit"));
84− if (intent === "spend-limit" && !(Number.isFinite(amount) && amount >= 0)) {
85− return { error: "A spend limit is a dollar amount." };
89+ if (mode === "fixed" && !(Number.isFinite(amount) && amount >= 1)) {
90+ return { error: "A spend limit is a dollar amount, $1 or more." };
8691 }
8792 const set = await billing.setSpendLimit(
8893 user,
8994 params.owner,
90− intent === "spend-limit" ? Math.round(amount * MICROS_PER_DOLLAR) : null,
95+ mode === "fixed" ? Math.round(amount * MICROS_PER_DOLLAR) : null,
96+ mode === "none",
9197 );
9298 return set.ok ? null : { error: set.error.message };
9399 }
120126 }
121127
122128 export default function WorkspaceBilling({ loaderData, actionData }: Route.ComponentProps) {
123− const { slug, role, account, ledger, features, deployUsage, limit, added, subscribed } = loaderData;
129+ const { slug, role, account, ledger, features, deployUsage, limit, invoices, added, subscribed } = loaderData;
124130 const { status } = account;
125131 const paying = useNavigation().state === "submitting";
126132 const empty = account.balanceMicros <= 0;
191197 </section>
192198 )}
193199
200+ {invoices.length > 0 && <InvoiceList invoices={invoices} />}
201+
194202 <h2 className="font-medium">Plans</h2>
195203 <p className="mt-1 max-w-2xl text-sm text-muted">
196204 Paid features are turned on per workspace with a monthly plan. They are never free, including while the rest of
491499 }
492500
493501 const TRUST: Record<Limit["trust"], { label: string; detail: string }> = {
494− new: {
495− label: "New",
496− detail: "No payment to g1t yet, so the limit is small: the free allowances and a little more. It grows once the workspace pays.",
497− },
498− paid: { label: "Paid", detail: "Twice what the workspace has paid g1t, from $25 up to $1,000." },
502+ new: { label: "New", detail: "No payment to g1t yet." },
503+ paid: { label: "Paid", detail: "Grows with every payment." },
504+ established: { label: "Established", detail: "Follows your monthly spend." },
499505 reviewed: { label: "Reviewed", detail: "Set by g1t for this workspace." },
500506 internal: { label: "Comped", detail: "g1t covers this workspace's usage: nothing is charged, and there is no limit." },
501507 };
502508
509+/** One meter: how much of a limit is used. */
510+function Meter({ used, of, state }: { used: number; of: number | null; state: "ok" | "warning" | "stopped" }) {
511+ const share = of ? Math.min(1, used / Math.max(of, 1)) : 0;
512+ const bar = state === "stopped" ? "bg-danger" : state === "warning" ? "bg-warn" : "bg-accent";
513+ return (
514+ <div className="mt-2 h-1.5 overflow-hidden rounded-full bg-line" role="presentation">
515+ <div className={`h-full ${bar}`} style={{ width: `${Math.max(share * 100, share > 0 ? 2 : 0)}%` }} />
516+ </div>
517+ );
518+}
519+
520+function meterState(used: number, of: number | null): "ok" | "warning" | "stopped" {
521+ if (of == null) return "ok";
522+ if (used >= of) return "stopped";
523+ return used * 5 >= of * 4 ? "warning" : "ok";
524+}
525+
503526 /**
504− * How far this month's unpaid usage has gone, and where work stops: g1t's
505− * ceiling for the workspace, or the owners' own spend limit if lower.
527+ * The workspace's two limits, side by side: the owners' monthly spend
528+ * limit, which protects them from a surprise; and what g1t lets go unpaid,
529+ * which grows with what they pay and is charged to the card as it nears.
506530 */
507531 function LimitCard({ limit, owner, busy, error }: { limit: Limit; owner: boolean; busy: boolean; error?: string }) {
508− const ceiling = limit.ceilingMicros;
509− const share = ceiling ? Math.min(1, limit.exposureMicros / Math.max(ceiling, 1)) : 0;
510532 const tone =
511533 limit.state === "stopped" ? "border-danger/40 bg-danger/5" : limit.state === "warning" ? "border-warn/40 bg-warn/5" : "border-line bg-surface";
512− const bar = limit.state === "stopped" ? "bg-danger" : limit.state === "warning" ? "bg-warn" : "bg-accent";
513534 const trust = TRUST[limit.trust];
535+ const spent = limit.spentMicros ?? 0;
536+ const spendLimit = limit.spendLimitMicros;
537+ const available = limit.availableMicros ?? limit.ceilingMicros;
538+ const mode = limit.defaultSpendLimit ? "automatic" : spendLimit == null ? "none" : "fixed";
539+ if (limit.trust === "internal") {
540+ return (
541+ <section className="mb-10 rounded-xl border border-line bg-surface p-5">
542+ <div className="flex flex-wrap items-baseline justify-between gap-2">
543+ <h2 className="font-medium">Limits</h2>
544+ <span className="rounded-full border border-accent/40 px-2 py-0.5 text-xs text-accent">Comped</span>
545+ </div>
546+ <p className="mt-1 text-sm text-muted">{trust.detail}</p>
547+ </section>
548+ );
549+ }
514550 return (
515551 <section className={`mb-10 rounded-xl border p-5 ${tone}`}>
516552 <div className="flex flex-wrap items-baseline justify-between gap-2">
517− <h2 className="font-medium">Usage limit</h2>
553+ <h2 className="font-medium">Limits</h2>
518554 <span className="rounded-full border border-line px-2 py-0.5 text-xs text-muted">{trust.label}</span>
519555 </div>
520− <p className="mt-1 max-w-2xl text-sm text-muted">
521− What this month's usage cost g1t, or is charged, whichever is more, less what was paid this month. With a card on
522− file, g1t charges it as the workspace nears the limit, so its work does not stop. Without one, at the limit new
523− sandboxes and builds stop and apps pause until it pays or the month turns. Work already running finishes.
524− </p>
525− <p className="mt-4 text-2xl font-semibold tabular-nums tracking-tight">
526− {dollars(limit.exposureMicros)}
527− <span className="text-base font-normal text-muted"> {ceiling == null ? "· no limit" : `of ${dollars(ceiling)}`}</span>
528− </p>
529− {ceiling != null && (
530− <div className="mt-3 h-1.5 overflow-hidden rounded-full bg-line" role="presentation">
531− <div className={`h-full ${bar}`} style={{ width: `${Math.max(share * 100, share > 0 ? 2 : 0)}%` }} />
532− </div>
533− )}
534556 {limit.account.startsWith("ent_") && (
535− <p className="mt-3 text-sm text-muted">
557+ <p className="mt-1 text-sm text-muted">
536558 Paid for by the <span className="font-medium text-fg">{limit.accountName}</span> enterprise: these figures are
537559 for all of its workspaces together.
538560 </p>
539561 )}
540− {limit.message && <p className="mt-3 text-sm">{limit.message}</p>}
541− <p className="mt-3 text-xs text-faint">
542− {trust.detail}
543− {limit.trustCeilingMicros != null && limit.spendLimitMicros != null && ` g1t's limit is ${dollars(limit.trustCeilingMicros)}.`}
544− </p>
545− {owner && limit.trust !== "internal" && (
546− <Form method="post" className="mt-4 flex flex-wrap items-end gap-2">
547− <label className="text-sm">
548− <span className="block text-xs text-muted">Your own monthly spend limit</span>
549− <span className="mt-1 flex items-center rounded-md border border-line bg-bg px-2 focus-within:border-accent">
550− <span className="text-muted">$</span>
551− <input
552− name="limit"
553− type="number"
554− min={0}
555− step={1}
556− defaultValue={limit.spendLimitMicros != null ? limit.spendLimitMicros / MICROS_PER_DOLLAR : ""}
557− placeholder="None"
558− className="w-24 bg-transparent px-1 py-1.5 tabular-nums outline-none"
559− />
562+
563+ <div className="mt-5 grid gap-6 sm:grid-cols-2">
564+ <div>
565+ <p className="text-xs text-muted">Spent this month</p>
566+ <p className="mt-1 text-2xl font-semibold tabular-nums tracking-tight">
567+ {dollars(spent)}
568+ <span className="text-base font-normal text-muted">
569+ {spendLimit == null ? " · no spend limit" : ` of ${dollars(spendLimit)}`}
570+ </span>
571+ </p>
572+ {spendLimit != null && <Meter used={spent} of={spendLimit} state={meterState(spent, spendLimit)} />}
573+ <p className="mt-2 text-xs text-faint">
574+ {mode === "automatic"
575+ ? "Your spend limit is automatic: $200, or twice last month's spend, so it keeps up as you grow."
576+ : mode === "fixed"
577+ ? "A spend limit you set. At it, work stops until the month turns."
578+ : "No spend limit: work never stops for spend, only for what g1t lets go unpaid."}
579+ </p>
580+ </div>
581+ <div>
582+ <p className="text-xs text-muted">Not yet paid</p>
583+ <p className="mt-1 text-2xl font-semibold tabular-nums tracking-tight">
584+ {dollars(limit.exposureMicros)}
585+ <span className="text-base font-normal text-muted">
586+ {available == null ? "" : ` of ${dollars(available)} available`}
560587 </span>
561− </label>
562− <Button variant="quiet" type="submit" name="intent" value="spend-limit" disabled={busy}>
563− Set
564− </Button>
565− {limit.spendLimitMicros != null && (
566− <Button variant="quiet" type="submit" name="intent" value="no-spend-limit" disabled={busy}>
567− Remove
568− </Button>
588+ </p>
589+ {available != null && (
590+ <Meter used={limit.exposureMicros} of={available} state={meterState(limit.exposureMicros, available)} />
569591 )}
592+ <p className="mt-2 text-xs text-faint">
593+ With a card on file, g1t charges it as this nears what is available, so work keeps going.
594+ {limit.growth ? ` ${limit.growth}` : ""}
595+ </p>
596+ </div>
597+ </div>
598+
599+ {limit.message && <p className="mt-4 text-sm">{limit.message}</p>}
600+
601+ {owner && (
602+ <Form method="post" className="mt-5 border-t border-line pt-4">
603+ <fieldset>
604+ <legend className="text-xs text-muted">Your monthly spend limit</legend>
605+ <div className="mt-2 flex flex-wrap items-center gap-x-5 gap-y-2 text-sm">
606+ <label className="flex items-center gap-2">
607+ <input type="radio" name="mode" value="automatic" defaultChecked={mode === "automatic"} className="accent-accent" />
608+ Automatic
609+ </label>
610+ <label className="flex items-center gap-2">
611+ <input type="radio" name="mode" value="fixed" defaultChecked={mode === "fixed"} className="accent-accent" />
612+ Fixed at
613+ <span className="flex items-center rounded-md border border-line bg-bg px-2 focus-within:border-accent">
614+ <span className="text-muted">$</span>
615+ <input
616+ name="limit"
617+ type="number"
618+ min={1}
619+ step={1}
620+ defaultValue={mode === "fixed" && spendLimit != null ? spendLimit / MICROS_PER_DOLLAR : ""}
621+ placeholder="500"
622+ className="w-24 bg-transparent px-1 py-1 tabular-nums outline-none"
623+ />
624+ </span>
625+ </label>
626+ <label className="flex items-center gap-2">
627+ <input type="radio" name="mode" value="none" defaultChecked={mode === "none"} className="accent-accent" />
628+ None
629+ </label>
630+ <Button variant="quiet" type="submit" name="intent" value="spend-limit" disabled={busy}>
631+ Save
632+ </Button>
633+ </div>
634+ </fieldset>
570635 <ErrorText>{error}</ErrorText>
636+ <p className="mt-3 text-xs text-faint">
637+ Need more than {available != null ? dollars(available) : "this"} available?{" "}
638+ <a href="mailto:billing@g1t.sh" className="text-fg hover:underline">
639+ Contact us
640+ </a>{" "}
641+ and we will set terms that fit.
642+ </p>
571643 </Form>
572644 )}
573645 </section>
574646 );
575647 }
648+
649+/** The workspace's invoices from g1t, each kept on Stripe with its PDF. */
650+function InvoiceList({ invoices }: { invoices: WorkspaceInvoice[] }) {
651+ return (
652+ <section className="mb-10">
653+ <h2 className="font-medium">Invoices</h2>
654+ <p className="mt-1 text-sm text-muted">
655+ One when each month closes, and one each time g1t charges the card near your limit. Receipts and PDFs are also
656+ on Stripe's billing page.
657+ </p>
658+ <ul className="mt-4 divide-y divide-line overflow-hidden rounded-xl border border-line">
659+ {invoices.map((invoice) => (
660+ <li key={invoice.invoiceId} className="px-4 py-3 text-sm">
661+ <div className="flex flex-wrap items-center gap-3">
662+ <span className="font-medium">
663+ {invoice.reason === "month" ? `Usage for ${invoice.period}` : `Charged near the limit, ${invoice.period}`}
664+ </span>
665+ <span
666+ className={`rounded-full border px-2 py-0.5 text-xs ${
667+ invoice.status === "paid" ? "border-accent/40 text-accent" : "border-danger/40 text-danger"
668+ }`}
669+ >
670+ {invoice.status === "paid" ? "Paid" : invoice.status === "failed" ? "Payment failed" : invoice.status}
671+ </span>
672+ <span className="ml-auto font-mono tabular-nums">{dollars(invoice.amountMicros)}</span>
673+ {invoice.hostedUrl && (
674+ <a href={invoice.hostedUrl} className="text-xs text-muted hover:text-fg">
675+ View
676+ </a>
677+ )}
678+ {invoice.pdfUrl && (
679+ <a href={invoice.pdfUrl} className="text-xs text-muted hover:text-fg">
680+ PDF
681+ </a>
682+ )}
683+ </div>
684+ <ul className="mt-1.5 space-y-0.5 text-xs text-faint">
685+ {invoice.lines.map((line) => (
686+ <li key={line.description} className="flex justify-between gap-4">
687+ <span>{line.description}</span>
688+ <span className="font-mono tabular-nums">{dollars(line.amountMicros)}</span>
689+ </li>
690+ ))}
691+ </ul>
692+ </li>
693+ ))}
694+ </ul>
695+ </section>
696+ );
697+}
+207−0
402402 New,
403403 /// Has paid g1t real money: the ceiling grows with what it has paid.
404404 Paid,
405+ /// Has paid steadily for months, with nothing disputed or declined:
406+ /// the ceiling follows its monthly spend, up to $10,000, by itself.
407+ Established,
405408 /// A ceiling g1t set by hand, after talking to the workspace.
406409 Reviewed,
407410 /// g1t's own workspaces: no ceiling.
446449 pub state: LimitState,
447450 /// What to tell people when work is stopped or close to it.
448451 pub message: Option<String>,
452+ /// Charged this month, which the spend limit is measured against.
453+ #[serde(default)]
454+ pub spent_micros: i64,
455+ /// True while the owners have not chosen a spend limit of their own, so
456+ /// the automatic one applies: $200, or twice last month's spend.
457+ #[serde(default)]
458+ pub default_spend_limit: bool,
459+ /// The most the owners may set their own limit to: g1t's ceiling. To
460+ /// go past it, they contact g1t.
461+ #[serde(default)]
462+ pub available_micros: Option<i64>,
463+ /// How the ceiling grows from here, in a sentence.
464+ #[serde(default)]
465+ pub growth: Option<String>,
449466 }
450467
451468 /// `limit`: a workspace's limit, for its members. Returns `Outcome<Limit>`.
483500 pub struct SetSpendLimitArgs {
484501 pub actor: User,
485502 pub workspace: String,
503+ /// A monthly limit, at most what is available; None goes back to the
504+ /// default.
486505 pub spend_limit_micros: Option<i64>,
506+ /// Use everything available, with no limit of their own.
507+ #[serde(default)]
508+ pub use_full_limit: bool,
487509 }
488510
489511 /// One metered unit: what it costs g1t, and what it is sold at. The price
717739 pub amount_micros: i64,
718740 }
719741
742+/// A workspace's invoice from g1t: one per month, and one each time it is
743+/// charged near its limit. Itemised, charged to the card on file, and kept
744+/// in Stripe's billing page with its PDF.
745+#[derive(Clone, Debug, Serialize, Deserialize)]
746+#[serde(rename_all = "camelCase")]
747+pub struct WorkspaceInvoice {
748+ pub invoice_id: String,
749+ pub workspace: String,
750+ /// `month` (2026-10) or `threshold`.
751+ pub reason: String,
752+ pub period: String,
753+ pub amount_micros: i64,
754+ /// `paid`, `open`, `failed` or `void`.
755+ pub status: String,
756+ pub hosted_url: Option<String>,
757+ pub pdf_url: Option<String>,
758+ pub lines: Vec<InvoiceItem>,
759+ pub created_at: String,
760+}
761+
762+#[derive(Clone, Debug, Serialize, Deserialize)]
763+#[serde(rename_all = "camelCase")]
764+pub struct InvoiceItem {
765+ pub description: String,
766+ pub amount_micros: i64,
767+}
768+
769+/// `invoices`: a workspace's invoices from g1t, newest first. Members
770+/// only. Returns `Outcome<Vec<WorkspaceInvoice>>`.
771+#[derive(Debug, Serialize, Deserialize)]
772+pub struct InvoicesArgs {
773+ pub workspace: String,
774+ pub viewer: Viewer,
775+}
776+
777+/// `admin_workspace_invoices`: the same, for staff. Returns
778+/// `Vec<WorkspaceInvoice>`.
779+#[derive(Debug, Serialize, Deserialize)]
780+pub struct AdminWorkspaceInvoicesArgs {
781+ pub workspace: String,
782+}
783+
784+// --- Sales (sudo.g1t.sh) ------------------------------------------------------
785+//
786+// What staff need to know to reach out: who is growing, who is close to
787+// their limit, who was declined, who has become a steady customer. And what
788+// was done about it: a stage, an owner on g1t's side, a next step, notes.
789+
790+/// Why a workspace is worth a look.
791+#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
792+#[serde(rename_all = "snake_case")]
793+pub enum SignalKind {
794+ /// At its limit, or its own spend limit: work is stopped.
795+ AtLimit,
796+ /// Past 80% of what is available to it: about to need more.
797+ NearCeiling,
798+ /// Its card was declined or a payment disputed.
799+ Declined,
800+ /// This month is well ahead of last month.
801+ Growing,
802+ /// Became Established: the ceiling now follows its spend.
803+ Established,
804+ /// Paid g1t for the first time.
805+ FirstPayment,
806+ /// Spending enough that custom terms or an enterprise may suit it.
807+ HighSpend,
808+}
809+
810+#[derive(Clone, Debug, Serialize, Deserialize)]
811+#[serde(rename_all = "camelCase")]
812+pub struct Signal {
813+ pub workspace: String,
814+ pub kind: SignalKind,
815+ /// One sentence, with the figures.
816+ pub detail: String,
817+ /// The figure that matters, such as this month's spend.
818+ pub value_micros: i64,
819+ /// Its sales stage, if staff gave it one.
820+ pub stage: Option<String>,
821+ pub owner: Option<String>,
822+}
823+
824+/// `admin_signals`: every workspace worth reaching out to, most urgent
825+/// first. Returns `Vec<Signal>`.
826+#[derive(Debug, Default, Serialize, Deserialize)]
827+pub struct AdminSignalsArgs {}
828+
829+/// What staff are doing about a workspace.
830+#[derive(Clone, Debug, Serialize, Deserialize)]
831+#[serde(rename_all = "camelCase")]
832+pub struct SalesRecord {
833+ pub workspace: String,
834+ /// `none`, `lead`, `contacted`, `negotiating`, `won`, `lost` or `churn_risk`.
835+ pub stage: String,
836+ /// The staff member looking after it.
837+ pub owner: Option<String>,
838+ pub next_step: Option<String>,
839+ /// RFC 3339 date.
840+ pub next_at: Option<String>,
841+ pub notes: Vec<SalesNote>,
842+ pub updated_at: Option<String>,
843+}
844+
845+#[derive(Clone, Debug, Serialize, Deserialize)]
846+#[serde(rename_all = "camelCase")]
847+pub struct SalesNote {
848+ pub id: String,
849+ pub text: String,
850+ pub by: String,
851+ pub created_at: String,
852+}
853+
854+/// `admin_sales`: a workspace's sales record. Returns `SalesRecord`.
855+#[derive(Debug, Serialize, Deserialize)]
856+pub struct AdminSalesArgs {
857+ pub workspace: String,
858+}
859+
860+/// `admin_set_sales`: its stage, owner and next step. Returns `Outcome<SalesRecord>`.
861+#[derive(Debug, Serialize, Deserialize)]
862+pub struct AdminSetSalesArgs {
863+ pub workspace: String,
864+ pub stage: String,
865+ #[serde(default)]
866+ pub owner: Option<String>,
867+ #[serde(default)]
868+ pub next_step: Option<String>,
869+ #[serde(default)]
870+ pub next_at: Option<String>,
871+ pub by: String,
872+}
873+
874+/// `admin_add_note`. Returns `Outcome<SalesRecord>`.
875+#[derive(Debug, Serialize, Deserialize)]
876+pub struct AdminAddNoteArgs {
877+ pub workspace: String,
878+ pub text: String,
879+ pub by: String,
880+}
881+
882+/// `admin_overview`: the business at a glance. Returns `Overview`.
883+#[derive(Debug, Default, Serialize, Deserialize)]
884+pub struct AdminOverviewArgs {}
885+
886+#[derive(Clone, Debug, Serialize, Deserialize)]
887+#[serde(rename_all = "camelCase")]
888+pub struct Overview {
889+ /// YYYY-MM.
890+ pub month: String,
891+ /// The last six months, oldest first, all workspaces together.
892+ pub months: Vec<MonthFigures>,
893+ /// This month by kind of usage: models, sandbox, deployments, plans.
894+ pub by_kind: Vec<KindFigures>,
895+ pub paying_workspaces: u32,
896+ pub stopped: u32,
897+ pub near_ceiling: u32,
898+ pub declined: u32,
899+ /// Sent and not yet paid, workspaces and enterprises.
900+ pub open_invoices_micros: i64,
901+ /// Follow-ups due today or earlier.
902+ pub follow_ups_due: u32,
903+}
904+
905+#[derive(Clone, Debug, Serialize, Deserialize)]
906+#[serde(rename_all = "camelCase")]
907+pub struct KindFigures {
908+ pub kind: String,
909+ pub charged_micros: i64,
910+ pub cost_micros: i64,
911+}
912+
720913 // --- Staff (sudo.g1t.sh) ------------------------------------------------------
721914 //
722915 // Called only by the sudo app, which only g1t staff can reach (behind
750943 /// any, so staff can see what one member of an enterprise used.
751944 #[serde(default)]
752945 pub by_workspace: Vec<WorkspaceFigures>,
946+ /// The last six months, oldest first, for trends.
947+ #[serde(default)]
948+ pub months: Vec<MonthFigures>,
949+}
950+
951+/// One month of an account's billing.
952+#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
953+#[serde(rename_all = "camelCase")]
954+pub struct MonthFigures {
955+ /// YYYY-MM.
956+ pub month: String,
957+ pub charged_micros: i64,
958+ pub cost_micros: i64,
959+ pub paid_micros: i64,
753960 }
754961
755962 /// One workspace's share of an [`AccountSummary`].
+80−3
112112 paidMicros: number;
113113 /** The same figures for each of the account's workspaces that has any. */
114114 byWorkspace: WorkspaceFigures[];
115+ /** The last six months, oldest first. */
116+ months?: MonthFigures[];
115117 };
116118
117119 /** One workspace's share of an `AccountSummary`. */
136138 createdAt: string;
137139 };
138140
141+/** A workspace's invoice: monthly, or when charged near its limit. Itemised, in Stripe's billing page. */
142+export type WorkspaceInvoice = {
143+ invoiceId: string;
144+ workspace: string;
145+ reason: "month" | "threshold" | string;
146+ period: string;
147+ amountMicros: number;
148+ status: "paid" | "open" | "failed" | "void" | string;
149+ hostedUrl: string | null;
150+ pdfUrl: string | null;
151+ lines: { description: string; amountMicros: number }[];
152+ createdAt: string;
153+};
154+
155+export type MonthFigures = { month: string; chargedMicros: number; costMicros: number; paidMicros: number };
156+
157+export type SignalKind = "at_limit" | "near_ceiling" | "declined" | "growing" | "established" | "first_payment" | "high_spend";
158+
159+/** Why a workspace is worth reaching out to. */
160+export type Signal = {
161+ workspace: string;
162+ kind: SignalKind;
163+ detail: string;
164+ valueMicros: number;
165+ stage: string | null;
166+ owner: string | null;
167+};
168+
169+export type SalesStage = "none" | "lead" | "contacted" | "negotiating" | "won" | "lost" | "churn_risk";
170+
171+export type SalesRecord = {
172+ workspace: string;
173+ stage: SalesStage | string;
174+ owner: string | null;
175+ nextStep: string | null;
176+ nextAt: string | null;
177+ notes: { id: string; text: string; by: string; createdAt: string }[];
178+ updatedAt: string | null;
179+};
180+
181+export type Overview = {
182+ month: string;
183+ months: MonthFigures[];
184+ byKind: { kind: string; chargedMicros: number; costMicros: number }[];
185+ payingWorkspaces: number;
186+ stopped: number;
187+ nearCeiling: number;
188+ declined: number;
189+ openInvoicesMicros: number;
190+ followUpsDue: number;
191+};
192+
139193 /** A customer's Stripe billing page, for staff to send them. */
140194 export type BillingLink = {
141195 /** One-time and short-lived, signed in already. */
173227 invoiceEnterprise(id: string, by: string): Promise<Result<EnterpriseInvoice>>;
174228 /** Exactly these workspaces' accounts, such as one page of the list. */
175229 accountsFor(workspaces: string[]): Promise<AccountSummary[]>;
230+ /** Every workspace worth reaching out to, most urgent first. */
231+ signals(): Promise<Signal[]>;
232+ /** The business at a glance. */
233+ overview(): Promise<Overview>;
234+ /** A workspace's sales record. */
235+ sales(workspace: string): Promise<SalesRecord>;
236+ setSales(workspace: string, record: { stage: string; owner?: string | null; nextStep?: string | null; nextAt?: string | null }, by: string): Promise<Result<SalesRecord>>;
237+ addNote(workspace: string, text: string, by: string): Promise<Result<SalesRecord>>;
238+ /** A workspace's invoices from g1t, for staff. */
239+ workspaceInvoices(workspace: string): Promise<WorkspaceInvoice[]>;
176240 }
177241
178242 /** How much a workspace has earned g1t's trust with money. */
179−export type Trust = "new" | "paid" | "reviewed" | "internal";
243+export type Trust = "new" | "paid" | "established" | "reviewed" | "internal";
180244
181245 /**
182246 * How far a workspace's unpaid usage has gone this month, and where its
197261 spendLimitMicros: number | null;
198262 state: "ok" | "warning" | "stopped";
199263 message: string | null;
264+ /** Charged this month: what the spend limit is measured against. */
265+ spentMicros?: number;
266+ /** True while the owners have not chosen a limit, so the automatic one applies: $200, or twice last month's spend. */
267+ defaultSpendLimit?: boolean;
268+ /** The most owners may set their own limit to; past it, they contact g1t. */
269+ availableMicros?: number | null;
270+ /** How the ceiling grows from here, in a sentence. */
271+ growth?: string | null;
200272 };
201273
202274 /** One metered unit: what it costs g1t and what it is sold at; the price follows the cost. */
352424 limit(workspace: string, viewer: Viewer): Promise<Result<Limit>>;
353425 /** The same, for the services that enforce it. */
354426 checkLimit(workspace: string): Promise<Result<Limit>>;
355− /** The owner's own monthly ceiling, under g1t's; null removes it. Owners only. */
356− setSpendLimit(actor: User, workspace: string, spendLimitMicros: number | null): Promise<Result<Limit>>;
357427 /**
428+ * The owners' own monthly limit, up to what is available; null goes back
429+ * to the default, and `useFullLimit` uses everything available. Owners only.
430+ */
431+ setSpendLimit(actor: User, workspace: string, spendLimitMicros: number | null, useFullLimit?: boolean): Promise<Result<Limit>>;
432+ /** The workspace's invoices from g1t, newest first. Members only. */
433+ invoices(workspace: string, viewer: Viewer): Promise<Result<WorkspaceInvoice[]>>;
434+ /**
358435 * How long a sandbox ran for a workspace, reported when it stops. Its
359436 * cost is always recorded; seconds past the month's free minutes are
360437 * charged. False if `reference` was recorded before.
+17−2
214214 checkLimit: (workspace) => call("check_limit", { workspace }),
215215 prices: () => call("prices", {}),
216216 notePending: (workspace, source, costMicros) => call("note_pending", { workspace, source, costMicros }),
217− setSpendLimit: (actor, workspace, spendLimitMicros) =>
218− call("set_spend_limit", { actor, workspace, spendLimitMicros }),
217+ setSpendLimit: (actor, workspace, spendLimitMicros, useFullLimit = false) =>
218+ call("set_spend_limit", { actor, workspace, spendLimitMicros, use_full_limit: useFullLimit }),
219+ invoices: (workspace, viewer) => call("invoices", { workspace, viewer }),
219220 };
220221 }
221222
233234 enterpriseBilling: (id, email, by) => call("admin_enterprise_billing", { id, email, by }),
234235 invoiceEnterprise: (id, by) => call("admin_invoice_enterprise", { id, by }),
235236 accountsFor: (workspaces) => call("admin_accounts", { query: null, workspaces }),
237+ signals: () => call("admin_signals", {}),
238+ overview: () => call("admin_overview", {}),
239+ sales: (workspace) => call("admin_sales", { workspace }),
240+ setSales: (workspace, record, by) =>
241+ call("admin_set_sales", {
242+ workspace,
243+ stage: record.stage,
244+ owner: record.owner ?? null,
245+ next_step: record.nextStep ?? null,
246+ next_at: record.nextAt ?? null,
247+ by,
248+ }),
249+ addNote: (workspace, text, by) => call("admin_add_note", { workspace, text, by }),
250+ workspaceInvoices: (workspace) => call("admin_workspace_invoices", { workspace }),
236251 };
237252 }
238253
+60−0
1+-- Real invoices, trust that is hard to game, and sales records.
2+
3+-- Payments: which kind of card paid (prepaid cards never raise the
4+-- limit), and whether the payment was disputed (never counts again).
5+ALTER TABLE ledger ADD COLUMN funding TEXT;
6+ALTER TABLE ledger ADD COLUMN disputed INTEGER NOT NULL DEFAULT 0;
7+
8+-- The owners chose to use everything available, with no limit of their
9+-- own. Without it and without spend_limit_micros, the default applies.
10+ALTER TABLE limits ADD COLUMN spend_limit_full INTEGER NOT NULL DEFAULT 0;
11+
12+-- A workspace's invoices from g1t: one when each month closes, and one
13+-- each time it is charged near its limit. Itemised at Stripe, charged to
14+-- the card on file, and kept in Stripe's billing page with a PDF.
15+CREATE TABLE workspace_invoices (
16+ invoice_id TEXT PRIMARY KEY,
17+ workspace TEXT NOT NULL,
18+ -- month or threshold.
19+ reason TEXT NOT NULL,
20+ -- YYYY-MM for a month, the date for a threshold.
21+ period TEXT NOT NULL,
22+ amount_micros INTEGER NOT NULL,
23+ -- paid, open, failed or void.
24+ status TEXT NOT NULL,
25+ hosted_url TEXT,
26+ pdf_url TEXT,
27+ -- Usage up to here is on this invoice.
28+ through_at TEXT NOT NULL,
29+ created_at TEXT NOT NULL,
30+ paid_at TEXT
31+);
32+CREATE INDEX workspace_invoices_by_workspace ON workspace_invoices (workspace, created_at);
33+
34+CREATE TABLE workspace_invoice_lines (
35+ invoice_id TEXT NOT NULL,
36+ position INTEGER NOT NULL,
37+ description TEXT NOT NULL,
38+ amount_micros INTEGER NOT NULL,
39+ PRIMARY KEY (invoice_id, position)
40+);
41+
42+-- What staff are doing about a workspace.
43+CREATE TABLE sales_records (
44+ workspace TEXT PRIMARY KEY,
45+ stage TEXT NOT NULL DEFAULT 'none',
46+ owner TEXT,
47+ next_step TEXT,
48+ next_at TEXT,
49+ updated_by TEXT NOT NULL,
50+ updated_at TEXT NOT NULL
51+);
52+
53+CREATE TABLE sales_notes (
54+ id TEXT PRIMARY KEY,
55+ workspace TEXT NOT NULL,
56+ text TEXT NOT NULL,
57+ by TEXT NOT NULL,
58+ created_at TEXT NOT NULL
59+);
60+CREATE INDEX sales_notes_by_workspace ON sales_notes (workspace, created_at);
+2−0
292292 for p in &paid {
293293 figures_for(&mut by_workspace, &p.workspace).paid_micros += p.paid.unwrap_or(0);
294294 }
295+ let months = self.months_for(&account.workspaces, 6).await?;
295296 Ok(AccountSummary {
297+ months,
296298 charged_micros: by_workspace.iter().map(|f| f.charged_micros).sum(),
297299 cost_micros: by_workspace.iter().map(|f| f.cost_micros).sum(),
298300 paid_micros: by_workspace.iter().map(|f| f.paid_micros).sum(),
+351−0
1+//! A workspace's invoices from g1t.
2+//!
3+//! Every time g1t charges a workspace's card, it is a real Stripe invoice:
4+//! when each month closes, and when the workspace nears its limit mid-month
5+//! (a threshold invoice, as Cloudflare and Fly do). Each is itemised by
6+//! what was used since the last one, with any credit paid in advance taken
7+//! off and anything left unpaid from before added, so its total is exactly
8+//! what is owed. Stripe charges the card, emails the receipt, and keeps
9+//! the invoice and its PDF in the workspace's billing page.
10+
11+use g1t_contracts::billing::{EntryKind, InvoiceItem, InvoicesArgs, WorkspaceInvoice};
12+use g1t_contracts::time::rfc3339;
13+use g1t_contracts::{FailureCode, Outcome};
14+use g1t_kit::now_ms;
15+use serde::Deserialize;
16+use serde_json::Value;
17+use worker::Result;
18+
19+use crate::Billing;
20+
21+/// Stripe will not charge a card less than this.
22+const MIN_INVOICE_MICROS: i64 = 500_000;
23+
24+/// The invoice's lines: what was used since the last one, by kind, then
25+/// whatever makes the total what is owed.
26+pub(crate) fn invoice_lines(used: &[(String, i64)], owed: i64) -> Vec<InvoiceItem> {
27+ let mut lines: Vec<InvoiceItem> = used
28+ .iter()
29+ .filter(|(_, amount)| *amount > 0)
30+ .map(|(kind, amount)| InvoiceItem { description: kind.clone(), amount_micros: *amount })
31+ .collect();
32+ let difference = owed - lines.iter().map(|l| l.amount_micros).sum::<i64>();
33+ if difference < 0 {
34+ lines.push(InvoiceItem { description: "Paid in advance".to_owned(), amount_micros: difference });
35+ } else if difference > 0 {
36+ lines.push(InvoiceItem { description: "Unpaid from earlier".to_owned(), amount_micros: difference });
37+ }
38+ lines
39+}
40+
41+#[derive(Deserialize)]
42+struct InvoiceRow {
43+ invoice_id: String,
44+ workspace: String,
45+ reason: String,
46+ period: String,
47+ amount_micros: i64,
48+ status: String,
49+ hosted_url: Option<String>,
50+ pdf_url: Option<String>,
51+ created_at: String,
52+}
53+
54+#[derive(Deserialize)]
55+struct LineRow {
56+ description: String,
57+ amount_micros: i64,
58+}
59+
60+/// A Stripe invoice, as far as billing reads it.
61+#[derive(Deserialize)]
62+struct StripeInvoice {
63+ id: String,
64+ #[serde(default)]
65+ status: Option<String>,
66+ #[serde(default)]
67+ hosted_invoice_url: Option<String>,
68+ #[serde(default)]
69+ invoice_pdf: Option<String>,
70+ #[serde(default)]
71+ amount_paid: i64,
72+ #[serde(default)]
73+ charge: Option<String>,
74+}
75+
76+impl Billing {
77+ /// Invoices the workspace for what it owes, charging its card. `Ok(Err)`
78+ /// says why not, when there was nothing to do or no card.
79+ pub(crate) async fn invoice_workspace(
80+ &self,
81+ workspace: &str,
82+ reason: &str,
83+ period: &str,
84+ ) -> Result<std::result::Result<WorkspaceInvoice, String>> {
85+ let Some(stripe) = &self.stripe else { return Ok(Err("Payments are not set up.".into())) };
86+ let Some(account) = self.row(workspace).await? else { return Ok(Err("Nothing billed yet.".into())) };
87+ let Some(customer) = account.customer_id else { return Ok(Err("No card on file.".into())) };
88+ let owed = (-account.balance_micros).max(0);
89+ if owed < MIN_INVOICE_MICROS {
90+ return Ok(Err("Less is owed than Stripe will charge.".into()));
91+ }
92+ // What was used since the last invoice, by kind.
93+ #[derive(Deserialize)]
94+ struct Last {
95+ through_at: Option<String>,
96+ }
97+ let since = self
98+ .db
99+ .prepare("SELECT MAX(through_at) AS through_at FROM workspace_invoices WHERE workspace = ? AND status <> 'void'")
100+ .bind(&[workspace.into()])?
101+ .first::<Last>(None)
102+ .await?
103+ .and_then(|l| l.through_at)
104+ .unwrap_or_default();
105+ #[derive(Deserialize)]
106+ struct Used {
107+ kind: String,
108+ charged: Option<i64>,
109+ }
110+ let now = rfc3339(now_ms());
111+ let used: Vec<(String, i64)> = self
112+ .db
113+ .prepare(
114+ "SELECT CASE
115+ WHEN task = 'sandbox' THEN 'Sandbox time'
116+ WHEN task = 'deployments' THEN 'Deployments: builds and usage past the plan'
117+ WHEN billed_to = 'workspace' THEN 'Runs on your own model provider'
118+ ELSE 'Agents on g1t''s models' END AS kind,
119+ -SUM(amount_micros) AS charged
120+ FROM ledger WHERE workspace = ? AND kind = 'usage' AND created_at > ? AND created_at <= ?
121+ GROUP BY 1 ORDER BY charged DESC",
122+ )
123+ .bind(&[workspace.into(), since.as_str().into(), now.as_str().into()])?
124+ .all()
125+ .await?
126+ .results::<Used>()?
127+ .into_iter()
128+ .map(|u| (u.kind, u.charged.unwrap_or(0)))
129+ .collect();
130+ let lines = invoice_lines(&used, owed);
131+ let key = format!("ws-invoice/{workspace}/{reason}/{period}/{}", owed / 10_000);
132+ for (position, line) in lines.iter().enumerate() {
133+ let fields = [
134+ ("customer", customer.clone()),
135+ ("amount", (line.amount_micros / 10_000).to_string()),
136+ ("currency", "usd".to_owned()),
137+ ("description", line.description.clone()),
138+ ("metadata[workspace]", workspace.to_owned()),
139+ ];
140+ let _: Value = stripe.post_idempotent("/invoiceitems", &fields, &format!("{key}/item/{position}")).await?;
141+ }
142+ let description = match reason {
143+ "month" => format!("g1t usage for {workspace}, {period}"),
144+ _ => format!("g1t usage for {workspace}, charged as it neared its limit"),
145+ };
146+ let fields = [
147+ ("customer", customer.clone()),
148+ ("collection_method", "charge_automatically".to_owned()),
149+ ("auto_advance", "false".to_owned()),
150+ ("pending_invoice_items_behavior", "include".to_owned()),
151+ ("description", description),
152+ ("metadata[g1t_workspace]", workspace.to_owned()),
153+ ("metadata[reason]", reason.to_owned()),
154+ ("metadata[period]", period.to_owned()),
155+ ];
156+ let draft: StripeInvoice = stripe.post_idempotent("/invoices", &fields, &key).await?;
157+ // A retry finds it finalized already; that is fine.
158+ let _ = stripe.post::<Value>(&format!("/invoices/{}/finalize", draft.id), &[]).await;
159+ // Charge the card now; a decline comes back as an error.
160+ let paid = stripe.post::<StripeInvoice>(&format!("/invoices/{}/pay", draft.id), &[("off_session", "true".to_owned())]).await;
161+ let invoice: StripeInvoice = stripe.get(&format!("/invoices/{}", draft.id)).await?;
162+ let total = lines.iter().map(|l| l.amount_micros).sum::<i64>();
163+ let status = if invoice.status.as_deref() == Some("paid") { "paid" } else { "failed" };
164+ let mut writes = vec![self
165+ .db
166+ .prepare(
167+ "INSERT OR REPLACE INTO workspace_invoices
168+ (invoice_id, workspace, reason, period, amount_micros, status, hosted_url, pdf_url, through_at, created_at, paid_at)
169+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
170+ )
171+ .bind(&[
172+ invoice.id.as_str().into(),
173+ workspace.into(),
174+ reason.into(),
175+ period.into(),
176+ (total as f64).into(),
177+ status.into(),
178+ crate::optional(invoice.hosted_invoice_url.as_deref()),
179+ crate::optional(invoice.invoice_pdf.as_deref()),
180+ now.as_str().into(),
181+ now.as_str().into(),
182+ crate::optional((status == "paid").then_some(now.as_str())),
183+ ])?];
184+ for (position, line) in lines.iter().enumerate() {
185+ writes.push(
186+ self.db
187+ .prepare("INSERT OR REPLACE INTO workspace_invoice_lines (invoice_id, position, description, amount_micros) VALUES (?, ?, ?, ?)")
188+ .bind(&[invoice.id.as_str().into(), (position as u32).into(), line.description.as_str().into(), (line.amount_micros as f64).into()])?,
189+ );
190+ }
191+ self.db.batch(writes).await?;
192+ if status == "paid" {
193+ self.credit_invoice(workspace, &invoice).await?;
194+ } else {
195+ let error = paid.err().map_or_else(|| "the card was declined".to_owned(), |e| e.to_string().chars().take(200).collect());
196+ self.mark_declined(workspace, &error).await?;
197+ }
198+ Ok(Ok(WorkspaceInvoice {
199+ invoice_id: invoice.id,
200+ workspace: workspace.to_owned(),
201+ reason: reason.to_owned(),
202+ period: period.to_owned(),
203+ amount_micros: total,
204+ status: status.to_owned(),
205+ hosted_url: invoice.hosted_invoice_url,
206+ pdf_url: invoice.invoice_pdf,
207+ lines,
208+ created_at: now,
209+ }))
210+ }
211+
212+ /// Enters an invoice's payment once, with the kind of card that paid.
213+ async fn credit_invoice(&self, workspace: &str, invoice: &StripeInvoice) -> Result<bool> {
214+ let seen = self
215+ .db
216+ .prepare("SELECT id FROM ledger WHERE reference = ?")
217+ .bind(&[invoice.id.as_str().into()])?
218+ .first::<Value>(None)
219+ .await?;
220+ if seen.is_some() {
221+ return Ok(false);
222+ }
223+ let amount = invoice.amount_paid * 10_000;
224+ if amount <= 0 {
225+ return Ok(false);
226+ }
227+ self.enter(workspace, EntryKind::TopUp, amount, &format!("Paid invoice {}", invoice.id), &invoice.id, None, None, None, None)
228+ .await?;
229+ // Prepaid cards pay, but never raise the limit.
230+ if let (Some(stripe), Some(charge)) = (&self.stripe, &invoice.charge) {
231+ if let Ok(charge) = stripe.get::<Value>(&format!("/charges/{charge}")).await {
232+ if let Some(funding) = charge["payment_method_details"]["card"]["funding"].as_str() {
233+ self.db
234+ .prepare("UPDATE ledger SET funding = ? WHERE reference = ?")
235+ .bind(&[funding.into(), invoice.id.as_str().into()])?
236+ .run()
237+ .await?;
238+ }
239+ }
240+ }
241+ Ok(true)
242+ }
243+
244+ pub(crate) async fn mark_declined(&self, workspace: &str, error: &str) -> Result<()> {
245+ let now = rfc3339(now_ms());
246+ self.db
247+ .prepare(
248+ "INSERT INTO limits (workspace, autopay_failed_at, autopay_error, updated_at) VALUES (?1, ?2, ?3, ?2)
249+ ON CONFLICT (workspace) DO UPDATE SET autopay_failed_at = ?2, autopay_error = ?3, updated_at = ?2",
250+ )
251+ .bind(&[workspace.into(), now.as_str().into(), error.into()])?
252+ .run()
253+ .await?;
254+ Ok(())
255+ }
256+
257+ /// A workspace invoice paid later, on Stripe's page or by a retry.
258+ pub(crate) async fn workspace_invoice_paid(&self, invoice_id: &str) -> Result<Option<String>> {
259+ #[derive(Deserialize)]
260+ struct Row {
261+ workspace: String,
262+ }
263+ let Some(row) = self
264+ .db
265+ .prepare("SELECT workspace FROM workspace_invoices WHERE invoice_id = ?")
266+ .bind(&[invoice_id.into()])?
267+ .first::<Row>(None)
268+ .await?
269+ else {
270+ return Ok(None);
271+ };
272+ let Some(stripe) = &self.stripe else { return Ok(None) };
273+ let invoice: StripeInvoice = stripe.get(&format!("/invoices/{invoice_id}")).await?;
274+ self.db
275+ .prepare("UPDATE workspace_invoices SET status = 'paid', paid_at = ? WHERE invoice_id = ?")
276+ .bind(&[rfc3339(now_ms()).into(), invoice_id.into()])?
277+ .run()
278+ .await?;
279+ let credited = self.credit_invoice(&row.workspace, &invoice).await?;
280+ Ok(Some(format!(
281+ "invoice {invoice_id} for {} paid{}",
282+ row.workspace,
283+ if credited { "" } else { " (already credited)" }
284+ )))
285+ }
286+
287+ pub(crate) async fn workspace_invoices(&self, workspace: &str) -> Result<Vec<WorkspaceInvoice>> {
288+ let rows = self
289+ .db
290+ .prepare("SELECT * FROM workspace_invoices WHERE workspace = ? ORDER BY created_at DESC LIMIT 36")
291+ .bind(&[workspace.into()])?
292+ .all()
293+ .await?
294+ .results::<InvoiceRow>()?;
295+ let mut invoices = vec![];
296+ for row in rows {
297+ let lines = self
298+ .db
299+ .prepare("SELECT description, amount_micros FROM workspace_invoice_lines WHERE invoice_id = ? ORDER BY position")
300+ .bind(&[row.invoice_id.as_str().into()])?
301+ .all()
302+ .await?
303+ .results::<LineRow>()?
304+ .into_iter()
305+ .map(|l| InvoiceItem { description: l.description, amount_micros: l.amount_micros })
306+ .collect();
307+ invoices.push(WorkspaceInvoice {
308+ invoice_id: row.invoice_id,
309+ workspace: row.workspace,
310+ reason: row.reason,
311+ period: row.period,
312+ amount_micros: row.amount_micros,
313+ status: row.status,
314+ hosted_url: row.hosted_url,
315+ pdf_url: row.pdf_url,
316+ lines,
317+ created_at: row.created_at,
318+ });
319+ }
320+ Ok(invoices)
321+ }
322+
323+ /// `invoices`: for the workspace's members.
324+ pub(crate) async fn invoices(&self, a: InvoicesArgs) -> Result<Outcome<Vec<WorkspaceInvoice>>> {
325+ let workspace = a.workspace.to_lowercase();
326+ if !a.viewer.is_some_and(|viewer| viewer.is_member(&workspace)) {
327+ return Ok(Outcome::fail(FailureCode::Forbidden, "Only members can see a workspace's invoices."));
328+ }
329+ Ok(Outcome::Ok(self.workspace_invoices(&workspace).await?))
330+ }
331+}
332+
333+#[cfg(test)]
334+mod tests {
335+ use super::*;
336+
337+ #[test]
338+ fn an_invoice_adds_up_to_what_is_owed() {
339+ let used = vec![("Agents on g1t's models".to_owned(), 40_000_000), ("Sandbox time".to_owned(), 10_000_000)];
340+ // $10 of credit was paid in advance.
341+ let lines = invoice_lines(&used, 40_000_000);
342+ assert_eq!(lines.last().unwrap().description, "Paid in advance");
343+ assert_eq!(lines.iter().map(|l| l.amount_micros).sum::<i64>(), 40_000_000);
344+ // $5 was left unpaid from before.
345+ let lines = invoice_lines(&used, 55_000_000);
346+ assert_eq!(lines.last().unwrap().description, "Unpaid from earlier");
347+ assert_eq!(lines.iter().map(|l| l.amount_micros).sum::<i64>(), 55_000_000);
348+ // Exactly what was used.
349+ assert_eq!(invoice_lines(&used, 50_000_000).len(), 2);
350+ }
351+}
+12−0
1717 //! the methods and their arguments.
1818
1919 mod accounts;
20+mod invoices;
21+mod sales;
2022 mod webhooks;
2123 mod features;
2224 mod keeper;
10161018 "admin_enterprise_billing" => reply(&billing.admin_enterprise_billing(args(body)?).await?),
10171019 "admin_invoice_enterprise" => reply(&billing.admin_invoice_enterprise(args(body)?).await?),
10181020 "stripe_webhook" => reply(&billing.stripe_webhook(args(body)?).await?),
1021+ "invoices" => reply(&billing.invoices(args(body)?).await?),
1022+ "admin_workspace_invoices" => {
1023+ let a: AdminWorkspaceInvoicesArgs = args(body)?;
1024+ reply(&billing.workspace_invoices(&a.workspace.to_lowercase()).await?)
1025+ }
1026+ "admin_signals" => reply(&billing.admin_signals(args(body)?).await?),
1027+ "admin_overview" => reply(&billing.admin_overview(args(body)?).await?),
1028+ "admin_sales" => reply(&billing.admin_sales(args(body)?).await?),
1029+ "admin_set_sales" => reply(&billing.admin_set_sales(args(body)?).await?),
1030+ "admin_add_note" => reply(&billing.admin_add_note(args(body)?).await?),
10191031 "note_pending" => reply(&billing.note_pending(args(body)?).await?),
10201032 "admin_accounts" => reply(&billing.admin_accounts(args(body)?).await?),
10211033 "admin_account" => reply(&billing.admin_account(args(body)?).await?),
+223−134
6868 }
6969 }
7070
71−/// Never charged automatically for less.
72−const AUTOPAY_MIN_CENTS: i64 = 500;
71+/// The automatic monthly spend limit's floor: $200.
72+pub(crate) const DEFAULT_SPEND_MICROS: i64 = 200_000_000;
73+/// Established workspaces' ceiling: three times their steady monthly
74+/// spend, up to $10,000.
75+const ESTABLISHED_FACTOR: i64 = 3;
76+const ESTABLISHED_MAX_MICROS: i64 = 10_000_000_000;
77+/// A month counts toward Established at this much spend or more.
78+const ESTABLISHED_MONTH_MICROS: i64 = 20_000_000;
79+/// Payments raise trust once this old: past the time most bad cards are
80+/// caught.
81+const SETTLE_DAYS: u64 = 7;
82+
83+/// The automatic spend limit: $200, or twice last month's spend.
84+pub(crate) fn automatic_spend_limit(last_month_charged: i64) -> i64 {
85+ DEFAULT_SPEND_MICROS.max(last_month_charged * 2)
86+}
7387
88+/// An Established workspace's ceiling, from its last three months'
89+/// charges, if each was steady enough.
90+pub(crate) fn established_ceiling(months: &[i64]) -> Option<i64> {
91+ if months.len() < 3 || months.iter().any(|m| *m < ESTABLISHED_MONTH_MICROS) {
92+ return None;
93+ }
94+ let average = months.iter().sum::<i64>() / months.len() as i64;
95+ Some((average * ESTABLISHED_FACTOR).min(ESTABLISHED_MAX_MICROS))
96+}
97+
7498 #[derive(Deserialize)]
7599 struct LimitRow {
76100 spend_limit_micros: Option<i64>,
101+ #[serde(default)]
102+ spend_limit_full: Option<i64>,
77103 autopay_failed_at: Option<String>,
78104 autopay_error: Option<String>,
79105 }
98124 let account = self.account_of(&workspace).await?;
99125 let row = self
100126 .db
101− .prepare("SELECT spend_limit_micros, autopay_failed_at, autopay_error FROM limits WHERE workspace = ?")
127+ .prepare("SELECT spend_limit_micros, spend_limit_full, autopay_failed_at, autopay_error FROM limits WHERE workspace = ?")
102128 .bind(&[workspace.as_str().into()])?
103129 .first::<LimitRow>(None)
104130 .await?;
171197 _ if account.terms.ceiling_micros.is_some() => (Trust::Reviewed, account.terms.ceiling_micros),
172198 _ => {
173199 let paid = self.live_paid(&members).await?;
174− if paid > 0 {
175− (Trust::Paid, Some(self.ceilings.for_paid(paid)))
176− } else {
177− (Trust::New, Some(self.ceilings.new))
200+ let established = if paid > 0 { self.established(&members).await? } else { None };
201+ match established {
202+ Some(ceiling) => (Trust::Established, Some(ceiling.max(self.ceilings.for_paid(paid)))),
203+ None if paid > 0 => (Trust::Paid, Some(self.ceilings.for_paid(paid))),
204+ None => (Trust::New, Some(self.ceilings.new)),
178205 }
179206 }
180207 };
181− let spend_limit = row.as_ref().and_then(|row| row.spend_limit_micros);
208+ // This month's charges, and last month's, for the spend limit.
209+ let (spent, last_month) = self.charged_months(&members, &month_start).await?;
210+ let spent = spent + pending;
211+ // The owners' own monthly limit: theirs, none, or the automatic one
212+ // ($200, or twice last month), which self-serve workspaces start on.
213+ let chosen = row.as_ref().and_then(|row| row.spend_limit_micros);
214+ let full = row.as_ref().and_then(|row| row.spend_limit_full).unwrap_or(0) == 1;
215+ let self_serve = matches!(trust, Trust::New | Trust::Paid | Trust::Established);
216+ let default_spend_limit = chosen.is_none() && !full && self_serve;
217+ let spend_limit = match (chosen, full) {
218+ (Some(own), _) => Some(own),
219+ (None, true) => None,
220+ (None, false) if self_serve => Some(automatic_spend_limit(last_month)),
221+ _ => None,
222+ };
182223 // A card declined when g1t charged it at the limit stops work until
183224 // it is paid; any payment clears it.
184225 let declined = row.as_ref().and_then(|row| row.autopay_failed_at.clone().map(|at| (at, row.autopay_error.clone())));
185− let ceiling = match (trust_ceiling, spend_limit) {
186− (Some(ceiling), Some(own)) => Some(ceiling.min(own)),
187− (None, Some(own)) => Some(own),
188− (ceiling, None) => ceiling,
226+ // Two limits: g1t's on what is unpaid, the owners' on what is spent.
227+ let ceiling = trust_ceiling;
228+ let risk = state(exposure, ceiling);
229+ let budget = state(spent, spend_limit);
230+ let over_budget = budget == LimitState::Stopped;
231+ let state = if declined.is_some() && exposure > 0 {
232+ LimitState::Stopped
233+ } else if risk == LimitState::Stopped || over_budget {
234+ LimitState::Stopped
235+ } else if risk == LimitState::Warning || budget == LimitState::Warning {
236+ LimitState::Warning
237+ } else {
238+ LimitState::Ok
189239 };
190− let state = if declined.is_some() && exposure > 0 { LimitState::Stopped } else { state(exposure, ceiling) };
191240 let who = if account.kind == g1t_contracts::billing::AccountKind::Enterprise {
192241 format!("The {} enterprise, which pays for {workspace},", account.name)
193242 } else {
195244 };
196245 let message = match state {
197246 LimitState::Ok => None,
247+ LimitState::Warning if budget == LimitState::Warning => Some(format!(
248+ "{who} has spent {} of its {} monthly spend limit. At the limit, its sandboxes, builds and apps stop until the month turns or an owner raises it under Billing.",
249+ dollars_plain(spent),
250+ dollars_plain(spend_limit.unwrap_or_default()),
251+ )),
198252 LimitState::Warning => Some(format!(
199− "{who} has used {} of its {} limit this month. At the limit, its sandboxes, builds and apps stop until it pays or the month turns.",
253+ "{who} has {} of usage not yet paid for, of the {} g1t allows. With a card on file g1t charges it now; without one, at the limit its sandboxes, builds and apps stop until it pays.",
200254 dollars_plain(exposure),
201255 dollars_plain(ceiling.unwrap_or_default()),
202256 )),
204258 "{who} could not be charged for its usage ({}), so its sandboxes, builds and apps are stopped. An owner can pay under Billing with another card.",
205259 declined.as_ref().and_then(|(_, error)| error.clone()).unwrap_or_else(|| "the card was declined".to_owned()),
206260 )),
207− LimitState::Stopped => Some(if spend_limit.is_some() && ceiling == spend_limit {
261+ LimitState::Stopped => Some(if over_budget {
208262 format!(
209− "The {workspace} workspace reached the {} spend limit its owners set for this month, so its sandboxes, builds and apps are stopped. An owner can raise it under Billing.",
210− dollars_plain(ceiling.unwrap_or_default()),
263+ "{who} reached its {} monthly spend limit, so its sandboxes, builds and apps are stopped until the month turns. An owner can raise it under Billing.",
264+ dollars_plain(spend_limit.unwrap_or_default()),
211265 )
212266 } else {
213267 format!(
216270 )
217271 }),
218272 };
273+ let growth = match trust {
274+ Trust::New => Some("Pay g1t once, by card or credit, and this grows to $25; after that it grows with every payment.".to_owned()),
275+ Trust::Paid => Some(format!(
276+ "Grows to twice what you have paid, as payments clear (after {SETTLE_DAYS} days), up to $1,000. After three steady months it follows your monthly spend, up to $10,000, by itself."
277+ )),
278+ Trust::Established => Some("Follows your monthly spend, up to $10,000, by itself. For more, contact us.".to_owned()),
279+ Trust::Reviewed | Trust::Internal => None,
280+ };
219281 Ok(Limit {
220282 workspace,
221283 account: account.id,
222284 account_name: account.name,
285+ spent_micros: spent,
286+ default_spend_limit,
287+ available_micros: trust_ceiling,
288+ growth,
223289 trust,
224290 exposure_micros: exposure,
225291 ceiling_micros: ceiling,
241307 .db
242308 .prepare(format!(
243309 "SELECT SUM(amount_micros) AS paid FROM ledger
244− WHERE workspace IN ({marks}) AND kind = 'top_up' AND reference NOT LIKE 'crd%'"
310+ WHERE workspace IN ({marks}) AND kind = 'top_up' AND reference NOT LIKE 'crd%'
311+ AND (amount_micros < 0
312+ OR (disputed = 0 AND COALESCE(funding, '') <> 'prepaid'
313+ AND created_at <= '{settled}'))",
314+ settled = rfc3339(now_ms() - SETTLE_DAYS * 24 * 60 * 60 * 1000)
245315 ))
246316 .bind(members)?
247317 .first::<Paid>(None)
250320 .unwrap_or(0))
251321 }
252322
323+ /// This month's charges and last month's, across the workspaces.
324+ async fn charged_months(&self, members: &[JsValue], month_start: &str) -> Result<(i64, i64)> {
325+ #[derive(Deserialize)]
326+ struct Charged {
327+ this_month: Option<i64>,
328+ last_month: Option<i64>,
329+ }
330+ let last_start = format!("{}-01", previous_month(&month_start[..7]));
331+ let marks = vec!["?"; members.len().max(1)].join(", ");
332+ let row = self
333+ .db
334+ .prepare(format!(
335+ "SELECT
336+ -SUM(CASE WHEN created_at >= '{month_start}' THEN amount_micros END) AS this_month,
337+ -SUM(CASE WHEN created_at >= '{last_start}' AND created_at < '{month_start}' THEN amount_micros END) AS last_month
338+ FROM ledger WHERE kind = 'usage' AND workspace IN ({marks}) AND created_at >= '{last_start}'"
339+ ))
340+ .bind(members)?
341+ .first::<Charged>(None)
342+ .await?;
343+ Ok(row.map_or((0, 0), |r| (r.this_month.unwrap_or(0).max(0), r.last_month.unwrap_or(0).max(0))))
344+ }
345+
346+ /// An Established ceiling, if the workspaces have paid steadily: three
347+ /// full months of real spend, each invoiced and paid, nothing declined
348+ /// in 90 days and nothing ever disputed.
349+ async fn established(&self, members: &[JsValue]) -> Result<Option<i64>> {
350+ let marks = vec!["?"; members.len().max(1)].join(", ");
351+ let now = rfc3339(now_ms());
352+ let mut months = vec![];
353+ let mut month = previous_month(&now[..7]);
354+ for _ in 0..3 {
355+ months.push(month.clone());
356+ month = previous_month(&month);
357+ }
358+ #[derive(Deserialize)]
359+ struct Count {
360+ n: Option<i64>,
361+ }
362+ let troubled = self
363+ .db
364+ .prepare(format!(
365+ "SELECT (SELECT COUNT(*) FROM ledger WHERE workspace IN ({marks}) AND disputed = 1)
366+ + (SELECT COUNT(*) FROM limits WHERE workspace IN ({marks}) AND autopay_failed_at >= '{since}') AS n",
367+ since = rfc3339(now_ms() - 90 * 24 * 60 * 60 * 1000)
368+ ))
369+ .bind(&[members, members].concat())?
370+ .first::<Count>(None)
371+ .await?
372+ .and_then(|c| c.n)
373+ .unwrap_or(0);
374+ if troubled > 0 {
375+ return Ok(None);
376+ }
377+ let mut charged = vec![];
378+ for month in &months {
379+ #[derive(Deserialize)]
380+ struct Month {
381+ charged: Option<i64>,
382+ unpaid: Option<i64>,
383+ }
384+ let next = {
385+ let year: i32 = month[..4].parse().unwrap_or(1970);
386+ let number: u32 = month[5..7].parse().unwrap_or(1);
387+ if number == 12 { format!("{}-01", year + 1) } else { format!("{year}-{:02}", number + 1) }
388+ };
389+ let row = self
390+ .db
391+ .prepare(format!(
392+ "SELECT
393+ (SELECT -SUM(amount_micros) FROM ledger WHERE kind = 'usage' AND workspace IN ({marks})
394+ AND created_at >= '{month}-01' AND created_at < '{next}-01') AS charged,
395+ (SELECT COUNT(*) FROM workspace_invoices WHERE workspace IN ({marks}) AND reason = 'month'
396+ AND period = '{month}' AND status <> 'paid') AS unpaid"
397+ ))
398+ .bind(&[members, members].concat())?
399+ .first::<Month>(None)
400+ .await?;
401+ let Some(row) = row else { return Ok(None) };
402+ if row.unpaid.unwrap_or(0) > 0 {
403+ return Ok(None);
404+ }
405+ charged.push(row.charged.unwrap_or(0));
406+ }
407+ Ok(established_ceiling(&charged))
408+ }
409+
253410 /// A refusal, with the reason, when the workspace's work is stopped.
254411 /// None while billing is off: a g1t without payments has no limits.
255412 pub(crate) async fn stopped<T>(&self, workspace: &str) -> Result<Option<Outcome<T>>> {
299456 /// nothing. Not for a workspace's own spend limit, which means stop, nor
300457 /// for enterprises, which are invoiced.
301458 pub(crate) async fn autopay(&self) -> Result<()> {
302− let Some(stripe) = self.stripe.as_ref().filter(|stripe| stripe.live()) else {
459+ if !self.stripe.as_ref().is_some_and(crate::stripe::Stripe::live) {
303460 return Ok(());
304− };
461+ }
305462 #[derive(Deserialize)]
306463 struct Candidate {
307464 workspace: String,
308− customer_id: Option<String>,
309465 }
310466 let month_start = format!("{}-01", &rfc3339(now_ms())[..7]);
467+ // With a card, and not already declined: a declined card waits for
468+ // the owners, rather than being tried again every few minutes.
311469 let candidates = self
312470 .db
313471 .prepare(
314− "SELECT DISTINCT ledger.workspace AS workspace, accounts.customer_id AS customer_id
472+ "SELECT DISTINCT ledger.workspace AS workspace
315473 FROM ledger JOIN accounts ON accounts.workspace = ledger.workspace
316− WHERE ledger.kind = 'usage' AND ledger.created_at >= ? AND accounts.customer_id IS NOT NULL",
474+ LEFT JOIN limits ON limits.workspace = ledger.workspace
475+ WHERE ledger.kind = 'usage' AND ledger.created_at >= ? AND accounts.customer_id IS NOT NULL
476+ AND limits.autopay_failed_at IS NULL",
317477 )
318478 .bind(&[month_start.as_str().into()])?
319479 .all()
320480 .await?
321481 .results::<Candidate>()?;
322482 for candidate in candidates {
323− let Some(customer) = candidate.customer_id else { continue };
324483 let limit = self.limit_of(&candidate.workspace).await?;
325− let own_limit = limit.spend_limit_micros.is_some() && limit.ceiling_micros == limit.spend_limit_micros;
326− if limit.state == LimitState::Ok
327− || own_limit
328− || limit.trust == Trust::Internal
329− || limit.account.starts_with("ent_")
330− {
331− continue;
332− }
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 {
484+ // Near g1t's ceiling on what is unpaid; the spend limit is the
485+ // owners' and stops work by itself, but what is owed is still owed.
486+ let near = limit.ceiling_micros.is_some_and(|ceiling| limit.exposure_micros * 5 >= ceiling * 4);
487+ if !near || limit.trust == Trust::Internal || limit.account.starts_with("ent_") {
339488 continue;
340489 }
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);
343− let description = format!("g1t usage for {}, paid automatically near its limit", candidate.workspace);
344− let now = rfc3339(now_ms());
345− match stripe.charge_saved_card(&customer, cents, &description, &key).await {
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− }
357− self.enter(
358− &candidate.workspace,
359− g1t_contracts::billing::EntryKind::TopUp,
360− payment.amount_received.max(cents) * 10_000,
361− &format!("Paid automatically by card, near the {} limit", dollars_plain(limit.ceiling_micros.unwrap_or_default())),
362− &payment.id,
363− None,
364− None,
365− None,
366− Some(&customer),
367− )
368− .await?;
369− self.db
370− .prepare("UPDATE limits SET autopay_failed_at = NULL, autopay_error = NULL WHERE workspace = ?")
371− .bind(&[candidate.workspace.as_str().into()])?
372− .run()
373− .await?;
374− }
375− outcome => {
376− let error = match outcome {
377− Ok(payment) => format!("the payment is {}", payment.status.replace('_', " ")),
378− Err(error) => error.to_string().chars().take(200).collect(),
379− };
380− self.db
381− .prepare(
382− "INSERT INTO limits (workspace, autopay_failed_at, autopay_error, updated_at) VALUES (?1, ?2, ?3, ?2)
383− ON CONFLICT (workspace) DO UPDATE SET autopay_failed_at = ?2, autopay_error = ?3, updated_at = ?2",
384− )
385− .bind(&[candidate.workspace.as_str().into(), now.as_str().into(), error.as_str().into()])?
386− .run()
387− .await?;
388− }
490+ // An invoice for what it owes, charged to its card now: never
491+ // the cost of what was free to it.
492+ let today = rfc3339(now_ms())[..10].to_owned();
493+ match self.invoice_workspace(&candidate.workspace, "threshold", &today).await? {
494+ Ok(_) => {}
495+ Err(why) => worker::console_log!("{}: no threshold invoice: {why}", candidate.workspace),
389496 }
390497 }
391498 Ok(())
396503 /// workspace and month; a declined card stops work until it is paid.
397504 /// Comped workspaces owe nothing, and enterprises are invoiced.
398505 pub(crate) async fn close_months(&self) -> Result<()> {
399− let Some(stripe) = self.stripe.as_ref().filter(|stripe| stripe.live()) else {
506+ if !self.stripe.as_ref().is_some_and(crate::stripe::Stripe::live) {
400507 return Ok(());
401− };
508+ }
402509 let now = rfc3339(now_ms());
403510 let month_start = format!("{}-01", &now[..7]);
404511 let closing = previous_month(&now[..7]);
405512 #[derive(Deserialize)]
406513 struct Open {
407514 workspace: String,
408− customer_id: String,
409515 balance: Option<i64>,
410516 }
411517 let open = self
412518 .db
413519 .prepare(
414− "SELECT accounts.workspace AS workspace, accounts.customer_id AS customer_id,
520+ "SELECT accounts.workspace AS workspace,
415521 (SELECT SUM(amount_micros) FROM ledger
416522 WHERE ledger.workspace = accounts.workspace AND ledger.created_at < ?1) AS balance
417523 FROM accounts
452558 record("nothing", 0, None, None)?.run().await?;
453559 continue;
454560 }
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?;
561+ match self.invoice_workspace(&account.workspace, "month", &closing).await? {
562+ Ok(invoice) if invoice.status == "paid" => {
563+ record("paid", invoice.amount_micros, Some(&invoice.invoice_id), None)?.run().await?;
481564 }
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?;
565+ Ok(invoice) => {
566+ record("failed", invoice.amount_micros, Some(&invoice.invoice_id), Some("the card was declined"))?.run().await?;
567+ }
568+ Err(why) => {
569+ record("nothing", 0, None, Some(&why))?.run().await?;
496570 }
497571 }
498572 }
614688 if a.spend_limit_micros.is_some_and(|limit| limit < 0) {
615689 return Ok(Outcome::fail(FailureCode::Invalid, "A spend limit cannot be negative."));
616690 }
617− let limit = a.spend_limit_micros.map_or(JsValue::NULL, |limit| (limit as f64).into());
691+ let limit = if a.use_full_limit { JsValue::NULL } else { a.spend_limit_micros.map_or(JsValue::NULL, |limit| (limit as f64).into()) };
618692 self.db
619693 .prepare(
620− "INSERT INTO limits (workspace, spend_limit_micros, updated_at) VALUES (?1, ?2, ?3)
621− ON CONFLICT (workspace) DO UPDATE SET spend_limit_micros = ?2, updated_at = ?3",
694+ "INSERT INTO limits (workspace, spend_limit_micros, spend_limit_full, updated_at) VALUES (?1, ?2, ?3, ?4)
695+ ON CONFLICT (workspace) DO UPDATE SET spend_limit_micros = ?2, spend_limit_full = ?3, updated_at = ?4",
622696 )
623− .bind(&[workspace.as_str().into(), limit, rfc3339(now_ms()).into()])?
697+ .bind(&[workspace.as_str().into(), limit, (if a.use_full_limit { 1 } else { 0 }).into(), rfc3339(now_ms()).into()])?
624698 .run()
625699 .await?;
626700 Ok(Outcome::Ok(self.limit_of(&workspace).await?))
675749 use super::*;
676750
677751 #[test]
752+ fn the_automatic_spend_limit_follows_last_month() {
753+ assert_eq!(automatic_spend_limit(0), 200_000_000);
754+ assert_eq!(automatic_spend_limit(50_000_000), 200_000_000);
755+ assert_eq!(automatic_spend_limit(900_000_000), 1_800_000_000);
756+ }
757+
758+ #[test]
759+ fn three_steady_months_make_a_workspace_established() {
760+ assert_eq!(established_ceiling(&[900_000_000, 850_000_000, 950_000_000]), Some(2_700_000_000));
761+ assert_eq!(established_ceiling(&[5_000_000_000, 5_000_000_000, 5_000_000_000]), Some(10_000_000_000));
762+ assert_eq!(established_ceiling(&[900_000_000, 10_000_000, 950_000_000]), None);
763+ assert_eq!(established_ceiling(&[900_000_000, 900_000_000]), None);
764+ }
765+
766+ #[test]
678767 fn warnings_come_at_half_four_fifths_and_the_limit() {
679768 assert_eq!(warning_level(0, 300), 0);
680769 assert_eq!(warning_level(149, 300), 0);
+451−0
1+//! What g1t's team needs to sell and support: month-by-month figures, the
2+//! signals that say a workspace is worth a call, and what was done about
3+//! it. Staff only, through sudo.g1t.sh.
4+
5+use g1t_contracts::billing::{
6+ AdminAddNoteArgs, AdminOverviewArgs, AdminSalesArgs, AdminSetSalesArgs, AdminSignalsArgs, KindFigures,
7+ LimitState, MonthFigures, Overview, SalesNote, SalesRecord, Signal, SignalKind, TermsKind, Trust,
8+};
9+use g1t_contracts::time::rfc3339;
10+use g1t_contracts::{FailureCode, Outcome, new_id};
11+use g1t_kit::now_ms;
12+use serde::Deserialize;
13+use worker::Result;
14+use worker::wasm_bindgen::JsValue;
15+
16+use crate::Billing;
17+use crate::features::dollars;
18+use crate::limits::previous_month;
19+
20+pub(crate) const STAGES: &[&str] = &["none", "lead", "contacted", "negotiating", "won", "lost", "churn_risk"];
21+
22+/// A workspace this much ahead of last month's pace is growing.
23+const GROWING_FACTOR: f64 = 1.5;
24+/// Below this last month, growth is noise.
25+const GROWING_FROM_MICROS: i64 = 10_000_000;
26+/// Spending this much a month may suit custom terms or an enterprise.
27+const HIGH_SPEND_MICROS: i64 = 500_000_000;
28+
29+/// The six months ending with `month`, oldest first.
30+pub(crate) fn last_months(month: &str, count: usize) -> Vec<String> {
31+ let mut months = vec![month.to_owned()];
32+ while months.len() < count {
33+ let earlier = previous_month(months.last().unwrap());
34+ months.push(earlier);
35+ }
36+ months.reverse();
37+ months
38+}
39+
40+/// How urgent a kind of signal is: lower first.
41+fn urgency(kind: SignalKind) -> u8 {
42+ match kind {
43+ SignalKind::AtLimit => 0,
44+ SignalKind::Declined => 1,
45+ SignalKind::NearCeiling => 2,
46+ SignalKind::HighSpend => 3,
47+ SignalKind::Growing => 4,
48+ SignalKind::Established => 5,
49+ SignalKind::FirstPayment => 6,
50+ }
51+}
52+
53+/// Whether this month, at its pace so far, is well ahead of last month.
54+pub(crate) fn growing(this_month: i64, last_month: i64, day: u32, days_in_month: u32) -> bool {
55+ if last_month < GROWING_FROM_MICROS || day == 0 {
56+ return false;
57+ }
58+ let pace = this_month as f64 * f64::from(days_in_month) / f64::from(day);
59+ pace >= last_month as f64 * GROWING_FACTOR
60+}
61+
62+fn days_in(month: &str) -> u32 {
63+ let year: i32 = month[..4].parse().unwrap_or(1970);
64+ match month[5..7].parse::<u32>().unwrap_or(1) {
65+ 2 if (year % 4 == 0 && year % 100 != 0) || year % 400 == 0 => 29,
66+ 2 => 28,
67+ 4 | 6 | 9 | 11 => 30,
68+ _ => 31,
69+ }
70+}
71+
72+#[derive(Deserialize)]
73+struct MonthRow {
74+ month: String,
75+ charged: Option<i64>,
76+ cost: Option<i64>,
77+ paid: Option<i64>,
78+}
79+
80+#[derive(Deserialize)]
81+struct RecordRow {
82+ stage: String,
83+ owner: Option<String>,
84+ next_step: Option<String>,
85+ next_at: Option<String>,
86+ updated_at: String,
87+}
88+
89+#[derive(Deserialize)]
90+struct NoteRow {
91+ id: String,
92+ text: String,
93+ by: String,
94+ created_at: String,
95+}
96+
97+impl Billing {
98+ /// Month-by-month figures for some workspaces (all, when empty).
99+ pub(crate) async fn months_for(&self, workspaces: &[String], count: usize) -> Result<Vec<MonthFigures>> {
100+ let months = last_months(&rfc3339(now_ms())[..7], count);
101+ let since = format!("{}-01", months[0]);
102+ let (filter, values): (String, Vec<JsValue>) = if workspaces.is_empty() {
103+ (String::new(), vec![])
104+ } else {
105+ let marks = vec!["?"; workspaces.len()].join(", ");
106+ (format!("AND workspace IN ({marks})"), workspaces.iter().map(|w| JsValue::from(w.as_str())).collect())
107+ };
108+ let rows = self
109+ .db
110+ .prepare(format!(
111+ "SELECT substr(created_at, 1, 7) AS month,
112+ -SUM(CASE WHEN kind = 'usage' THEN amount_micros END) AS charged,
113+ SUM(CASE WHEN kind = 'usage' THEN cost_micros END) AS cost,
114+ SUM(CASE WHEN kind = 'top_up' AND reference NOT LIKE 'crd%' THEN amount_micros END) AS paid
115+ FROM ledger WHERE created_at >= '{since}' {filter} GROUP BY 1"
116+ ))
117+ .bind(&values)?
118+ .all()
119+ .await?
120+ .results::<MonthRow>()?;
121+ Ok(months
122+ .into_iter()
123+ .map(|month| {
124+ let row = rows.iter().find(|r| r.month == month);
125+ MonthFigures {
126+ charged_micros: row.and_then(|r| r.charged).unwrap_or(0),
127+ cost_micros: row.and_then(|r| r.cost).unwrap_or(0),
128+ paid_micros: row.and_then(|r| r.paid).unwrap_or(0),
129+ month,
130+ }
131+ })
132+ .collect())
133+ }
134+
135+ async fn record_row(&self, workspace: &str) -> Result<Option<RecordRow>> {
136+ self.db
137+ .prepare("SELECT * FROM sales_records WHERE workspace = ?")
138+ .bind(&[workspace.into()])?
139+ .first::<RecordRow>(None)
140+ .await
141+ }
142+
143+ pub(crate) async fn admin_sales(&self, a: AdminSalesArgs) -> Result<SalesRecord> {
144+ let workspace = a.workspace.trim().to_lowercase();
145+ let row = self.record_row(&workspace).await?;
146+ let notes = self
147+ .db
148+ .prepare("SELECT id, text, by, created_at FROM sales_notes WHERE workspace = ? ORDER BY created_at DESC LIMIT 100")
149+ .bind(&[workspace.as_str().into()])?
150+ .all()
151+ .await?
152+ .results::<NoteRow>()?
153+ .into_iter()
154+ .map(|n| SalesNote { id: n.id, text: n.text, by: n.by, created_at: n.created_at })
155+ .collect();
156+ Ok(match row {
157+ Some(row) => SalesRecord {
158+ workspace,
159+ stage: row.stage,
160+ owner: row.owner,
161+ next_step: row.next_step,
162+ next_at: row.next_at,
163+ notes,
164+ updated_at: Some(row.updated_at),
165+ },
166+ None => SalesRecord {
167+ workspace,
168+ stage: "none".to_owned(),
169+ owner: None,
170+ next_step: None,
171+ next_at: None,
172+ notes,
173+ updated_at: None,
174+ },
175+ })
176+ }
177+
178+ pub(crate) async fn admin_set_sales(&self, a: AdminSetSalesArgs) -> Result<Outcome<SalesRecord>> {
179+ let workspace = a.workspace.trim().to_lowercase();
180+ if workspace.is_empty() || a.by.trim().is_empty() {
181+ return Ok(Outcome::fail(FailureCode::Invalid, "Name the workspace, and who is making the change."));
182+ }
183+ if !STAGES.contains(&a.stage.as_str()) {
184+ return Ok(Outcome::fail(FailureCode::Invalid, format!("A stage is one of: {}.", STAGES.join(", "))));
185+ }
186+ let clean = |value: &Option<String>| value.as_deref().map(str::trim).filter(|v| !v.is_empty()).map(str::to_owned);
187+ let (owner, next_step, next_at) = (clean(&a.owner), clean(&a.next_step), clean(&a.next_at));
188+ let before = self.record_row(&workspace).await?;
189+ self.db
190+ .prepare(
191+ "INSERT INTO sales_records (workspace, stage, owner, next_step, next_at, updated_by, updated_at)
192+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
193+ ON CONFLICT (workspace) DO UPDATE SET stage = ?2, owner = ?3, next_step = ?4, next_at = ?5,
194+ updated_by = ?6, updated_at = ?7",
195+ )
196+ .bind(&[
197+ workspace.as_str().into(),
198+ a.stage.as_str().into(),
199+ crate::optional(owner.as_deref()),
200+ crate::optional(next_step.as_deref()),
201+ crate::optional(next_at.as_deref()),
202+ a.by.as_str().into(),
203+ rfc3339(now_ms()).into(),
204+ ])?
205+ .run()
206+ .await?;
207+ let account = self.account_of(&workspace).await?;
208+ let was = before.map_or_else(|| "none".to_owned(), |b| b.stage);
209+ let detail = format!(
210+ "{workspace}: stage {was} → {}{}{}",
211+ a.stage,
212+ owner.as_deref().map(|o| format!(", owner {o}")).unwrap_or_default(),
213+ next_step.as_deref().map(|s| format!(", next: {s}")).unwrap_or_default(),
214+ );
215+ self.audit(&account.id, "sales", &detail, &a.by).await?;
216+ Ok(Outcome::Ok(self.admin_sales(AdminSalesArgs { workspace }).await?))
217+ }
218+
219+ pub(crate) async fn admin_add_note(&self, a: AdminAddNoteArgs) -> Result<Outcome<SalesRecord>> {
220+ let workspace = a.workspace.trim().to_lowercase();
221+ let text = a.text.trim();
222+ if workspace.is_empty() || text.is_empty() || a.by.trim().is_empty() {
223+ return Ok(Outcome::fail(FailureCode::Invalid, "A note needs a workspace, some words, and who wrote it."));
224+ }
225+ if text.chars().count() > 4000 {
226+ return Ok(Outcome::fail(FailureCode::Invalid, "Keep a note under 4,000 characters."));
227+ }
228+ let now = now_ms();
229+ self.db
230+ .prepare("INSERT INTO sales_notes (id, workspace, text, by, created_at) VALUES (?, ?, ?, ?, ?)")
231+ .bind(&[new_id("note", now).into(), workspace.as_str().into(), text.into(), a.by.as_str().into(), rfc3339(now).into()])?
232+ .run()
233+ .await?;
234+ Ok(Outcome::Ok(self.admin_sales(AdminSalesArgs { workspace }).await?))
235+ }
236+
237+ /// Every workspace worth reaching out to, most urgent first.
238+ pub(crate) async fn admin_signals(&self, _: AdminSignalsArgs) -> Result<Vec<Signal>> {
239+ let now = rfc3339(now_ms());
240+ let month = &now[..7];
241+ let last = previous_month(month);
242+ #[derive(Deserialize)]
243+ struct Active {
244+ workspace: String,
245+ this_month: Option<i64>,
246+ last_month: Option<i64>,
247+ first_paid: Option<String>,
248+ }
249+ let active = self
250+ .db
251+ .prepare(
252+ "SELECT workspace,
253+ -SUM(CASE WHEN kind = 'usage' AND created_at >= ?1 THEN amount_micros END) AS this_month,
254+ -SUM(CASE WHEN kind = 'usage' AND created_at >= ?2 AND created_at < ?1 THEN amount_micros END) AS last_month,
255+ MIN(CASE WHEN kind = 'top_up' AND amount_micros > 0 AND reference NOT LIKE 'crd%' THEN created_at END) AS first_paid
256+ FROM ledger GROUP BY workspace
257+ HAVING MAX(created_at) >= ?2
258+ LIMIT 500",
259+ )
260+ .bind(&[format!("{month}-01").into(), format!("{last}-01").into()])?
261+ .all()
262+ .await?
263+ .results::<Active>()?;
264+ let day: u32 = now[8..10].parse().unwrap_or(1);
265+ let fortnight_ago = rfc3339(now_ms() - 14 * 24 * 60 * 60 * 1000);
266+ let mut signals = vec![];
267+ for row in active {
268+ let limit = self.limit_of(&row.workspace).await?;
269+ if limit.trust == Trust::Internal {
270+ continue;
271+ }
272+ let this_month = row.this_month.unwrap_or(0).max(0);
273+ let last_month = row.last_month.unwrap_or(0).max(0);
274+ let record = self.record_row(&row.workspace).await?;
275+ let (stage, owner) = record.map_or((None, None), |r| (Some(r.stage), r.owner));
276+ let mut push = |kind: SignalKind, detail: String, value: i64| {
277+ signals.push(Signal {
278+ workspace: row.workspace.clone(),
279+ kind,
280+ detail,
281+ value_micros: value,
282+ stage: stage.clone(),
283+ owner: owner.clone(),
284+ });
285+ };
286+ let declined = limit.message.as_deref().is_some_and(|m| m.contains("could not be charged"));
287+ if declined {
288+ push(SignalKind::Declined, limit.message.clone().unwrap_or_default(), limit.exposure_micros);
289+ } else if limit.state == LimitState::Stopped {
290+ push(SignalKind::AtLimit, limit.message.clone().unwrap_or_default(), limit.exposure_micros.max(limit.spent_micros));
291+ } else if let Some(available) = limit.available_micros.filter(|a| *a > 0) {
292+ if limit.exposure_micros * 5 >= available * 4 {
293+ push(
294+ SignalKind::NearCeiling,
295+ format!(
296+ "{} unpaid of the {} g1t allows it ({:?}); a call could raise it before it stops.",
297+ dollars(limit.exposure_micros),
298+ dollars(available),
299+ limit.trust
300+ ),
301+ limit.exposure_micros,
302+ );
303+ }
304+ }
305+ if last_month >= HIGH_SPEND_MICROS {
306+ let terms = self.terms_of(&row.workspace).await?;
307+ if terms.kind == TermsKind::Standard && !limit.account.starts_with("ent_") {
308+ push(
309+ SignalKind::HighSpend,
310+ format!("Spent {} last month on standard terms: worth offering custom terms or an enterprise.", dollars(last_month)),
311+ last_month,
312+ );
313+ }
314+ }
315+ if growing(this_month, last_month, day, days_in(month)) {
316+ push(
317+ SignalKind::Growing,
318+ format!(
319+ "{} so far this month, on pace for about {}, against {} last month.",
320+ dollars(this_month),
321+ dollars((this_month as f64 * f64::from(days_in(month)) / f64::from(day.max(1))) as i64),
322+ dollars(last_month)
323+ ),
324+ this_month,
325+ );
326+ }
327+ if limit.trust == Trust::Established {
328+ push(
329+ SignalKind::Established,
330+ format!(
331+ "A steady customer: its limit now follows its spend ({} available).",
332+ limit.available_micros.map(dollars).unwrap_or_default()
333+ ),
334+ last_month,
335+ );
336+ }
337+ if row.first_paid.as_deref().is_some_and(|at| at >= fortnight_ago.as_str()) {
338+ push(SignalKind::FirstPayment, "Paid g1t for the first time in the last two weeks: say hello.".to_owned(), this_month);
339+ }
340+ }
341+ signals.sort_by(|a, b| urgency(a.kind).cmp(&urgency(b.kind)).then(b.value_micros.cmp(&a.value_micros)));
342+ Ok(signals)
343+ }
344+
345+ /// The business at a glance.
346+ pub(crate) async fn admin_overview(&self, _: AdminOverviewArgs) -> Result<Overview> {
347+ let now = rfc3339(now_ms());
348+ let month = now[..7].to_owned();
349+ let months = self.months_for(&[], 6).await?;
350+ #[derive(Deserialize)]
351+ struct KindRow {
352+ kind: Option<String>,
353+ charged: Option<i64>,
354+ cost: Option<i64>,
355+ }
356+ let by_kind = self
357+ .db
358+ .prepare(
359+ "SELECT CASE
360+ WHEN task = 'sandbox' THEN 'Sandbox time'
361+ WHEN task = 'deployments' THEN 'Deployments'
362+ WHEN billed_to = 'workspace' THEN 'Own-provider runs'
363+ ELSE 'Models' END AS kind,
364+ -SUM(amount_micros) AS charged, SUM(cost_micros) AS cost
365+ FROM ledger WHERE kind = 'usage' AND created_at >= ? GROUP BY 1 ORDER BY charged DESC",
366+ )
367+ .bind(&[format!("{month}-01").into()])?
368+ .all()
369+ .await?
370+ .results::<KindRow>()?
371+ .into_iter()
372+ .map(|r| KindFigures {
373+ kind: r.kind.unwrap_or_else(|| "Other".to_owned()),
374+ charged_micros: r.charged.unwrap_or(0),
375+ cost_micros: r.cost.unwrap_or(0),
376+ })
377+ .collect();
378+ #[derive(Deserialize)]
379+ struct Count {
380+ n: Option<i64>,
381+ }
382+ let count = |sql: &'static str, args: Vec<JsValue>| {
383+ let db = &self.db;
384+ async move {
385+ Ok::<i64, worker::Error>(db.prepare(sql).bind(&args)?.first::<Count>(None).await?.and_then(|c| c.n).unwrap_or(0))
386+ }
387+ };
388+ let paying = count(
389+ "SELECT COUNT(DISTINCT workspace) AS n FROM ledger
390+ WHERE kind = 'top_up' AND amount_micros > 0 AND reference NOT LIKE 'crd%' AND created_at >= ?",
391+ vec![rfc3339(now_ms() - 60 * 24 * 60 * 60 * 1000).into()],
392+ )
393+ .await?;
394+ let open_invoices = count(
395+ "SELECT (SELECT COALESCE(SUM(amount_micros), 0) FROM workspace_invoices WHERE status IN ('open', 'failed'))
396+ + (SELECT COALESCE(SUM(amount_micros), 0) FROM enterprise_invoices WHERE status IN ('open', 'overdue')) AS n",
397+ vec![],
398+ )
399+ .await?;
400+ let follow_ups = count(
401+ "SELECT COUNT(*) AS n FROM sales_records WHERE next_at IS NOT NULL AND next_at <= ? AND stage NOT IN ('won', 'lost')",
402+ vec![now[..10].into()],
403+ )
404+ .await?;
405+ let signals = self.admin_signals(AdminSignalsArgs {}).await?;
406+ let tally = |kind: SignalKind| signals.iter().filter(|s| s.kind == kind).count() as u32;
407+ Ok(Overview {
408+ month,
409+ months,
410+ by_kind,
411+ paying_workspaces: paying as u32,
412+ stopped: tally(SignalKind::AtLimit),
413+ near_ceiling: tally(SignalKind::NearCeiling),
414+ declined: tally(SignalKind::Declined),
415+ open_invoices_micros: open_invoices,
416+ follow_ups_due: follow_ups as u32,
417+ })
418+ }
419+}
420+
421+#[cfg(test)]
422+mod tests {
423+ use super::*;
424+
425+ #[test]
426+ fn six_months_end_with_this_one() {
427+ assert_eq!(last_months("2026-02", 3), vec!["2025-12", "2026-01", "2026-02"]);
428+ }
429+
430+ #[test]
431+ fn growth_is_judged_on_pace_not_on_the_month_so_far() {
432+ // Ten days in, $20 on a 30-day month is a $60 pace against $30.
433+ assert!(growing(20_000_000, 30_000_000, 10, 30));
434+ assert!(!growing(10_000_000, 30_000_000, 10, 30));
435+ // Too small last month to say.
436+ assert!(!growing(9_000_000, 1_000_000, 10, 30));
437+ }
438+
439+ #[test]
440+ fn the_most_urgent_comes_first() {
441+ assert!(urgency(SignalKind::AtLimit) < urgency(SignalKind::NearCeiling));
442+ assert!(urgency(SignalKind::Declined) < urgency(SignalKind::Growing));
443+ }
444+
445+ #[test]
446+ fn february_knows_its_leap_years() {
447+ assert_eq!(days_in("2028-02"), 29);
448+ assert_eq!(days_in("2026-02"), 28);
449+ assert_eq!(days_in("2026-10"), 31);
450+ }
451+}
+11−45
104104 }
105105
106106 /// `name=value` pairs as a form body.
107−/// A payment made with no one there.
108−#[derive(Debug, Deserialize)]
109−pub struct PaymentIntent {
110− pub id: String,
111− /// `succeeded`, or anything else when it did not go through.
112− pub status: String,
113− #[serde(default)]
114− pub amount_received: i64,
115−}
116−
117107 pub(crate) fn form(fields: &[(&str, String)]) -> String {
118108 fields
119109 .iter()
142132 self.call(Method::Post, path, Some(form(fields))).await
143133 }
144134
135+ /// A form POST that Stripe does at most once for `key`, however often
136+ /// it is sent.
137+ pub(crate) async fn post_idempotent<T: for<'a> Deserialize<'a>>(
138+ &self,
139+ path: &str,
140+ fields: &[(&str, String)],
141+ key: &str,
142+ ) -> Result<T> {
143+ self.send(Method::Post, path, Some(form(fields)), Some(key)).await
144+ }
145+
145146 pub(crate) async fn delete<T: for<'a> Deserialize<'a>>(&self, path: &str) -> Result<T> {
146147 self.call(Method::Delete, path, None).await
147148 }
185186 )));
186187 }
187188 response.json().await
188− }
189−
190− /// Charges the customer's saved card, with no one there: the automatic
191− /// payment at a workspace's limit. `key` makes a retry the same charge.
192− pub async fn charge_saved_card(
193− &self,
194− customer: &str,
195− amount_cents: i64,
196− description: &str,
197− key: &str,
198− ) -> Result<PaymentIntent> {
199− #[derive(Deserialize)]
200− struct Methods {
201− data: Vec<Method_>,
202− }
203− #[derive(Deserialize)]
204− struct Method_ {
205− id: String,
206− }
207− let methods: Methods = self
208− .call(Method::Get, &format!("/payment_methods?customer={}&type=card&limit=1", encode(customer)), None)
209− .await?;
210− let Some(card) = methods.data.first() else {
211− return Err(Error::RustError("no card on file".into()));
212− };
213− let fields = [
214− ("amount", amount_cents.to_string()),
215− ("currency", "usd".to_owned()),
216− ("customer", customer.to_owned()),
217− ("payment_method", card.id.clone()),
218− ("off_session", "true".to_owned()),
219− ("confirm", "true".to_owned()),
220− ("description", description.to_owned()),
221− ];
222− self.send(Method::Post, "/payment_intents", Some(form(&fields)), Some(key)).await
223189 }
224190
225191 /// A customer for a workspace that has none yet.
+17−3
273273 "invoice.paid" => {
274274 if let Some(subscription) = object["subscription"].as_str() {
275275 self.settle_subscription(subscription).await?
276+ } else if let Some(done) = self.workspace_invoice_paid(&text("id")).await? {
277+ done
276278 } else {
277279 self.enterprise_invoice_paid(&text("id")).await?
278280 }
279281 }
280− "invoice.payment_failed" => match object["subscription"].as_str() {
281− Some(subscription) => self.settle_subscription(subscription).await?,
282− None => "ignored: not a plan".to_owned(),
282+ "invoice.payment_failed" => match (object["subscription"].as_str(), object["metadata"]["g1t_workspace"].as_str()) {
283+ (Some(subscription), _) => self.settle_subscription(subscription).await?,
284+ (None, Some(workspace)) => {
285+ self.mark_declined(workspace, "the card was declined for an invoice").await?;
286+ format!("{workspace}: invoice payment failed; work stopped")
287+ }
288+ _ => "ignored: not g1t's".to_owned(),
283289 },
284290 "invoice.overdue" => self.enterprise_invoice_status(&text("id"), "overdue").await?,
285291 "invoice.voided" => self.enterprise_invoice_status(&text("id"), "void").await?,
455461 return Ok("ignored: not a workspace's customer".to_owned());
456462 };
457463 let now = rfc3339(now_ms());
464+ // The disputed payment never counts toward trust again.
465+ for reference in [charge["payment_intent"].as_str(), charge["invoice"].as_str()].into_iter().flatten() {
466+ self.db
467+ .prepare("UPDATE ledger SET disputed = ? WHERE workspace = ? AND reference = ?")
468+ .bind(&[(if stop { 1 } else { 0 }).into(), workspace.as_str().into(), reference.into()])?
469+ .run()
470+ .await?;
471+ }
458472 if stop {
459473 self.db
460474 .prepare(