flagon-io/g1t

public

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

Commit

Two limits, real invoices, trust that grows by itself, sales signals

Every workspace now has two limits. Its owners' monthly spend limit protects them from a surprise: automatic by default ($200, or twice last month's spend, so a steady $900-a-month customer gets $1,800 without asking), fixed, or none. g1t's ceiling on what is unpaid protects g1t: $3 new, twice what has cleared once paid (to $1,000), and Established after three steady months, following spend to $10,000 by itself. Only payments cleared for seven days count; prepaid cards, disputes, credits and test mode never raise it. Every charge is a real Stripe invoice, charged to the card: monthly, and mid-month as a workspace nears its ceiling. Itemised by kind of usage, with credit paid in advance and earlier arrears as lines, so the total is exactly what is owed; PDFs and receipts on Stripe, listed on Billing. For sudo: signals of who to reach out to (at limit, declined, near the ceiling, high spend, growing, Established, first payment), sales records with stages, owners, next steps and notes, an overview with six months of figures and usage by kind, and six-month figures per account. The app's top bars are 64px.

syntaqxcommitted Parent77f9c6eBrowse files
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(