flagon-io/g1t

public

Git for AI scale: a forge for thousands of agents working on the same code at once.

Commit

Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put

Pricing (what g1t pays plus 20%, never per seat): - g1t Team: $20 a month per workspace with $5 of usage included, 50 GB of private storage and a year of audit log. Comped accounts get it free. - Work on public repositories draws on a capped open-source pool ($10 a month, $1 per repository) before the workspace is billed. - Trials are $1 of usage per new workspace from a $40 monthly pool. - A month's close charges no less than $5; less carries over. A charge because a limit was reached always goes through. - Deployments includes 200 build minutes. - Private storage past 1 GB, context embeddings and security scans are metered at cost plus 20%; enterprise terms are on the pricing page. Browsing: - Signed-out visitors browse projects, Explore, search and profiles in the sidebar, with Sign in and Sign up in it. - A project, issue, pull request or workspace you cannot see answers the same whether it is private or missing, and says how to get in. - Sign-in redirects accept only same-origin paths. - The workspace you are in changes only when you choose one; opening a project elsewhere no longer switches it, so nothing flickers back.

syntaqxcommitted Parent135337eBrowse files
82 files+3033−5190/82 viewed
+6−6
139139 member of the workspace chooses to merge anyway.
140140
141141 Checks run in repositories of workspaces that can use g1t's agents: those
142−with [their own model provider](/guides/models/), and those on
143−[the free allowance](/guides/usage-and-billing/#the-free-allowance) of
144−g1t's hosted models.
142+with [their own model provider](/guides/models/), and those with
143+[trial credit](/guides/usage-and-billing/#trials) left on g1t's hosted
144+models.
145145
146146 ## Review
147147
234234 - **Milestones.**
235235 - **g1t agents for everyone.** g1t can put its own agents on an issue, each
236236 in a sandbox. A workspace that connects its own model provider can use
237− them today, and until October 22 every workspace gets a free $1 of agent
238− time on g1t's own models, no key needed.
239− See [the free allowance](/guides/usage-and-billing/#the-free-allowance).
237+ them today, and every new workspace gets $1 of trial credit, which pays
238+ for g1t's own models and sandboxes, no key needed.
239+ See [trials](/guides/usage-and-billing/#trials).
+6−5
123123 ## Who may run workflows
124124
125125 Workflows run in every workspace that can use g1t's agents: one with its
126−own [model provider](/guides/models/) connected, or one on
127−[the free allowance](/guides/usage-and-billing/#the-free-allowance) while
128−it lasts. Each job's sandbox is charged as
129−[sandbox time](/guides/usage-and-billing/#sandbox-time), past the free
130−minutes. Elsewhere a run is
126+own [model provider](/guides/models/) connected, or one with
127+[trial credit](/guides/usage-and-billing/#trials) left. Each job's sandbox
128+is charged as [sandbox time](/guides/usage-and-billing/#sandbox-time), from
129+the first second; on a public repository,
130+[g1t's open-source pool](/guides/usage-and-billing/#public-repositories-and-open-source)
131+pays for it first. Elsewhere a run is
131132 recorded with its jobs failed and the reason, and the Actions page says so
132133 before the first run.
133134
+6−0
5252 order, and a pull request's **Agent** panel shows the latest of what its
5353 runs did. Both link to the full log, filtered to the run.
5454
55+## How long it is kept
56+
57+The log can be read and exported back **30 days**, or **a year** on the
58+[Team plan](/guides/usage-and-billing/#plans). The Audit log page says
59+which applies. Entries older than a year are removed.
60+
5561 ## Export
5662
5763 **CSV** and **JSON** on the Audit log page download what the current
+13−11
273273 | 1 million requests | To all of the workspace's apps. |
274274 | 3 million CPU milliseconds | Time your code spends computing. Waiting on the network is not counted. |
275275 | 3 custom domains | Across the workspace's projects, with their certificates. A www/apex pair is two. |
276+| 200 build minutes | Every build's time, whether it succeeds or fails. About $0.25 of the plan's price at cost. |
276277
277−**Usage past that,** and **every build**, come out of the workspace's
278−[credit](/guides/usage-and-billing/#add-credit) at Cloudflare's price plus
279−20%:
278+**Usage past that** is charged at Cloudflare's price plus 20%:
280279
281280 | | Price |
282281 | --- | --- |
283−| A build | $0.0015 a minute, by the second, whether it succeeds or fails. Not part of the plan. |
282+| A build past the 200 minutes | $0.0015 a minute, by the second, whether it succeeds or fails. |
284283 | Each app past 10 | $0.024 a month |
285284 | Each million requests past 1 million | $0.36 |
286285 | Each million CPU milliseconds past 3 million | $0.024 |
287286 | Each custom domain past 3 | $0.12 a month, by the most the workspace had at once that month |
288287
289−Builds are charged when they finish. Usage past the allowance is charged
290−once, on the first sweep after the month ends, as one line: *Deployments in
291−2026-10 past the plan*.
288+Builds are recorded when they finish: the plan's minutes pay for them
289+first, and a build that runs past them is charged only for the seconds
290+past them. Other usage past the allowance is charged once, on the first
291+sweep after the month ends, as one line: *Deployments in 2026-10 past the
292+plan*.
292293
293294 An app that no one visits costs nothing beyond counting toward the 10. That
294295 is why previews come down when their pull request closes and after their
296297
297298 ### Seeing what you use
298299
299−- **Billing**, under the Deployments plan, shows this month's apps,
300− requests and CPU time against what the plan includes, and what builds
301− have cost. Requests and CPU time are counted from Cloudflare's analytics
300+- **Billing**, under the Deployments plan, shows this month's apps, build
301+ minutes, requests and CPU time against what the plan includes.
302+ Requests and CPU time are counted from Cloudflare's analytics
302303 every 10 minutes.
303304 - The **statement** on Billing lists every build (*Building acme/web to
304− production (48 s)*) and every month's usage past the plan.
305+ production (48 s), all of it included in the plan*) and every month's
306+ usage past the plan.
305307 - Each build's page shows how long it ran.
306308
307309 ## Turn it off
+4−4
259259
260260 **Who can.** Members of the project's workspace. Anyone else who mentions
261261 it gets a short reply saying only members can, and nothing starts. When
262−the workspace cannot run agents (for example, its free allowance is used
262+the workspace cannot run agents (for example, its trial credit is used
263263 up and it has no model provider of its own), g1t-agent replies with why.
264264
265265 Each comment starts one run at most; to ask again, write a new comment.
334334
335335 - g1t's agents, and the sandboxes that run acceptance checks and the merge
336336 queue, work in any workspace that has
337− [its own model provider](/guides/models/), and, until October 22, in any
338− workspace on its free $1 of g1t's own models. See
339− [the free allowance](/guides/usage-and-billing/#the-free-allowance).
337+ [its own model provider](/guides/models/), and in any workspace with
338+ trial credit left on g1t's own models. See
339+ [trials](/guides/usage-and-billing/#trials).
340340 - A run has two hours. After that its credentials expire and it can no
341341 longer push or report.
342342
+21−0
5353 everyone else it looks exactly like a repository that does not exist, both
5454 on the site and to git.
5555
56+On the site, an address you cannot see gives the same page either way, with
57+status 404:
58+
59+| You are | The page says |
60+| --- | --- |
61+| Signed out | **Sign in to see this project**: it may be private, or it may not exist. Signing in brings you back to the same address. |
62+| Signed in | **This project doesn't exist, or you don't have access to it**, which account you are signed in as, and a link to switch account. If you should have access, ask an owner of the workspace to add you. |
63+
64+Issues, pull requests and workspace pages work the same way. A profile
65+that does not exist says **No one goes by that name**, since profiles are
66+public.
67+
68+## Browsing without an account
69+
70+Public projects, Explore, Search and profiles are open to everyone, in the
71+same sidebar members use. Signed out, the sidebar has Explore and Search,
72+and in a project its Code, Issues, Pull requests, Agents and Workflows;
73+pages only members see, such as Deployments, Security and Settings, are
74+left out. **Sign in** and **Sign up** sit at the bottom, and both bring you
75+back to the page you were on.
76+
5677 ## Protected branches
5778
5879 A repository can protect its default branch under **Settings → Repository**. Pushing to
+3−3
77
88 - **g1t's hosted models.** g1t chooses the model for each kind of work, pays
99 the provider, and charges your workspace's credit what it cost plus a
10− margin. Until October 22 every workspace gets $1 of it free (see
11− [the free allowance](/guides/usage-and-billing/#the-free-allowance));
12− once payments go live, open to all.
10+ margin. Every new workspace gets $1 of trial credit that pays for it
11+ first (see [trials](/guides/usage-and-billing/#trials)); once payments
12+ go live, open to all.
1313 - **Your own providers.** Connect as many as you use, then choose, for each
1414 kind of work, which provider and model it runs on. Each provider bills
1515 you directly. Open to every workspace now.
+144−37
33 description: What g1t costs, how a workspace pays, the limits that keep unpaid usage in check, and enterprise billing.
44 ---
55
6−Hosting repositories, issues, pull requests, review and your own agent cost
7−nothing on g1t. What costs money is what g1t runs for you: its agents'
8−models, the sandboxes they and your checks run in, and deployed apps. Each
9−is charged at what it costs g1t plus a set markup, after it is used, to the
10−workspace that owns the repository. There is no seat price.
6+Hosting repositories, git, issues, pull requests, review, search and your
7+own agent cost nothing on g1t. Public repositories are free, and so is the
8+compute their agents use, up to a monthly budget g1t pays for. What costs
9+money is what g1t runs for you on private work: its agents' models, the
10+sandboxes they and your checks run in, deployed apps, and private storage
11+past the free amount. Each is charged at what it costs g1t plus 20%, after
12+it is used, to the workspace that owns the repository. There is no seat
13+price, ever.
1114
1215 Some features are paid for with a monthly plan the workspace turns on, and
1316 are never free, including while the rest of g1t is. See
1518
1619 ## Plans
1720
18−A plan turns on one paid feature for the whole workspace: a monthly price
19−that includes an allowance, with usage past it charged at cost plus 20%.
21+A plan is one flat price a month for the whole workspace, however many
22+people and agents are in it: never per person.
2023
2124 | Plan | Price | Includes each month |
2225 | --- | --- | --- |
23−| [Deployments](/guides/deployments/) | $5 a month | 10 apps up at once, 1 million requests, 3 million CPU milliseconds. Builds are charged by the second. |
26+| Team | $20 a month per workspace | $5 of usage credit, 50 GB of private storage, and a year of [audit log](/guides/audit-log/) |
27+| [Deployments](/guides/deployments/) | $5 a month | 10 apps up at once, 200 build minutes, 1 million requests, 3 million CPU milliseconds and 3 custom domains |
28+
29+Usage past what a plan includes is charged at cost plus 20%, as it is
30+without the plan.
31+
32+### Team
33+
34+| | Without Team | With Team |
35+| --- | --- | --- |
36+| Usage credit | None | $5 a month |
37+| Private repository storage | 1 GB | 50 GB |
38+| Audit log | 30 days | 1 year |
39+| People | Everyone, no seat price | Everyone, no seat price |
2440
41+The **usage credit** pays for the month's usage first, at cost plus 20%:
42+agents, sandbox time, builds, storage, search embeddings and security
43+scans. Past it, usage is charged as usual. It starts again on the 1st of
44+each month (UTC), and what is left unused does not carry over. Billing
45+shows how much of it this month's usage has drawn.
46+
47+### Turning a plan on
48+
2549 Only an owner can turn a plan on or off.
2650
2751 1. Open **Settings → Billing**, `g1t.sh/<workspace>/-/billing`.
28−2. Under **Plans**, choose **Turn on Deployments**, and pay on the card
29− page you are sent to.
52+2. Under **Plans**, choose **Turn on Team** (or **Turn on Deployments**),
53+ and pay on the card page you are sent to.
3054
3155 Back on Billing, the plan says **On** and when it renews; the card is kept
3256 and charged each month. **Turn off at the end of the period** ends the plan
33−on its renewal date, with nothing more charged after; **Keep Deployments**
34−takes that back. If a renewal payment fails, the plan says **Payment
35−failed** and the feature stops until it is paid.
57+on its renewal date, with nothing more charged after; **Keep Team** takes
58+that back. If a renewal payment fails, the plan says **Payment failed** and
59+the plan's features stop until it is paid.
3660
37−Plan usage past the allowance and builds are drawn from the workspace's
38−credit, so a workspace with a plan can add credit while agents are free.
61+A plan that says **Included, no charge** is on under terms g1t set with the
62+workspace, such as [comped](#enterprises-and-custom-terms) terms, with
63+nothing to pay or turn off.
3964
40−## The free allowance
65+## What is free, and what pays for it
4166
42−So anyone can try g1t's agents without a key of their own, every workspace
43−gets **$1 of model cost on g1t's own models**, free, until **October 22,
44−2026** (11:59 PM Pacific). That is roughly 10 to 25 agent runs: changes,
45−reviews and revisions. The allowance pays for the model part of those
46−runs; everything else, such as sandbox time, is charged as usual.
67+Everything free on g1t is paid for by something: a plan, or a fixed
68+budget g1t sets aside each month. None of it is an open-ended allowance.
69+
70+### Public repositories and open source
71+
72+Hosting, git, issues, pull requests, review and search on a public
73+repository are never charged.
74+
75+The compute its agents use, **sandbox time and model cost**, is paid by
76+**g1t's open-source pool** first:
77+
78+| | Each month (UTC) |
79+| --- | --- |
80+| The pool, for every public repository together | $10 |
81+| One repository's share of it, at most | $1 |
82+
83+When the pool or the repository's share is spent, that month's usage is
84+charged to the workspace as usual. Each statement line the pool paid says
85+so, as in *Work on acme/lib#12 ($0.04 paid by g1t's open-source pool)*,
86+and the month's statement totals it under **Paid by g1t's open-source
87+pool**. Builds, storage, embeddings and scans are not paid by the pool.
88+
89+### Trials
90+
91+So anyone can try g1t's agents without a key or a card, each new workspace
92+gets **$1 of trial credit**, once. It is given the first time the
93+workspace uses something, and pays for its usage at cost plus 20%, after
94+any Team credit: g1t's models, sandbox time, storage, embeddings and
95+scans, but never deployments. That is roughly 10 to 25 agent runs.
4796
97+- Trials come from a budget of **$40 a month** for every new workspace
98+ together. It renews on the 1st of each month (UTC). When a month's
99+ budget is given out, new trials wait for the next month, and mission
100+ control and **Settings → Integrations** say when they start again.
48101 - Mission control and **Settings → Integrations** show what is left.
49−- When it is used up, agents and workflow runs stop starting, and the
50− pages that start them say so and link to Integrations. Connect your own
51− model provider there and everything carries on at once.
52−- The allowance draws on one shared pool. If the pool runs out before
53− October 22, the allowance ends for everyone early.
54−- Workspaces on their own provider never use it.
102+- When it is used up, agents and workflow runs on g1t's models stop
103+ starting, and the pages that start them say so and link to
104+ Integrations. Connect your own model provider there and everything
105+ carries on at once.
106+- Workspaces that had the free allowance before trials renewed monthly keep
107+ what they had left of it.
55108
56109 ## What is charged
57110
66119 | The [merge queue](/guides/merge-queue/) | [Sandbox time](#sandbox-time) |
67120 | [Workflow](/guides/actions/) jobs | [Sandbox time](#sandbox-time) |
68121 | [Deployments](/guides/deployments/) | The plan, and builds and usage past it. Never free. |
69−| Repositories, git, issues, pull requests, the API and MCP | No |
122+| [Private repository storage](#storage-search-embeddings-and-scans) | Past 1 GB (50 GB on Team) |
123+| [Search embeddings](#storage-search-embeddings-and-scans) | For private text |
124+| [Security scans](#storage-search-embeddings-and-scans) | Yes |
125+| Repositories, git, issues, pull requests, search, the API and MCP | No |
126+| Agents and sandboxes on a public repository | From [the open-source pool](#public-repositories-and-open-source) first |
70127
71128 Each run is charged when it finishes: what the model provider charged for
72129 it, plus 20%. A small change costs a few cents.
117174 | Deploy builds | 20% |
118175 | App requests, CPU and apps | 20% |
119176 | Custom domains past the plan | 20% |
177+| Private storage, search embeddings and security scans | 20% |
120178
121179 ## Sandbox time
122180
141199 Each sandbox is one line on the [statement](#the-statement), such as
142200 *Checks on acme/api#12: 3m 12s of sandbox time*.
143201
202+## Storage, search embeddings and scans
203+
204+Three things g1t used to absorb are metered at what they cost plus 20%,
205+like the rest. Each is counted through the month and charged once it is
206+over, as one line dated the month's last day, so the limit counts it as it
207+happens.
208+
209+| | What it costs g1t | What is counted |
210+| --- | --- | --- |
211+| Private repository storage | $0.50 a GB-month (Cloudflare Artifacts) | Each day, what the workspace's private repositories hold past 1 GB (50 GB on Team). A month's GB-months are those days added up, divided by 30. |
212+| Search embeddings | $0.067 per million tokens (Workers AI) | The text of private repositories, issues and pull requests put in the search index. Public text and searches are not charged. |
213+| Security scans | $0.02 per million CPU milliseconds and $1.00 per million rows written (Workers and D1) | The CPU each history scan and dependency check takes and the rows it writes. Calls to OSV are free. |
214+
215+Storage is measured from the packs pushed through g1t's git endpoints to
216+each repository and its pull requests' working copies. Pushes made by
217+g1t's own agents and imports are not counted yet, so what is charged is
218+never more than what is stored. Public repositories are never charged.
219+
220+The live prices are on [g1t.sh/pricing](https://g1t.sh/pricing).
221+
144222 ## Usage limits
145223
146224 Every workspace has two limits. One protects you from a surprise bill;
167245
168246 | | Ceiling on what is unpaid |
169247 | --- | --- |
170−| **New**: has not paid g1t yet | $3: the free allowances and a little more |
248+| **New**: has not paid g1t yet | $3 |
171249 | **Paid** | twice what you have paid, from $25 up to $1,000 |
172250 | **Established**: three steady months | three times your monthly spend, up to $10,000, by itself |
173251 | **Reviewed** | what g1t set with you; contact us |
192270 What counts as unpaid is each item at what it cost g1t or what it is
193271 charged, whichever is more, less what was paid this month, plus anything
194272 left unpaid from earlier months: a new month is not a fresh allowance.
273+What a plan's credit, a trial or the open-source pool paid for does not
274+count: those are paid for already.
195275
196276 A **declined card** stops work until it is paid, as does a payment
197277 disputed with the card's bank. Paying under Billing with another card
212292 Each is charged to the card on file. The Billing page lists them, with
213293 links to view each on Stripe and download its PDF.
214294
295+### The minimum charge
296+
297+No card is charged less than **$5**, so a payment's fee is never most of
298+what is paid. When a month closes owing less, nothing is charged: the
299+amount carries over and goes on the next invoice that reaches $5, as
300+*Unpaid from earlier*. That month's statement says what carried over. A
301+charge made because a workspace is near its limit goes through whatever
302+the amount, so work can carry on.
303+
215304 ## Enterprises and custom terms
216305
217306 Some accounts are billed differently, set up by g1t with you:
225314 invoice page by card or bank transfer. Paying it clears every workspace
226315 on it; if it goes overdue, their work stops until it is paid.
227316 - **Comped**: g1t covers the account's usage. Usage is still recorded with
228− what it cost, so the Usage page stays accurate, and paid features are on
229− without a plan.
317+ what it cost, so the Usage page stays accurate, and paid features,
318+ Team among them, are on without a plan.
319+- **Plan and pools**: Team without charge, such as for a partner, or a
320+ larger share of the open-source pool or of trials.
230321 - **Custom**: a discount on every usage charge, a limit of its own, or
231322 both, sometimes until a date, after which standard terms apply.
232323
233324 Each change is made by g1t staff in g1t's billing console and recorded with
234−who made it and why. To ask for one, write to support.
325+who made it and why. To ask for one, write to
326+[billing@g1t.sh](mailto:billing@g1t.sh).
235327
328+An enterprise account also has the [audit log](/guides/audit-log/) of each
329+of its workspaces, exported as CSV or JSON, and a year of it with Team.
330+Single sign-on through your identity provider is coming; it is not
331+available yet.
332+
236333 ## Your card, invoices and billing details
237334
238335 These live on **Stripe's billing page**, not on g1t: g1t never sees or
344441 | Runs on your own model provider | Older months only: the flat fee runs on your own provider used to carry. |
345442 | Sandbox time | Each sandbox's time, memory and disk. |
346443 | Deployments | Builds and apps beyond the allowance. |
444+ | Private storage | Storage past the free amount, once a month. |
445+ | Search embeddings | Private text put in the search index, once a month. |
446+ | Security scans | History scans and dependency checks, once a month. |
347447 | Payments | Card payments and invoices paid. |
348448 | Credits from g1t | Credit g1t added, such as a goodwill credit. |
349449 | Refunds | Money given back to your card. |
350450
451+- **Paid for.** Below the totals, what paid for usage before it was
452+ charged: **Paid by your Team plan's credit**, **Paid by your trial
453+ credit** and **Paid by g1t's open-source pool**, each with its amount.
454+ Lines show how much of them was paid this way, and each entry says so
455+ in its description. A month that closed under the
456+ [minimum charge](#the-minimum-charge) says what carried over.
351457 - **Open a line** to see its entries, 50 at a time, newest first. Each
352458 run links to the pull request it was for.
353459 - **Pick a month** to see an earlier one; months with no entries are not
354460 listed.
355461 - **CSV** downloads every entry of the month, with the line it falls
356− under, for your own books. Invoices from Stripe remain the record for
462+ under and what the Team credit, the trial and the open-source pool paid
463+ of it, for your own books. Invoices from Stripe remain the record for
357464 what was charged to your card.
358465
359466 Each pull request's session also ends with what its run cost before the
371478 provider bills you for the models; g1t charges nothing for now, and only
372479 each run's sandbox time once pricing starts.
373480 - **g1t's hosted models:** while payments are in test mode, every
374− workspace can use g1t's own models on
375− [the free allowance](#the-free-allowance) ($1 each, until October 22),
376− and a few g1t has opened them to without limit. When payments go live,
377− every workspace can use them, paid from its credit.
481+ workspace can use g1t's own models on [its trial credit](#trials) ($1
482+ each, from a budget that renews monthly), and a few g1t has opened them
483+ to without limit. When payments go live, every workspace can use them,
484+ paid from its credit.
378485
379486 Each workspace decides where its model spend goes. A workspace that can use
380487 neither sees a message saying so, with the way to connect its own provider.
+5−5
5757 Add **acceptance checks**, commands that must pass, to hold the work to
5858 them.
5959
60−5. **Use the free allowance, or connect a model**
60+5. **Use your trial credit, or connect a model**
6161
6262 g1t's agents, and the runners your workflows use, need a model to work
63− with. Until October 22, every new workspace gets **$1 of agent time on
64− g1t's own models**, no key needed: enough for a good handful of issues.
65− Mission control shows what is left. See
66− [the free allowance](/guides/usage-and-billing/#the-free-allowance).
63+ with. Every new workspace gets **$1 of trial credit** for g1t's own
64+ models and sandboxes, no key needed: enough for a good handful of
65+ issues. Mission control shows what is left. See
66+ [trials](/guides/usage-and-billing/#trials).
6767
6868 For more, or to choose models, connect your Anthropic or OpenAI key, or
6969 any compatible endpoint, in your workspace's **Settings →
+59−1
77 import type { ReactNode } from "react";
88 import { Link } from "react-router";
99
10−import type { AdminAction, AdminOwner, BillingLink, LedgerEntry, Terms } from "@g1t/contracts";
10+import type { AdminAction, AdminOwner, Allowances, BillingLink, LedgerEntry, Terms } from "@g1t/contracts";
1111
1212 import { Avatar, Badge, Button, EmptyState, Field, Input, Notice, Section, Select, Textarea, When } from "~/components/ui";
1313 import { actionLabel } from "~/lib/ledgers";
259259 );
260260 }
261261
262+// --- Plan and pools -----------------------------------------------------------
263+
264+/**
265+ * The Team plan without charge, and the account's share of g1t's pools.
266+ * Comped accounts have Team anyway; this is for partners on other terms.
267+ */
268+export function AllowancesForm({
269+ allowances,
270+ comped,
271+ pathname,
272+ error,
273+}: {
274+ allowances: Allowances | undefined;
275+ comped: boolean;
276+ pathname: string;
277+ error: SectionError;
278+}) {
279+ const values = error?.values;
280+ const current = allowances ?? { team: false, ossRepoMicros: null, trialMicros: null };
281+ return (
282+ <Section
283+ id="allowances"
284+ title="Plan and pools"
285+ description={
286+ comped
287+ ? "Comped: the Team plan is on without charge whatever is set here. The pool shares still apply."
288+ : "The Team plan without charge, and this account's share of g1t's open-source pool and of trials."
289+ }
290+ >
291+ <form method="post" action={`${pathname}#allowances`} className="space-y-4">
292+ <input type="hidden" name="intent" value="allowances" />
293+ {error && <Notice tone="error">{error.error}</Notice>}
294+ <label className="flex cursor-pointer items-start gap-2.5 text-sm">
295+ <input type="checkbox" name="team" defaultChecked={values ? values.team === "on" : current.team} className="mt-0.5" />
296+ <span>
297+ <span className="block font-medium">Team, without charge</span>
298+ <span className="block text-xs text-muted">Its credit, storage and audit log, with no plan to pay for.</span>
299+ </span>
300+ </label>
301+ <div className="grid gap-4 sm:grid-cols-2">
302+ <Field label="Open-source share $" hint="Each public repository, a month. Blank: the default.">
303+ <Input name="oss" inputMode="decimal" placeholder="Default" defaultValue={values?.oss ?? dollarsField(current.ossRepoMicros)} />
304+ </Field>
305+ <Field label="Trial credit $" hint="Each workspace, outside the monthly pool. Blank: the default.">
306+ <Input name="trial" inputMode="decimal" placeholder="Default" defaultValue={values?.trial ?? dollarsField(current.trialMicros)} />
307+ </Field>
308+ </div>
309+ <Field label="Note" hint="Required. Why, for whoever looks next.">
310+ <Textarea name="note" rows={2} required maxLength={500} defaultValue={values?.note ?? ""} placeholder="e.g. Open-source foundation, larger share through 2027" />
311+ </Field>
312+ <div className="flex justify-end">
313+ <Button type="submit">Save plan and pools</Button>
314+ </div>
315+ </form>
316+ </Section>
317+ );
318+}
319+
262320 // --- Credit -------------------------------------------------------------------
263321
264322 export function CreditForm({ workspaces, pathname, error }: { workspaces: string[]; pathname: string; error: SectionError }) {
+16−1
88 import type { Terms } from "@g1t/contracts";
99 import { data, redirect } from "react-router";
1010
11−import { fields, parseCredit, parseEmail, parseNote, parseSlug, parseTerms, text } from "./forms";
11+import { fields, parseAllowances, parseCredit, parseEmail, parseNote, parseSlug, parseTerms, text } from "./forms";
1212 import type { ActionData } from "./review";
1313 import { admin, identity } from "./services.server";
1414 import type { Staff } from "./staff";
4343 return back("terms");
4444 }
4545
46+ if (intent === "allowances") {
47+ // Team without charge, and the account's share of g1t's pools.
48+ const values = fields(form, "team", "oss", "trial", "note");
49+ if (subject.kind === "workspace" && subject.billedTo) {
50+ return failed("allowances", `This workspace is on ${subject.billedTo.name}. Set its plan and pools on the enterprise.`, values);
51+ }
52+ const allowances = parseAllowances(form);
53+ if (!allowances.ok) return failed("allowances", allowances.error, values);
54+ const note = parseNote(values.note);
55+ if (!note.ok) return failed("allowances", note.error, values);
56+ const result = await admin.setAllowances(subject.accountId, allowances.value, note.value, staff.email);
57+ if (!result.ok) return failed("allowances", result.error.message, values);
58+ return back("allowances");
59+ }
60+
4661 if (intent === "attach") {
4762 const values = fields(form, "workspace", "target");
4863 const section = isEnterprise ? "members" : "billed-to";
+16−1
11 import assert from "node:assert/strict";
22 import { test } from "node:test";
33
4−import { parseCredit, parseEmail, parseSales, parseSalesNote, parseSlugList, parseTerms } from "./forms.ts";
4+import { parseAllowances, parseCredit, parseEmail, parseSales, parseSalesNote, parseSlugList, parseTerms } from "./forms.ts";
55
66 const NOW = new Date("2026-10-04T12:00:00Z");
77
8686 assert.equal(parseSalesNote("").ok, false);
8787 assert.equal(parseSalesNote("x".repeat(2001)).ok, false);
8888 });
89+
90+test("allowances: Team on or off, and pool shares in dollars or the default", () => {
91+ const form = (entries: Record<string, string>) => {
92+ const data = new FormData();
93+ for (const [name, value] of Object.entries(entries)) data.set(name, value);
94+ return data;
95+ };
96+ assert.deepEqual(parseAllowances(form({})), { ok: true, value: { team: false, ossRepoMicros: null, trialMicros: null } });
97+ assert.deepEqual(parseAllowances(form({ team: "on", oss: "5", trial: "2.50" })), {
98+ ok: true,
99+ value: { team: true, ossRepoMicros: 5_000_000, trialMicros: 2_500_000 },
100+ });
101+ assert.equal(parseAllowances(form({ oss: "lots" })).ok, false);
102+ assert.equal(parseAllowances(form({ trial: "5000" })).ok, false);
103+});
+25−1
22 * Reading sudo's forms. Everything typed is checked here before it goes
33 * to the billing service, which checks it again.
44 */
5−import type { SalesStage, Terms } from "@g1t/contracts";
5+import type { Allowances, SalesStage, Terms } from "@g1t/contracts";
66
77 import { MICROS_PER_DOLLAR, parseDollars } from "./money.ts";
88 import { isStage } from "./signals.ts";
176176 if (micros > MAX_CREDIT_MICROS) return { ok: false, error: "One credit is at most $10,000. Issue more than one if it really is more." };
177177 return { ok: true, value: micros };
178178 }
179+
180+/** The most staff can set an account's share of a pool to, against a slipped finger. */
181+export const MAX_POOL_SHARE_MICROS = 1_000 * MICROS_PER_DOLLAR;
182+
183+/**
184+ * Allowances from the plan-and-pools form: Team without charge, and the
185+ * account's share of the open-source pool and of trials. A blank amount
186+ * means the default.
187+ */
188+export function parseAllowances(form: FormData): Parsed<Allowances> {
189+ const amount = (name: string, what: string): Parsed<number | null> => {
190+ const raw = text(form, name);
191+ if (!raw) return { ok: true, value: null };
192+ const micros = parseDollars(raw);
193+ if (micros == null || micros < 0) return { ok: false, error: `${what} is dollars and cents, such as 5 or 2.50, or blank for the default.` };
194+ if (micros > MAX_POOL_SHARE_MICROS) return { ok: false, error: `${what} is at most $1,000.` };
195+ return { ok: true, value: micros };
196+ };
197+ const oss = amount("oss", "The open-source share");
198+ if (!oss.ok) return oss;
199+ const trial = amount("trial", "The trial credit");
200+ if (!trial.ok) return trial;
201+ return { ok: true, value: { team: text(form, "team") === "on", ossRepoMicros: oss.value, trialMicros: trial.value } };
202+}
+1−0
8585 /** Each kind of change, as staff read it. */
8686 export const AUDIT_ACTIONS: Record<string, string> = {
8787 terms: "Terms changed",
88+ allowances: "Plan and pools changed",
8889 create: "Enterprise created",
8990 attach: "Workspace added",
9091 detach: "Workspace removed",
+1−0
2424 /** The flash messages a change redirects back with (`?done=`). */
2525 export const DONE: Record<string, string> = {
2626 terms: "Terms saved. They apply to charges from now on.",
27+ allowances: "Plan and pools saved. They apply to charges from now on.",
2728 attach: "Workspace moved onto the enterprise.",
2829 detach: "Workspace moved off the enterprise. It pays for itself again.",
2930 credit: "Credit issued.",
+7−1
44 import { type AdminOwner, type EnterpriseInvoice, type Limit, httpStatus } from "@g1t/contracts";
55
66 import type { Route } from "./+types/enterprise";
7−import { AuditSection, CreditForm, Figure, LedgerSection, ReviewPanel, TermsForm } from "~/components/billing";
7+import { AllowancesForm, AuditSection, CreditForm, Figure, LedgerSection, ReviewPanel, TermsForm } from "~/components/billing";
88 import { Avatar, Badge, Button, EmptyState, ExposureBar, Field, Input, Notice, Section, StateBadge, TermsBadge, TrustBadge, When } from "~/components/ui";
99 import { type Subject, billingAction } from "~/lib/billing-actions.server";
1010 import { usd } from "~/lib/money";
150150 error={error("invoices")}
151151 />
152152 <TermsForm terms={account.terms} pathname={pathname} error={error("terms")} />
153+ <AllowancesForm
154+ allowances={account.allowances}
155+ comped={account.terms.kind === "comped"}
156+ pathname={pathname}
157+ error={error("allowances")}
158+ />
153159 <LedgerSection ledger={detail.ledger} showWorkspace description="Recent lines for every workspace it pays for, newest first." />
154160 </div>
155161 <div className="space-y-6">
+17−0
7474 <Stat label="Paying workspaces" value={String(overview.payingWorkspaces)} hint="Charged something this month" />
7575 </div>
7676
77+ {overview.pools && (
78+ <div className="mt-3 grid grid-cols-2 gap-3">
79+ <Stat
80+ label="Open-source pool"
81+ value={`${usd(overview.pools.ossUsedMicros)} of ${usd(overview.pools.ossPoolMicros)}`}
82+ hint={`Paid for work on public repositories this month; up to ${usd(overview.pools.ossRepoMicros)} each`}
83+ tone={overview.pools.ossUsedMicros >= overview.pools.ossPoolMicros ? "warn" : undefined}
84+ />
85+ <Stat
86+ label="Trials given"
87+ value={`${usd(overview.pools.trialGrantedMicros)} of ${usd(overview.pools.trialPoolMicros)}`}
88+ hint={`${overview.pools.trialGrants} new workspace${overview.pools.trialGrants === 1 ? "" : "s"} this month; new trials wait when it is given out`}
89+ tone={overview.pools.trialGrantedMicros >= overview.pools.trialPoolMicros ? "warn" : undefined}
90+ />
91+ </div>
92+ )}
93+
7794 <div className="mt-6 grid gap-6 lg:grid-cols-[minmax(0,3fr)_minmax(0,2fr)]">
7895 <Section title="Last six months" description="Charged against what it cost g1t, every workspace together.">
7996 <MonthsChart months={months} />
+7−2
66
77 import type { Route } from "./+types/workspace";
88 import {
9+ AllowancesForm,
910 AuditSection,
1011 BillingLinkSection,
1112 CreditForm,
8990 person,
9091 billedTo,
9192 terms: subject.terms,
93+ allowances: summary.account.allowances,
9294 limit,
9395 figures,
9496 months: summary.months ?? [],
124126 ];
125127
126128 export default function Workspace({ loaderData, actionData }: Route.ComponentProps) {
127− const { me, slug, name, person, billedTo, terms, limit, figures, months, enterprises, sales, salesError, invoices, invoicesError, ledger, audit, done, salesDone } =
129+ const { me, slug, name, person, billedTo, terms, allowances, limit, figures, months, enterprises, sales, salesError, invoices, invoicesError, ledger, audit, done, salesDone } =
128130 loaderData;
129131 const { pathname } = useLocation();
130132 const result = actionData as ActionData | undefined;
274276 </div>
275277 </Section>
276278 ) : (
277− <TermsForm terms={terms} pathname={pathname} error={error("terms")} />
279+ <>
280+ <TermsForm terms={terms} pathname={pathname} error={error("terms")} />
281+ <AllowancesForm allowances={allowances} comped={terms.kind === "comped"} pathname={pathname} error={error("allowances")} />
282+ </>
278283 )}
279284 <WorkspaceInvoicesSection invoices={invoices} unavailable={invoicesError} />
280285 <div id="ledger" className="scroll-mt-20">
+4−8
99 import { RepoList } from "./repo-list";
1010 import { ButtonLink, CopyLine } from "./ui";
1111
12−/** When the free allowance on g1t's models ends (billing's TRIAL_UNTIL). */
13−const TRIAL_ENDS = Date.parse("2026-10-23T06:59:59Z");
1412
1513 /** A small label above a heading, in the mono face. */
1614 function Eyebrow({ children }: { children: ReactNode }) {
163161 Watch agents at work
164162 </ButtonLink>
165163 </div>
166− {Date.now() < TRIAL_ENDS && (
167− <p className="mx-auto mt-5 max-w-xl animate-fade-up text-sm text-fg-soft/70 text-balance">
168− Free to try: every new workspace gets $1 of agent time on g1t's models, no key needed, until
169− October 22. Bring your own model for more.
170− </p>
171− )}
164+ <p className="mx-auto mt-5 max-w-xl animate-fade-up text-sm text-fg-soft/70 text-balance">
165+ Free to try: every new workspace gets $1 of trial credit for g1t's agents, no key needed. Public
166+ repositories are free. Bring your own model for more.
167+ </p>
172168 </div>
173169 <div className="relative mx-auto max-w-5xl px-4 pb-4">
174170 <ConvergeArt className="w-full" />
+86−0
1+import { EyeOff, KeyRound, UserX } from "lucide-react";
2+import { Form, Link, useLocation, useRouteLoaderData } from "react-router";
3+
4+import type { User } from "@g1t/contracts";
5+
6+import { withNext } from "../lib/next";
7+import { missingKind, notFoundCopy } from "../lib/not-found";
8+import { Avatar, ButtonLink } from "./ui";
9+
10+/**
11+ * The page for anything someone cannot see. It reads only the kind of thing
12+ * the address names and who is signed in, so a private project and a
13+ * missing one give the same page, word for word.
14+ */
15+export function NotFound({ data }: { data?: unknown }) {
16+ const { pathname, search } = useLocation();
17+ const user = (useRouteLoaderData("root") as { user?: User | null } | undefined)?.user ?? null;
18+ const kind = missingKind(data, pathname);
19+ const copy = notFoundCopy(kind, user?.username);
20+ const here = pathname + search;
21+ const Icon = kind === "person" ? UserX : copy.signIn ? KeyRound : EyeOff;
22+ return (
23+ <main className="mx-auto flex max-w-lg flex-col items-center px-4 py-24 text-center sm:py-32">
24+ <span className="flex size-11 items-center justify-center rounded-xl bg-surface text-muted ring-1 ring-line">
25+ <Icon size={20} />
26+ </span>
27+ <h1 className="mt-6 text-balance text-2xl font-semibold tracking-tight">{copy.title}</h1>
28+ {copy.body && <p className="mt-3 text-muted">{copy.body}</p>}
29+
30+ {copy.signIn && (
31+ <>
32+ <div className="mt-8 flex flex-wrap justify-center gap-2">
33+ <ButtonLink to={withNext("/login", here)}>Sign in</ButtonLink>
34+ <ButtonLink to="/explore" variant="quiet">
35+ Explore public projects
36+ </ButtonLink>
37+ </div>
38+ <p className="mt-5 text-sm text-muted">
39+ New to g1t?{" "}
40+ <Link to={withNext("/register", here)} className="font-medium text-fg underline-offset-4 hover:underline">
41+ Sign up
42+ </Link>
43+ </p>
44+ </>
45+ )}
46+
47+ {copy.signedInAs && (
48+ <>
49+ <div className="mt-6 flex w-full flex-col items-center gap-3 rounded-lg bg-surface px-4 py-4 text-sm ring-1 ring-line">
50+ <p className="flex items-center gap-2 text-muted">
51+ <Avatar name={copy.signedInAs} image={user?.avatar} size={20} />
52+ Signed in as <span className="font-mono font-medium text-fg">@{copy.signedInAs}</span>
53+ </p>
54+ {/* Signing out, then in again, comes back here. */}
55+ <Form method="post" action={`/logout?next=${encodeURIComponent(withNext("/login", here))}`}>
56+ <span className="text-faint">Not you? </span>
57+ <button type="submit" className="font-medium text-fg underline-offset-4 hover:underline">
58+ Switch account
59+ </button>
60+ </Form>
61+ </div>
62+ {copy.askOwner && (
63+ <p className="mt-4 text-sm text-muted">
64+ If you should have access, ask an owner of the workspace to add you.
65+ </p>
66+ )}
67+ <div className="mt-8 flex flex-wrap justify-center gap-2">
68+ <ButtonLink to="/">Back to Mission control</ButtonLink>
69+ <ButtonLink to="/explore" variant="quiet">
70+ Explore
71+ </ButtonLink>
72+ </div>
73+ </>
74+ )}
75+
76+ {kind === "person" && (
77+ <div className="mt-8 flex flex-wrap justify-center gap-2">
78+ <ButtonLink to="/search?type=people">Search people</ButtonLink>
79+ <ButtonLink to="/explore" variant="quiet">
80+ Explore
81+ </ButtonLink>
82+ </div>
83+ )}
84+ </main>
85+ );
86+}
+139−26
2424 LayoutGrid,
2525 ListTree,
2626 Lock,
27+ LogIn,
2728 LogOut,
2829 Menu,
2930 Plus,
4950 import { MICROS_PER_DOLLAR } from "@g1t/contracts";
5051
5152 import { CommandPalette, type PaletteCommand, usePaletteShortcut } from "./command-palette";
52−import { Mark } from "./logo";
53+import { Logo, Mark } from "./logo";
5354 import { Avatar, notACredential } from "./ui";
5455 import {
5556 DropdownMenu,
6061 DropdownMenuTrigger,
6162 } from "./ui/dropdown-menu";
6263 import { type RoadmapItem, roadmapIn } from "../lib/roadmap";
64+import { VISITOR_LINKS, projectPages } from "../lib/chrome";
65+import { withNext } from "../lib/next";
6366
64−/** What the sidebar needs, worked out by the root loader for a signed-in person. */
67+/**
68+ * What the sidebar needs, worked out by the root loader. For a visitor who
69+ * is not signed in there is no workspace, no projects and no usage: only
70+ * the project being looked at, if they can see it.
71+ */
6572 export type ShellData = {
6673 /** The workspace the sidebar is about: the one being looked at, or their first. */
6774 workspace: Membership | null;
459466 * as it does for settings: everything about the project, running and its
460467 * code, and nothing else, with the way back to everything.
461468 */
462−function RepoMenu({ repo, isPrivate, open }: { repo: MenuRepo; isPrivate: boolean; open: boolean }) {
469+function RepoMenu({
470+ repo,
471+ isPrivate,
472+ open,
473+ visitor = false,
474+}: {
475+ repo: MenuRepo;
476+ isPrivate: boolean;
477+ open: boolean;
478+ /** Not signed in: the way back is Explore, not mission control. */
479+ visitor?: boolean;
480+}) {
463481 const base = `/${repo.namespace}/${repo.name}`;
482+ // What a member sees, and what everyone who can see the project does.
483+ const shows = new Set(projectPages(repo.member));
464484 return (
465485 <nav aria-label={`${repo.namespace}/${repo.name}`} inert={!open} className={PANEL}>
466486 <Link
467− to="/"
487+ to={visitor ? "/explore" : "/"}
468488 className="group mt-3 flex h-8 items-center gap-2 rounded-md px-2 text-[0.8125rem] text-muted transition-colors hover:bg-raised/60 hover:text-fg"
469489 >
470490 <ArrowLeft size={15} className="text-faint transition-transform group-hover:-translate-x-0.5 group-hover:text-muted" />
471− Mission control
491+ {visitor ? "Explore" : "Mission control"}
472492 </Link>
473493 <NavLink
474494 to={base}
504524 <SidebarLink to={`${base}/actions`} icon={<PlayCircle size={15} />}>
505525 Workflows
506526 </SidebarLink>
507− {repo.member ? (
527+ {shows.has("deployments") ? (
508528 <SidebarLink to={`${base}/deployments`} also={soonPaths(base, "Deployments")} icon={<Rocket size={15} />}>
509529 Deployments
510530 </SidebarLink>
512532 <SidebarSoonLink to={`${base}/soon/logs`} also={soonPaths(base, "Observability")} icon={<Activity size={15} />} about="Logs, errors, uptime and analytics of the project's deployed apps.">
513533 Observability
514534 </SidebarSoonLink>
515− {repo.member ? (
535+ {shows.has("security") ? (
516536 <SidebarLink to={`${base}/security`} also={soonPaths(base, "Security")} icon={<ShieldCheck size={15} />}>
517537 Security
518538 </SidebarLink>
520540 <SidebarSoonLink to={`${base}/soon/delivery`} also={soonPaths(base, "Insights")} icon={<BarChart3 size={15} />} about="Delivery metrics, costs and the work agents do.">
521541 Insights
522542 </SidebarSoonLink>
523− {repo.member && (
543+ {shows.has("settings") && (
524544 <SidebarLink to={`${base}/settings`} icon={<Settings size={15} />}>
525545 Settings
526546 </SidebarLink>
624644 );
625645 }
626646
627−function Sidebar({ user, shell, onFind }: { user: User; shell: ShellData; onFind: () => void }) {
647+/**
648+ * Signing in and signing up, in place of the account for a visitor. Signing
649+ * in brings them back to the page they are on.
650+ */
651+function VisitorPanel() {
652+ const { pathname, search } = useLocation();
653+ return (
654+ <div className="space-y-2">
655+ <p className="px-1 text-xs text-muted">Sign in to open issues, review pull requests and run agents.</p>
656+ <div className="grid grid-cols-2 gap-2">
657+ <Link
658+ to={withNext("/login", pathname + search)}
659+ className="flex h-9 items-center justify-center gap-1.5 rounded-md border border-line text-[0.8125rem] font-medium text-fg/90 transition-colors hover:border-line-strong hover:bg-raised hover:text-fg"
660+ >
661+ <LogIn size={14} />
662+ Sign in
663+ </Link>
664+ <Link
665+ to={withNext("/register", pathname + search)}
666+ className="flex h-9 items-center justify-center rounded-md bg-fg text-[0.8125rem] font-medium text-bg transition-colors hover:bg-white"
667+ >
668+ Sign up
669+ </Link>
670+ </div>
671+ </div>
672+ );
673+}
674+
675+function Sidebar({ user, shell, onFind }: { user: User | null; shell: ShellData; onFind: () => void }) {
628676 const ws = shell.workspace;
629677 const { pathname } = useLocation();
630678 const going = useNavigation().location?.pathname;
649697 ? active
650698 : {
651699 ...targetRepo,
652− member: (user.workspaces ?? []).some((m) => m.slug === targetRepo.namespace.toLowerCase()),
700+ member: (user?.workspaces ?? []).some((m) => m.slug === targetRepo.namespace.toLowerCase()),
653701 }
654702 : null;
655703 // What sits on the far side of the track. Kept while sliding back, so it
684732 <div className="flex h-full flex-col">
685733 {/* The same height and rule as the top bar, so the two read as one line. */}
686734 <div className="flex h-16 shrink-0 items-center gap-1 border-b border-line pr-2 pl-2.5">
687− <Link to="/" aria-label="g1t home" className="shrink-0 rounded-md p-1.5 hover:bg-raised">
688− <Mark className="size-6" />
689− </Link>
690− <span className="shrink-0 text-line-strong" aria-hidden="true">
691− /
692− </span>
693− <WorkspaceSwitcher user={user} shell={shell} />
735+ {user ? (
736+ <>
737+ <Link to="/" aria-label="g1t home" className="shrink-0 rounded-md p-1.5 hover:bg-raised">
738+ <Mark className="size-6" />
739+ </Link>
740+ <span className="shrink-0 text-line-strong" aria-hidden="true">
741+ /
742+ </span>
743+ <WorkspaceSwitcher user={user} shell={shell} />
744+ </>
745+ ) : (
746+ <Link to="/" aria-label="g1t home" className="rounded-md px-1.5 py-1 hover:bg-raised">
747+ <Logo />
748+ </Link>
749+ )}
694750 </div>
695751 <div className="px-2 pt-3">
696752 <button
706762 <div className="relative min-h-0 grow overflow-hidden">
707763 <div className={`${LAYER} ${away ? "pointer-events-none -translate-x-1/4 opacity-0" : "translate-x-0 opacity-100"}`}>
708764 <nav aria-label="g1t" inert={away} className={PANEL}>
765+ {/* A visitor browses: no workspace, no projects of their own. */}
766+ {!user ? (
767+ <div className="mt-3 space-y-px">
768+ {VISITOR_LINKS.map((link) => (
769+ <SidebarLink key={link.to} to={link.to} icon={link.to === "/search" ? <Search size={15} /> : <Compass size={15} />}>
770+ {link.label}
771+ </SidebarLink>
772+ ))}
773+ </div>
774+ ) : (
775+ <>
709776 <div className="mt-3 space-y-px">
710777 <SidebarLink to="/" end icon={<LayoutDashboard size={15} />}>
711778 Mission control
777844 ))}
778845 </SidebarGroup>
779846 )}
847+ </>
848+ )}
780849 </nav>
781850 </div>
782851 <div className={`${LAYER} ${away ? "translate-x-0 opacity-100" : "pointer-events-none translate-x-full opacity-0"}`}>
788857 repo={shown.current}
789858 isPrivate={shell.repos.some((repo) => sameRepo(repo, shown.current) && repo.isPrivate)}
790859 open={inRepo}
860+ visitor={!user}
791861 />
792862 ) : side.current === "account" || !ws ? (
793863 <AccountSettingsMenu open={inAccount} />
821891 </div>
822892 )}
823893 {ws && <UsageCard slug={ws.slug} shell={shell} />}
824− <AccountMenu user={user} />
894+ {user ? <AccountMenu user={user} /> : <VisitorPanel />}
825895 </div>
826896 </div>
827897 );
867937 };
868938 return <span className="text-sm font-medium">{words[parts[0]!]}</span>;
869939 }
940+ // A person's profile, by their handle.
941+ if (parts[0] === "u" && parts[1]) {
942+ return <span className="truncate font-mono text-[0.8125rem] font-medium">@{parts[1]}</span>;
943+ }
870944 const [owner, second, third, fourth] = parts;
871945 const trail: { label: string; to: string; mono?: boolean }[] = [{ label: owner!, to: `/${owner}`, mono: true }];
872946 if (second === "-") {
911985 type Command = PaletteCommand;
912986
913987 /** Everything the palette can jump to, from what the sidebar already knows. */
914−function commandsFor(user: User, shell: ShellData): Command[] {
988+function commandsFor(user: User | null, shell: ShellData, here: string): Command[] {
989+ if (!user) return visitorCommands(shell, here);
915990 const commands: Command[] = [
916991 { label: "Mission control", to: "/", icon: <LayoutDashboard size={15} /> },
917992 { label: "Explore repositories", to: "/explore", icon: <Compass size={15} /> },
9621037 return commands;
9631038 }
9641039
1040+/** What the palette offers a visitor: browsing, the project they are in, and signing in. */
1041+function visitorCommands(shell: ShellData, here: string): Command[] {
1042+ const commands: Command[] = [];
1043+ const repo = shell.repo;
1044+ if (repo) {
1045+ const base = `/${repo.namespace}/${repo.name}`;
1046+ const name = `${repo.namespace}/${repo.name}`;
1047+ commands.push(
1048+ { label: "Overview", hint: name, to: base, icon: <LayoutGrid size={15} /> },
1049+ { label: "Code", hint: name, to: `${base}/code`, icon: <Code2 size={15} /> },
1050+ { label: "Issues", hint: name, to: `${base}/issues`, icon: <CircleDot size={15} /> },
1051+ { label: "Pull requests", hint: name, to: `${base}/pulls`, icon: <GitPullRequest size={15} /> },
1052+ { label: "Commits", hint: name, to: `${base}/commits`, icon: <History size={15} /> },
1053+ );
1054+ }
1055+ commands.push(
1056+ { label: "Explore", hint: "Public projects", to: "/explore", icon: <Compass size={15} /> },
1057+ { label: "Search g1t", hint: "Repositories, code, issues, people", to: "/search", icon: <Search size={15} /> },
1058+ { label: "Pricing", to: "/pricing", icon: <CreditCard size={15} /> },
1059+ { label: "Documentation", to: "https://docs.g1t.sh/", icon: <BookOpen size={15} /> },
1060+ { label: "Sign in", to: withNext("/login", here), icon: <LogIn size={15} /> },
1061+ { label: "Sign up", to: withNext("/register", here), icon: <Plus size={15} /> },
1062+ );
1063+ return commands;
1064+}
1065+
9651066 /**
9661067 * A bar across the top of the page while the next one loads, as GitHub
9671068 * has: it appears at once, creeps towards the end while waiting, then fills
10041105 }
10051106
10061107 /**
1007− * The signed-in app: a sidebar with the workspace, its repositories and the
1008− * sections of the one being looked at; a slim bar with search and the
1009− * account; and the page.
1108+ * The app: a sidebar with the workspace, its repositories and the sections
1109+ * of the one being looked at; a slim bar with search and the account; and
1110+ * the page. A visitor who is not signed in gets the same frame, with
1111+ * Explore and Search in place of the workspace, and signing in in place of
1112+ * the account.
10101113 */
10111114 export function AppShell({
10121115 user,
10141117 banner,
10151118 children,
10161119 }: {
1017− user: User;
1120+ user: User | null;
10181121 shell: ShellData;
10191122 banner?: ReactNode;
10201123 children: ReactNode;
10211124 }) {
1022− const submit = useSubmit();
1023− const { pathname } = useLocation();
1125+ const { pathname, search } = useLocation();
10241126 const [drawer, setDrawer] = useState(false);
10251127 const [palette, setPalette] = useState(false);
1026− const commands = useMemo(() => commandsFor(user, shell), [user, shell]);
1128+ const here = pathname + search;
1129+ const commands = useMemo(() => commandsFor(user, shell, here), [user, shell, here]);
10271130
10281131 // A new page closes the drawer on small screens.
10291132 useEffect(() => setDrawer(false), [pathname]);
10881191 >
10891192 Docs
10901193 </a>
1194+ {!user ? (
1195+ // The sidebar has these too, but on a phone it is folded away.
1196+ <Link
1197+ to={withNext("/login", here)}
1198+ className="flex h-9 items-center rounded-md bg-fg px-3 text-sm font-medium text-bg transition-colors hover:bg-white lg:hidden"
1199+ >
1200+ Sign in
1201+ </Link>
1202+ ) : (
10911203 <DropdownMenu>
10921204 <DropdownMenuTrigger
10931205 aria-label="Create"
11271239 </DropdownMenuItem>
11281240 </DropdownMenuContent>
11291241 </DropdownMenu>
1242+ )}
11301243 </div>
11311244 </header>
11321245 {banner}
+21−0
105105 </div>
106106 </dl>
107107
108+ {((totals.covered?.length ?? 0) > 0 || (totals.carriedMicros ?? 0) > 0) && (
109+ <ul className="mt-2 space-y-1 rounded-xl border border-line bg-surface px-4 py-3 text-sm">
110+ {totals.covered?.map((paid) => (
111+ <li key={paid.source} className="flex justify-between gap-4">
112+ <span className="text-muted">{paid.label}</span>
113+ <span className="font-mono tabular-nums text-accent">{charge(paid.micros)}</span>
114+ </li>
115+ ))}
116+ {(totals.carriedMicros ?? 0) > 0 && (
117+ <li className="flex justify-between gap-4">
118+ <span className="text-muted">Under the minimum charge, so carried over to the next invoice</span>
119+ <span className="font-mono tabular-nums">{charge(totals.carriedMicros ?? 0)}</span>
120+ </li>
121+ )}
122+ </ul>
123+ )}
124+
108125 <div className="mt-3">
109126 {statement.groups.length === 0 ? (
110127 <EmptyState title={`Nothing in ${monthLabel(statement.month)}`}>
129146 kind={line.kind}
130147 count={line.count}
131148 chargedMicros={line.chargedMicros}
149+ coveredMicros={line.coveredMicros ?? 0}
132150 day={group === "day" ? g.key : null}
133151 project={group === "project" ? g.key : null}
134152 />
149167 kind,
150168 count,
151169 chargedMicros,
170+ coveredMicros,
152171 day,
153172 project,
154173 }: {
157176 kind: string;
158177 count: number;
159178 chargedMicros: number;
179+ coveredMicros: number;
160180 day: string | null;
161181 project: string | null;
162182 }) {
199219 <span className="grow truncate font-medium">{kind}</span>
200220 <span className="shrink-0 text-xs text-faint tabular-nums">
201221 {count.toLocaleString("en-US")} {count === 1 ? "entry" : "entries"}
222+ {coveredMicros > 0 && ` · ${charge(coveredMicros)} paid for`}
202223 </span>
203224 <span
204225 className={`w-24 shrink-0 text-right font-mono tabular-nums ${moneyIn ? "text-accent" : "text-fg"}`}
+15−2
22
33 import { type AuditEntry, type AuditQuery, type Viewer, auditClient } from "@g1t/contracts";
44
5−import { visibilityFor } from "./audit";
5+import { retainedSince, visibilityFor } from "./audit";
6+import { billing } from "./services.server";
67 import { roleIn } from "./session.server";
78
89 /** The audit log, which the events service keeps. */
910 export const audit = auditClient(env.EVENTS);
1011
12+/**
13+ * How many days of the workspace's log its plan keeps: 30, or a year on
14+ * Team. Null when billing cannot say, and then nothing is held back.
15+ */
16+export async function auditRetention(workspace: string): Promise<number | null> {
17+ const found = await billing.entitlements(workspace.toLowerCase()).catch(() => null);
18+ return found?.auditRetentionDays ?? null;
19+}
20+
1121 /** The most rows one export writes. */
1222 export const EXPORT_LIMIT = 10_000;
1323
1828 export async function auditPage(viewer: Viewer, query: Omit<AuditQuery, "visibility">) {
1929 const visibility = viewer ? visibilityFor(roleIn(viewer, query.workspace), viewer.username) : null;
2030 if (!visibility) return null;
21− return audit.list({ ...query, workspace: query.workspace.toLowerCase(), visibility });
31+ // Reads and exports go back only as far as the workspace's plan keeps.
32+ const days = await auditRetention(query.workspace);
33+ const since = days == null ? query.since : retainedSince(query.since, days);
34+ return audit.list({ ...query, since, workspace: query.workspace.toLowerCase(), visibility });
2235 }
2336
2437 /** Every entry matching `query`, page by page, up to `EXPORT_LIMIT`. */
+12−0
88 exportName,
99 filterHref,
1010 parseFilters,
11+ retainedSince,
1112 ruleLabel,
1213 targetLabel,
1314 toCsv,
8990 assert.ok(lines[2].includes("'=HYPERLINK(1)"));
9091 assert.equal(exportName("acme", "csv", new Date("2026-10-04T23:00:00Z")), "acme-audit-2026-10-04.csv");
9192 });
93+
94+test("the log reads back only as far as the plan keeps it", () => {
95+ const now = Date.parse("2026-10-31T00:00:00.000Z");
96+ // 30 days without Team.
97+ assert.equal(retainedSince(null, 30, now), "2026-10-01T00:00:00.000Z");
98+ assert.equal(retainedSince("2026-01-01T00:00:00.000Z", 30, now), "2026-10-01T00:00:00.000Z");
99+ // A later start than the window is kept.
100+ assert.equal(retainedSince("2026-10-20T00:00:00.000Z", 30, now), "2026-10-20T00:00:00.000Z");
101+ // A year on Team.
102+ assert.equal(retainedSince("2026-01-01T00:00:00.000Z", 365, now), "2026-01-01T00:00:00.000Z");
103+});
+10−0
2424 return null;
2525 }
2626
27+/**
28+ * The earliest time a workspace's log can be read from, given how many
29+ * days its plan keeps (30, or a year on Team): the later of what was asked
30+ * for and the start of the window.
31+ */
32+export function retainedSince(since: string | null | undefined, days: number, now = Date.now()): string {
33+ const start = new Date(now - days * 24 * 60 * 60 * 1000).toISOString();
34+ return since && since > start ? since : start;
35+}
36+
2737 /** The filters a page's address can carry. */
2838 export type AuditFilters = {
2939 actor: string;
+14−0
1515 export const CHANGELOG: ChangelogEntry[] = [
1616 {
1717 date: "2026-10-05",
18+ title: "Browse public projects in the sidebar",
19+ about:
20+ "Public projects, Explore, Search and profiles use the sidebar whether you are signed in or not. A page you cannot see says so plainly, the same for private and missing, with a way to sign in or switch account.",
21+ href: "https://docs.g1t.sh/guides/git/#browsing-without-an-account",
22+ },
23+ {
24+ date: "2026-10-05",
25+ title: "The Team plan, and free open source",
26+ about:
27+ "Team is $20 a month per workspace, never per person, with $5 of usage credit, 50 GB of private storage and a year of audit log. Work on public repositories is paid by g1t's open-source pool first, trials renew monthly, and no card is charged under $5.",
28+ href: "/pricing",
29+ },
30+ {
31+ date: "2026-10-05",
1832 title: "Cost plus 20%, on everything",
1933 about:
2034 "Sandbox time is charged at cost plus 20% from the first second, with no free minutes. Runs on your own model provider no longer carry a $0.10 fee: they pay only their sandbox time.",
+44−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import { VISITOR_LINKS, projectPages, usesAppShell } from "./chrome.ts";
5+
6+test("someone signed in always gets the sidebar", () => {
7+ for (const path of ["/", "/pricing", "/acme/web", "/explore", "/does/not/exist"]) {
8+ assert.equal(usesAppShell(path, true), true, path);
9+ }
10+});
11+
12+test("a visitor gets the sidebar on app pages", () => {
13+ for (const path of [
14+ "/acme/web",
15+ "/acme/web/code",
16+ "/acme/web/issues/4",
17+ "/explore",
18+ "/search",
19+ "/u/ada",
20+ "/acme",
21+ "/nothing/here/at/all",
22+ ]) {
23+ assert.equal(usesAppShell(path, false), true, path);
24+ }
25+});
26+
27+test("a visitor gets the marketing frame on the front, pricing and sign-in pages", () => {
28+ for (const path of ["/", "/pricing", "/pricing/", "/login", "/register", "/verify", "/forgot", "/reset", "/device", "/oauth/authorize"]) {
29+ assert.equal(usesAppShell(path, false), false, path);
30+ }
31+});
32+
33+test("a visitor's sidebar offers Explore and Search", () => {
34+ assert.deepEqual(VISITOR_LINKS.map((link) => link.to), ["/explore", "/search"]);
35+});
36+
37+test("a project's menu hides member-only pages from everyone else", () => {
38+ const visitor = projectPages(false);
39+ assert.deepEqual(visitor.slice(0, 5), ["code", "issues", "pulls", "agents", "actions"]);
40+ for (const page of ["deployments", "security", "settings"] as const) {
41+ assert.ok(!visitor.includes(page), page);
42+ assert.ok(projectPages(true).includes(page), page);
43+ }
44+});
+77−0
1+/**
2+ * Which frame a page is drawn in, and what the sidebar offers someone who
3+ * is not signed in.
4+ *
5+ * g1t is a sidebar site: projects, Explore, Search, profiles and
6+ * not-found pages are drawn in the app's sidebar for everyone, so a
7+ * visitor browsing public projects finds their way the same as a member.
8+ * Only the front page and the pages about signing in or paying keep the
9+ * marketing header and footer.
10+ */
11+
12+/** Pages a visitor sees in the marketing frame. */
13+const MARKETING = new Set([
14+ "/",
15+ "/pricing",
16+ "/login",
17+ "/register",
18+ "/logout",
19+ "/verify",
20+ "/forgot",
21+ "/reset",
22+ "/device",
23+ // These send a visitor to sign in; the frame matters only for a moment.
24+ "/new",
25+ "/settings",
26+ "/workspaces/new",
27+]);
28+
29+/** Whether the page is drawn in the app's sidebar frame. */
30+export function usesAppShell(pathname: string, signedIn: boolean): boolean {
31+ if (signedIn) return true;
32+ const path = pathname.length > 1 ? pathname.replace(/\/+$/, "") : pathname;
33+ if (MARKETING.has(path)) return false;
34+ if (path === "/oauth" || path.startsWith("/oauth/")) return false;
35+ return true;
36+}
37+
38+export type SidebarItem = { label: string; to: string };
39+
40+/** The visitor's sidebar menu: where to browse from. */
41+export const VISITOR_LINKS: SidebarItem[] = [
42+ { label: "Explore", to: "/explore" },
43+ { label: "Search", to: "/search" },
44+];
45+
46+/** The project pages the sidebar lists, by key, for a member or not. */
47+export type ProjectPage =
48+ | "code"
49+ | "issues"
50+ | "pulls"
51+ | "agents"
52+ | "actions"
53+ | "deployments"
54+ | "observability"
55+ | "security"
56+ | "insights"
57+ | "settings";
58+
59+/**
60+ * A project's menu, in order. Deployments, Security and Settings are for
61+ * members only; everyone who can see the project sees the rest.
62+ */
63+export function projectPages(member: boolean): ProjectPage[] {
64+ const pages: (ProjectPage | false)[] = [
65+ "code",
66+ "issues",
67+ "pulls",
68+ "agents",
69+ "actions",
70+ member && "deployments",
71+ "observability",
72+ member && "security",
73+ "insights",
74+ member && "settings",
75+ ];
76+ return pages.filter((page): page is ProjectPage => page !== false);
77+}
+41−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import { safeNext, withNext } from "./next.ts";
5+
6+test("a path on g1t is kept, with its query and fragment", () => {
7+ assert.equal(safeNext("/acme/web"), "/acme/web");
8+ assert.equal(safeNext("/acme/web/issues?state=closed#top"), "/acme/web/issues?state=closed#top");
9+ assert.equal(safeNext("/device?code=ABCD-EFGH"), "/device?code=ABCD-EFGH");
10+});
11+
12+test("anything that could leave g1t goes home instead", () => {
13+ for (const raw of [
14+ null,
15+ undefined,
16+ "",
17+ "acme/web",
18+ "https://evil.example/",
19+ "//evil.example/",
20+ "/\\evil.example/",
21+ "/\\/evil.example/",
22+ "\\\\evil.example",
23+ "/%5C%5Cevil.example".replace(/%5C/g, "\\"),
24+ "/\t/evil.example",
25+ "/\n/evil.example",
26+ "/%0a",
27+ "javascript:alert(1)",
28+ ]) {
29+ const kept = safeNext(raw);
30+ // An encoded newline is still a path on g1t.
31+ if (raw === "/%0a") assert.equal(kept, "/%0a");
32+ else assert.equal(kept, "/", String(raw));
33+ }
34+});
35+
36+test("sign in and sign up links carry where to come back to", () => {
37+ assert.equal(withNext("/login", "/acme/web/issues?state=open"), "/login?next=%2Facme%2Fweb%2Fissues%3Fstate%3Dopen");
38+ assert.equal(withNext("/register", "/explore"), "/register?next=%2Fexplore");
39+ assert.equal(withNext("/login", "/"), "/login");
40+ assert.equal(withNext("/login", "//evil.example"), "/login");
41+});
+28−0
1+/**
2+ * Where to send someone after they sign in, sign up or switch account:
3+ * a path on g1t, and never anywhere else.
4+ *
5+ * Only a plain same-origin path is honoured. `//host`, `/\host` and paths
6+ * with control characters are refused, because browsers read all of them
7+ * as another site (they drop tabs and newlines, and treat `\` as `/`).
8+ */
9+export function safeNext(raw: string | null | undefined): string {
10+ if (!raw || !raw.startsWith("/")) return "/";
11+ if (raw.startsWith("//") || raw.includes("\\")) return "/";
12+ // Control characters and DEL, which a browser may strip before parsing.
13+ if (/[\u0000-\u001f\u007f]/.test(raw)) return "/";
14+ try {
15+ const base = "https://g1t.invalid";
16+ const url = new URL(raw, base);
17+ if (url.origin !== base) return "/";
18+ return url.pathname + url.search + url.hash;
19+ } catch {
20+ return "/";
21+ }
22+}
23+
24+/** A sign-in or sign-up page that brings someone back to `here` afterwards. */
25+export function withNext(page: "/login" | "/register", here: string): string {
26+ const next = safeNext(here);
27+ return next === "/" ? page : `${page}?next=${encodeURIComponent(next)}`;
28+}
+12−0
1+import { data } from "react-router";
2+
3+import type { MissingKind } from "./not-found";
4+
5+/**
6+ * The 404 a loader throws when the viewer cannot see what the address
7+ * names. It carries only the kind of thing, never whether it exists, and is
8+ * thrown the same way for something private and something missing.
9+ */
10+export function notFound(kind: MissingKind) {
11+ return data({ kind }, { status: 404 });
12+}
+74−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import { type MissingKind, missingKind, notFoundCopy } from "./not-found.ts";
5+
6+test("the kind a loader names wins over the address", () => {
7+ assert.equal(missingKind({ kind: "project" }, "/acme/web/blob/main/x"), "project");
8+ assert.equal(missingKind({ kind: "pull" }, "/acme/web/pull/7"), "pull");
9+ // Anything else a loader put there is ignored.
10+ assert.equal(missingKind({ kind: "secret" }, "/acme/web"), "project");
11+ assert.equal(missingKind("Issue not found.", "/acme/web/issues/3"), "issue");
12+});
13+
14+test("without one, the shape of the address decides", () => {
15+ const cases: [string, MissingKind][] = [
16+ ["/acme", "workspace"],
17+ ["/acme/web", "project"],
18+ ["/acme/web/issues/12", "issue"],
19+ ["/acme/web/pull/12", "pull"],
20+ ["/acme/web/issues", "page"],
21+ ["/acme/web/settings", "page"],
22+ ["/acme/-/billing", "page"],
23+ ["/u/ada", "person"],
24+ ["/explore/nothing", "page"],
25+ ["/", "page"],
26+ ];
27+ for (const [path, kind] of cases) assert.equal(missingKind(null, path), kind, path);
28+});
29+
30+const KINDS: [MissingKind, string][] = [
31+ ["project", "project"],
32+ ["issue", "issue"],
33+ ["pull", "pull request"],
34+ ["workspace", "workspace"],
35+ ["page", "page"],
36+];
37+
38+test("signed out: sign in to see it, which may be private or missing", () => {
39+ for (const [kind, noun] of KINDS) {
40+ const copy = notFoundCopy(kind, null);
41+ assert.equal(copy.title, `Sign in to see this ${noun}`);
42+ assert.equal(copy.body, "It may be private, or it may not exist.");
43+ assert.equal(copy.signIn, true);
44+ assert.equal(copy.signedInAs, null);
45+ assert.equal(copy.askOwner, false);
46+ }
47+});
48+
49+test("signed in: it does not exist or you cannot see it, and who you are", () => {
50+ for (const [kind, noun] of KINDS) {
51+ const copy = notFoundCopy(kind, "ada");
52+ assert.equal(copy.title, `This ${noun} doesn't exist, or you don't have access to it`);
53+ assert.equal(copy.signIn, false);
54+ assert.equal(copy.signedInAs, "ada");
55+ assert.equal(copy.askOwner, true);
56+ }
57+});
58+
59+test("a missing person is simply no one, signed in or not", () => {
60+ for (const viewer of [null, "ada"]) {
61+ const copy = notFoundCopy("person", viewer);
62+ assert.equal(copy.title, "No one goes by that name");
63+ assert.equal(copy.signIn, false);
64+ assert.equal(copy.askOwner, false);
65+ }
66+});
67+
68+test("the page depends only on the kind and the viewer", () => {
69+ // The same inputs for a private project and a missing one: the loader
70+ // throws the same kind, at the same address, so the copy is identical.
71+ const privateOne = notFoundCopy(missingKind({ kind: "project" }, "/acme/secret"), null);
72+ const missingOne = notFoundCopy(missingKind({ kind: "project" }, "/acme/secret"), null);
73+ assert.deepEqual(privateOne, missingOne);
74+});
+87−0
1+/**
2+ * What a not-found page says. A private project, issue or workspace and one
3+ * that does not exist must look the same to someone who cannot see it, so
4+ * the words depend only on what kind of thing the address names and on who
5+ * is looking, never on whether it exists.
6+ */
7+
8+/** The kind of thing an address names. */
9+export type MissingKind = "project" | "issue" | "pull" | "workspace" | "person" | "page";
10+
11+const KINDS: readonly MissingKind[] = ["project", "issue", "pull", "workspace", "person", "page"];
12+
13+/** Words for each kind, as a sentence uses them. */
14+const NOUN: Record<MissingKind, string> = {
15+ project: "project",
16+ issue: "issue",
17+ pull: "pull request",
18+ workspace: "workspace",
19+ person: "person",
20+ page: "page",
21+};
22+
23+/** Names that are pages of g1t itself, never a workspace. */
24+const RESERVED = new Set([
25+ "settings", "explore", "search", "new", "u", "pricing", "avatars", "workspaces", "login", "logout",
26+ "register", "verify", "forgot", "reset", "device", "oauth",
27+]);
28+
29+/**
30+ * The kind a loader said it could not find (`data({ kind }, { status: 404 })`),
31+ * else the kind the address names. Only the shape of the address is used.
32+ */
33+export function missingKind(errorData: unknown, pathname: string): MissingKind {
34+ const said = (errorData as { kind?: unknown } | null | undefined)?.kind;
35+ if (typeof said === "string" && (KINDS as readonly string[]).includes(said)) return said as MissingKind;
36+ const parts = pathname.split("/").filter(Boolean);
37+ if (parts[0] === "u" && parts.length === 2) return "person";
38+ if (parts.length === 0 || RESERVED.has(parts[0]!)) return "page";
39+ if (parts.length === 1) return "workspace";
40+ if (parts[1] === "-") return "page";
41+ if (parts.length === 2) return "project";
42+ if (parts[2] === "issues" && parts.length === 4 && /^\d+$/.test(parts[3]!)) return "issue";
43+ if (parts[2] === "pull" && parts.length === 4 && /^\d+$/.test(parts[3]!)) return "pull";
44+ return "page";
45+}
46+
47+export type NotFoundCopy = {
48+ title: string;
49+ body: string;
50+ /** Who is signed in, for "Signed in as". Null when no one is. */
51+ signedInAs: string | null;
52+ /** Offer to sign in and come back. */
53+ signIn: boolean;
54+ /** Tell them to ask a workspace owner for access. */
55+ askOwner: boolean;
56+};
57+
58+/** What the page says for this kind of thing, to whoever is looking. */
59+export function notFoundCopy(kind: MissingKind, username: string | null | undefined): NotFoundCopy {
60+ // People are public: a name either belongs to someone or it does not.
61+ if (kind === "person") {
62+ return {
63+ title: "No one goes by that name",
64+ body: "Check the spelling, or search for them.",
65+ signedInAs: null,
66+ signIn: false,
67+ askOwner: false,
68+ };
69+ }
70+ const noun = NOUN[kind];
71+ if (!username) {
72+ return {
73+ title: `Sign in to see this ${noun}`,
74+ body: "It may be private, or it may not exist.",
75+ signedInAs: null,
76+ signIn: true,
77+ askOwner: false,
78+ };
79+ }
80+ return {
81+ title: `This ${noun} doesn't exist, or you don't have access to it`,
82+ body: "",
83+ signedInAs: username,
84+ signIn: false,
85+ askOwner: true,
86+ };
87+}
+2−2
88
99 import { type Result, type Role, type User, type Viewer, httpStatus } from "@g1t/contracts";
1010
11+import { safeNext } from "./next";
1112 import { identity } from "./services.server";
1213
1314 const SESSION_COOKIE = "g1t_session";
8687 * `next` cannot redirect off g1t.
8788 */
8889 export function nextPath(request: Request): string {
89− const next = new URL(request.url).searchParams.get("next") ?? "/";
90− return next.startsWith("/") && !next.startsWith("//") ? next : "/";
90+ return safeNext(new URL(request.url).searchParams.get("next"));
9191 }
9292
9393 /** `Set-Cookie` value that starts a session. */
+19−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import { trialClosed } from "./trial.ts";
5+
6+const base = { usedMicros: 0, limitMicros: 1_000_000, endsAt: null };
7+
8+test("an open trial says nothing", () => {
9+ assert.equal(trialClosed({ ...base, open: true, reason: null }, "acme"), null);
10+ assert.equal(trialClosed(null, "acme"), null);
11+});
12+
13+test("a spent trial and a given-out month say so", () => {
14+ assert.equal(trialClosed({ ...base, open: false, reason: "used" }, "acme"), "acme has used its trial credit on g1t.");
15+ assert.equal(
16+ trialClosed({ ...base, open: false, reason: "pool", waitsUntil: "2026-11-01T00:00:00Z" }, "acme"),
17+ "This month's free trials are all given out; new ones start on November 1.",
18+ );
19+});
+23−0
1+/**
2+ * How the site speaks of a workspace's trial credit. Pure, so it can be
3+ * tested.
4+ */
5+
6+import type { Trial } from "@g1t/contracts";
7+
8+/** "November 1", in UTC, when new trials start again. */
9+export function resumesOn(at: string): string {
10+ return new Date(at).toLocaleDateString("en-US", { month: "long", day: "numeric", timeZone: "UTC" });
11+}
12+
13+/** Why the trial cannot pay for anything now, in a sentence, or null when it can. */
14+export function trialClosed(trial: Trial | null | undefined, workspace: string): string | null {
15+ if (!trial || trial.open) return null;
16+ if (trial.reason === "used") return `${workspace} has used its trial credit on g1t.`;
17+ if (trial.reason === "pool")
18+ return trial.waitsUntil
19+ ? `This month's free trials are all given out; new ones start on ${resumesOn(trial.waitsUntil)}.`
20+ : "This month's free trials are all given out; new ones start next month.";
21+ if (trial.reason === "ended") return "The free allowance on g1t's models has ended.";
22+ return null;
23+}
+38−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import { readCookie } from "./mission.ts";
5+import { chosenWorkspace, rememberWorkspace, workspaceFor } from "./workspace-choice.ts";
6+
7+const mine = [{ slug: "flagon-io" }, { slug: "syntaqx" }];
8+
9+test("the chosen workspace holds while you are a member, else the first", () => {
10+ assert.equal(chosenWorkspace(mine, "syntaqx")?.slug, "syntaqx");
11+ assert.equal(chosenWorkspace(mine, "SYNTAQX")?.slug, "syntaqx");
12+ assert.equal(chosenWorkspace(mine, "someone-else")?.slug, "flagon-io");
13+ assert.equal(chosenWorkspace(mine, null)?.slug, "flagon-io");
14+ assert.equal(chosenWorkspace([], "syntaqx"), null);
15+});
16+
17+test("a project in another workspace does not change which one you are in", () => {
18+ // In Flagon, looking at syntaqx/g1t: still Flagon.
19+ assert.equal(workspaceFor(mine, "flagon-io", { owner: "syntaqx", repo: "g1t" })?.slug, "flagon-io");
20+ // A public project somewhere else entirely: still Flagon.
21+ assert.equal(workspaceFor(mine, "flagon-io", { owner: "acme", repo: "web" })?.slug, "flagon-io");
22+ // Mission control: still Flagon.
23+ assert.equal(workspaceFor(mine, "flagon-io", {})?.slug, "flagon-io");
24+});
25+
26+test("a workspace's own pages are about that workspace", () => {
27+ assert.equal(workspaceFor(mine, "flagon-io", { owner: "syntaqx" })?.slug, "syntaqx");
28+ // Not yours: the chosen one.
29+ assert.equal(workspaceFor(mine, "flagon-io", { owner: "acme" })?.slug, "flagon-io");
30+});
31+
32+test("the cookie reads back what was remembered", () => {
33+ const set = rememberWorkspace("Flagon-IO", true);
34+ assert.match(set, /^g1t_ws=flagon-io; Path=\/; Max-Age=31536000; SameSite=Lax; Secure$/);
35+ assert.equal(readCookie("a=1; g1t_ws=flagon-io; b=2", "g1t_ws"), "flagon-io");
36+ assert.equal(readCookie(null, "g1t_ws"), null);
37+ assert.equal(readCookie("g1t_ws=%E0%A4%A", "g1t_ws"), null);
38+});
+39−0
1+/**
2+ * Which of your workspaces the sidebar and Mission control are about.
3+ *
4+ * It changes only when you choose one: from the switcher, or by opening a
5+ * workspace's own pages. Looking at a project in another workspace, or a
6+ * public one, leaves it alone, so coming back finds everything as it was.
7+ */
8+
9+export const WORKSPACE_COOKIE = "g1t_ws";
10+
11+type Membership = { slug: string };
12+
13+/** The chosen workspace if you still belong to it, else your first. */
14+export function chosenWorkspace<M extends Membership>(memberships: M[], chosen: string | null | undefined): M | null {
15+ const wanted = chosen?.trim().toLowerCase();
16+ return memberships.find((m) => m.slug.toLowerCase() === wanted) ?? memberships[0] ?? null;
17+}
18+
19+/**
20+ * The workspace a page is about: the one in its address when it is one of
21+ * yours and the page is the workspace's own (not a project in it), else the
22+ * chosen one.
23+ */
24+export function workspaceFor<M extends Membership>(
25+ memberships: M[],
26+ chosen: string | null | undefined,
27+ params: { owner?: string; repo?: string },
28+): M | null {
29+ if (params.owner && !params.repo) {
30+ const here = memberships.find((m) => m.slug.toLowerCase() === params.owner!.toLowerCase());
31+ if (here) return here;
32+ }
33+ return chosenWorkspace(memberships, chosen);
34+}
35+
36+/** The Set-Cookie header that remembers a choice, for a year. */
37+export function rememberWorkspace(slug: string, secure: boolean): string {
38+ return `${WORKSPACE_COOKIE}=${encodeURIComponent(slug.toLowerCase())}; Path=/; Max-Age=31536000; SameSite=Lax${secure ? "; Secure" : ""}`;
39+}
+36−12
2222 Scripts,
2323 ScrollRestoration,
2424 type ShouldRevalidateFunctionArgs,
25+ useLocation,
2526 useParams,
2627 useRouteLoaderData,
2728 useSubmit,
4243 DropdownMenuTrigger,
4344 } from "./components/ui/dropdown-menu";
4445 import { AppShell, Progress, type ShellData } from "./components/shell";
46+import { readCookie } from "./lib/mission";
47+import { WORKSPACE_COOKIE, workspaceFor } from "./lib/workspace-choice";
48+import { NotFound } from "./components/not-found";
49+import { usesAppShell } from "./lib/chrome";
4550 import { CommandPalette, type PaletteCommand, usePaletteShortcut } from "./components/command-palette";
4651 import { billing, projects, work } from "./lib/services.server";
4752 import { getViewer, roleIn, viewerMiddleware } from "./lib/session.server";
6570
6671 export const middleware: Route.MiddlewareFunction[] = [viewerMiddleware];
6772
68−export async function loader({ context, params }: Route.LoaderArgs) {
73+export async function loader({ context, params, request }: Route.LoaderArgs) {
6974 const user = getViewer(context);
70− return { user, shell: user ? await shellFor(user, params) : null };
75+ const chosen = readCookie(request.headers.get("cookie"), WORKSPACE_COOKIE);
76+ return { user, shell: user ? await shellFor(user, params, chosen) : await visitorShell(params) };
77+}
78+
79+/**
80+ * The sidebar for someone not signed in: nothing of their own, only the
81+ * open counts of the project being looked at. One call, and only in a
82+ * project; it answers the same for a private project as a missing one.
83+ */
84+async function visitorShell(params: { owner?: string; repo?: string }): Promise<ShellData> {
85+ const path = params.owner && params.repo ? { namespace: params.owner, name: params.repo } : null;
86+ const counts = path ? await work.counts(path, null) : null;
87+ return {
88+ workspace: null,
89+ repos: [],
90+ repo: path && counts?.ok ? { ...path, member: false, issues: counts.value.issues, pulls: counts.value.pulls } : null,
91+ limit: null,
92+ monthUsageMicros: null,
93+ };
7194 }
7295
7396 /**
85108 }
86109
87110 /**
88− * The sidebar: the workspace being looked at if they belong to it, else
89− * their first; its repositories; and the repository being looked at.
111+ * The sidebar: the workspace you chose (lib/workspace-choice.ts), or the
112+ * one whose own pages these are; its projects; and the repository being
113+ * looked at. A project in another workspace does not switch it.
90114 */
91115 async function shellFor(
92116 user: User,
93117 params: { owner?: string; repo?: string },
118+ chosen: string | null,
94119 ): Promise<ShellData> {
95120 const memberships = user.workspaces ?? [];
96− const here = params.owner ? memberships.find((m) => m.slug === params.owner?.toLowerCase()) : undefined;
97− const workspace = here ?? memberships[0] ?? null;
121+ const workspace = workspaceFor(memberships, chosen, params);
98122 const path = params.owner && params.repo ? { namespace: params.owner, name: params.repo } : null;
99123 const now = new Date();
100124 const monthStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)).toISOString();
376400 // Undefined when the root loader itself failed.
377401 const root = useRouteLoaderData<typeof loader>("root");
378402 const user = root?.user;
403+ const { pathname } = useLocation();
379404 const banner = user && !user.verified && (
380405 <Form
381406 method="post"
401426 <Links />
402427 </head>
403428 <body className="flex min-h-screen flex-col">
404− {user && root?.shell ? (
405− <AppShell user={user} shell={root.shell} banner={banner}>
429+ {root?.shell && usesAppShell(pathname, user != null) ? (
430+ <AppShell user={user ?? null} shell={root.shell} banner={banner}>
406431 {children}
407432 </AppShell>
408433 ) : (
431456 let stack: string | undefined;
432457
433458 if (isRouteErrorResponse(error)) {
434− if (error.status === 404) {
435− title = "Page not found";
436− details = "There is nothing at this address, or you do not have access to it.";
437− } else if (typeof error.data === "string" && error.data) {
459+ // The same page for something private and something missing.
460+ if (error.status === 404) return <NotFound data={error.data} />;
461+ if (typeof error.data === "string" && error.data) {
438462 details = error.data;
439463 }
440464 } else if (import.meta.env.DEV && error instanceof Error) {
+8−7
3939 import type { Route } from "./+types/home";
4040 import { page } from "../lib/meta";
4141 import { CHANGELOG, changelogHref } from "../lib/changelog";
42+import { WORKSPACE_COOKIE, chosenWorkspace } from "../lib/workspace-choice";
43+import { trialClosed } from "../lib/trial";
4244 import {
4345 type ActivityItem,
4446 type Need,
201203 const seen = nextSeen(readCookie(cookies, SEEN_COOKIE), now);
202204 const tz = readCookie(cookies, TZ_COOKIE);
203205 const memberships = viewer.workspaces ?? [];
204− const slug = memberships[0]?.slug ?? null;
206+ // The workspace you chose, as the sidebar shows it (lib/workspace-choice.ts).
207+ const slug = chosenWorkspace(memberships, readCookie(cookies, WORKSPACE_COOKIE))?.slug ?? null;
205208 const mine = new Set(memberships.map((m) => m.slug.toLowerCase()));
206209 const username = viewer.username;
207210
958961 ? {
959962 done: true,
960963 title: "Try g1t's agents free",
961− about: `This workspace has ${dollars(trial.limitMicros - trial.usedMicros)} of its free ${dollars(trial.limitMicros)} left on g1t's models.`,
964+ about: trial.granted
965+ ? `This workspace has ${dollars(trial.limitMicros - trial.usedMicros)} of its ${dollars(trial.limitMicros)} trial credit left, for g1t's models and sandboxes.`
966+ : `This workspace gets ${dollars(trial.limitMicros)} of trial credit, for g1t's models and sandboxes, the first time its agents work.`,
962967 to: workspace ? `/${workspace}/-/integrations` : null,
963968 action: "Connect",
964969 }
974979 done: false,
975980 title: "Connect a model",
976981 about: `${
977− trial?.reason === "used"
978− ? "This workspace has used its free allowance on g1t's models. "
979− : trial?.reason === "pool" || trial?.reason === "ended"
980− ? "The free allowance on g1t's models has ended. "
981− : ""
982+ trialClosed(trial, workspace ?? "This workspace") ? `${trialClosed(trial, workspace ?? "This workspace")} ` : ""
982983 }Agents need a model to think with. Connect your Anthropic or OpenAI key, or any compatible endpoint.`,
983984 to: workspace ? `/${workspace}/-/integrations` : null,
984985 action: "Connect",
+6−2
11 import { redirect } from "react-router";
22
33 import type { Route } from "./+types/logout";
4−import { assertSameOrigin, endSession } from "../lib/session.server";
4+import { assertSameOrigin, endSession, nextPath } from "../lib/session.server";
55
6+/**
7+ * Signs out, then goes home, or to `next` (a path on g1t only): "Switch
8+ * account" signs out to the sign-in page, which brings them back.
9+ */
610 export async function action({ request }: Route.ActionArgs) {
711 assertSameOrigin(request);
8− throw redirect("/", {
12+ throw redirect(nextPath(request), {
913 headers: { "set-cookie": await endSession(request) },
1014 });
1115 }
+188−24
11 import { ArrowUpRight } from "lucide-react";
22
3−import { DEPLOYMENTS_ALLOWANCE, MICROS_PER_DOLLAR, type Price } from "@g1t/contracts";
3+import { DEPLOYMENTS_ALLOWANCE, type FeaturePlan, type FreeTier, MICROS_PER_DOLLAR, type Price } from "@g1t/contracts";
44
55 import type { Route } from "./+types/pricing";
66 import { page } from "../lib/meta";
1010 export function meta(args: Route.MetaArgs) {
1111 return page(args, {
1212 title: "Pricing · g1t",
13− description: "g1t passes its costs through: what Cloudflare and model providers charge g1t, plus 20%. No seats.",
13+ description:
14+ "g1t passes its costs through: what Cloudflare and model providers charge g1t, plus 20%. Public repositories are free. No seats, ever.",
1415 });
1516 }
1617
3233 return price.unit === "second" ? `${money(micros * 60)} a minute` : `${money(micros)} per ${price.unit}`;
3334 }
3435
36+/** Whole dollars when they are whole. */
37+function dollars(micros: number): string {
38+ const d = micros / MICROS_PER_DOLLAR;
39+ return Number.isInteger(d) ? `$${d}` : `$${d.toFixed(2)}`;
40+}
41+
42+function gigabytes(bytes: number): string {
43+ return `${Math.round((bytes / 1e9) * 10) / 10} GB`;
44+}
45+
46+/** What the page says when billing cannot be reached: the published defaults. */
47+const DEFAULT_FREE: FreeTier = {
48+ trialWorkspaceMicros: 1_000_000,
49+ trialMonthlyPoolMicros: 40_000_000,
50+ ossPoolMicros: 10_000_000,
51+ ossRepoMicros: 1_000_000,
52+ freePrivateStorageBytes: 1_000_000_000,
53+ auditRetentionDays: 30,
54+ minChargeMicros: 5_000_000,
55+};
56+
57+const DEFAULT_PLANS: FeaturePlan[] = [
58+ {
59+ feature: "team",
60+ title: "Team",
61+ monthlyCents: 2000,
62+ includes: [
63+ "$5 of usage credit each month, drawn first by the month's usage at cost plus 20%. Unused credit does not roll over.",
64+ "50 GB of private repository storage, rather than 1 GB",
65+ "The audit log kept for 1 year, rather than 30 days",
66+ "Everyone in the workspace, at one price: never per person",
67+ ],
68+ overage: "Usage past the credit is charged as it is without the plan: at cost plus 20%.",
69+ },
70+ {
71+ feature: "deployments",
72+ title: "Deployments",
73+ monthlyCents: 500,
74+ includes: [
75+ `${DEPLOYMENTS_ALLOWANCE.apps} apps deployed at once, production and previews together`,
76+ `${DEPLOYMENTS_ALLOWANCE.buildSeconds / 60} build minutes`,
77+ `${DEPLOYMENTS_ALLOWANCE.requests / 1e6} million requests`,
78+ `${DEPLOYMENTS_ALLOWANCE.cpuMs / 1e6} million CPU milliseconds`,
79+ `${DEPLOYMENTS_ALLOWANCE.customDomains} custom domains, with certificates`,
80+ ],
81+ overage: "Usage past that is charged at Cloudflare's price plus 20%.",
82+ },
83+];
84+
85+/** The ways to reach g1t about an enterprise account. */
86+const ENTERPRISE_MAIL = "mailto:billing@g1t.sh?subject=Enterprise%20billing%20for%20g1t";
87+
3588 const HOW = [
3689 {
3790 title: "Our cost, passed through",
47100 },
48101 {
49102 title: "No seats, ever",
50− body: "Add as many people and agents as you like. A workspace pays for what it uses, and for the features it turns on.",
103+ body: "Add as many people and agents as you like. A workspace pays for what it uses, and for the plans it turns on, each a flat price for the whole workspace.",
104+ },
105+ {
106+ title: "Free where something pays for it",
107+ body: "Public repositories are free, and their agents' compute comes from a capped open-source pool g1t pays for. New workspaces get a small trial from a monthly budget. Nothing free is an open-ended allowance.",
51108 },
52109 {
53110 title: "Limits that protect both of us",
54111 body: "Usage not yet paid for can only go so far: $3 for a new workspace, growing with what it pays. With a card on file, g1t charges it as you near the limit, so work that is paid for never stops. Owners can set a lower limit of their own.",
55− },
56− {
57− title: "Enterprise billing",
58− body: "One bill, one limit and one set of terms for several workspaces. Write to us to set one up.",
59112 },
60113 ];
61114
62115 export default function Pricing({ loaderData }: Route.ComponentProps) {
63116 const { book, free } = loaderData;
64117 const checked = book?.prices.map((p) => p.checkedAt).filter((at): at is string => !!at).sort().at(-1);
118+ const tier = book?.free ?? DEFAULT_FREE;
119+ const plans = book?.plans?.length ? book.plans : DEFAULT_PLANS;
65120 return (
66121 <main className="mx-auto max-w-4xl px-4 py-12">
67122 <p className="text-sm font-medium text-accent">Pricing</p>
135190 </table>
136191 </div>
137192
138− <h2 className="mt-14 text-xl font-semibold tracking-tight">Features</h2>
139− <p className="mt-1 text-sm text-muted">Turned on per workspace with a monthly plan. Never free.</p>
193+ <h2 className="mt-14 text-xl font-semibold tracking-tight">Free, and what pays for it</h2>
194+ <p className="mt-1 text-sm text-muted">
195+ Hosting, git, issues, pull requests, review, search, the API and MCP cost nothing. What runs for you is metered,
196+ and these pay for some of it first, each from a fixed budget.
197+ </p>
198+ <div className="mt-4 overflow-x-auto rounded-xl border border-line">
199+ <table className="w-full min-w-[36rem] text-left text-sm">
200+ <thead className="border-b border-line text-xs text-muted">
201+ <tr>
202+ <th className="px-4 py-2.5 font-medium">What is free</th>
203+ <th className="px-4 py-2.5 font-medium">Paid for by</th>
204+ </tr>
205+ </thead>
206+ <tbody className="divide-y divide-line">
207+ <tr>
208+ <td className="px-4 py-3">
209+ <p className="font-medium">Public repositories and open source</p>
210+ <p className="text-xs text-faint">
211+ Hosting, issues, pull requests and search are never charged. Agents' sandbox time and model cost on a
212+ public repository come from the pool, up to {dollars(tier.ossRepoMicros)} a month per repository.
213+ </p>
214+ </td>
215+ <td className="px-4 py-3 text-muted">
216+ g1t's open-source pool, {dollars(tier.ossPoolMicros)} a month in all. When it or a repository's share is
217+ spent, the workspace pays as usual.
218+ </td>
219+ </tr>
220+ {tier.trialWorkspaceMicros > 0 && (
221+ <tr>
222+ <td className="px-4 py-3">
223+ <p className="font-medium">A trial for each new workspace</p>
224+ <p className="text-xs text-faint">
225+ {dollars(tier.trialWorkspaceMicros)} of usage credit, once, given the first time it uses something.
226+ </p>
227+ </td>
228+ <td className="px-4 py-3 text-muted">
229+ A trial budget of {dollars(tier.trialMonthlyPoolMicros)} a month. When a month's is given out, new
230+ trials start again on the 1st.
231+ </td>
232+ </tr>
233+ )}
234+ <tr>
235+ <td className="px-4 py-3">
236+ <p className="font-medium">Private storage</p>
237+ <p className="text-xs text-faint">
238+ {gigabytes(tier.freePrivateStorageBytes)} per workspace; past it, at the storage price above.
239+ </p>
240+ </td>
241+ <td className="px-4 py-3 text-muted">g1t, as part of the free core: at most {gigabytes(tier.freePrivateStorageBytes)} of storage a workspace, about $0.50 a month at Cloudflare's price.</td>
242+ </tr>
243+ </tbody>
244+ </table>
245+ </div>
246+ <p className="mt-3 text-sm text-muted">
247+ No card is charged less than {dollars(tier.minChargeMicros)}, so a payment's fee is never most of it. Smaller
248+ amounts carry over to the next invoice.
249+ </p>
250+
251+ <h2 className="mt-14 text-xl font-semibold tracking-tight">Plans</h2>
252+ <p className="mt-1 text-sm text-muted">
253+ Turned on by an owner, per workspace, at one flat price a month. Never per person.
254+ </p>
255+ <div className="mt-4 grid gap-4 sm:grid-cols-2">
256+ {plans.map((plan) => (
257+ <section key={plan.feature} className="flex flex-col rounded-xl border border-line bg-surface p-5">
258+ <div className="flex flex-wrap items-baseline justify-between gap-2">
259+ <h3 className="font-medium">{plan.title}</h3>
260+ <p>
261+ <span className="text-2xl font-semibold">${plan.monthlyCents / 100}</span>{" "}
262+ <span className="text-sm text-muted">/ month per workspace</span>
263+ </p>
264+ </div>
265+ <ul className="mt-3 space-y-1.5 text-sm text-muted">
266+ {plan.includes.map((line) => (
267+ <li key={line} className="flex gap-2">
268+ <span aria-hidden className="text-accent">
269+ ·
270+ </span>
271+ {line}
272+ </li>
273+ ))}
274+ </ul>
275+ <p className="mt-3 text-xs text-faint">{plan.overage}</p>
276+ </section>
277+ ))}
278+ </div>
279+
280+ <h2 className="mt-14 text-xl font-semibold tracking-tight">Enterprise</h2>
281+ <p className="mt-1 text-sm text-muted">
282+ For organizations that would rather we run it for several teams, on terms set with them.
283+ </p>
140284 <section className="mt-4 rounded-xl border border-line bg-surface p-5">
141− <div className="flex flex-wrap items-baseline justify-between gap-2">
142− <h3 className="font-medium">Deployments</h3>
143− <p>
144− <span className="text-2xl font-semibold">$5</span> <span className="text-sm text-muted">/ month</span>
145− </p>
146− </div>
147− <p className="mt-1 text-sm text-muted">
148− Includes {DEPLOYMENTS_ALLOWANCE.apps} apps up at once, {(DEPLOYMENTS_ALLOWANCE.requests / 1e6).toLocaleString()}{" "}
149− million requests, {(DEPLOYMENTS_ALLOWANCE.cpuMs / 1e6).toLocaleString()} million CPU milliseconds and{" "}
150− {DEPLOYMENTS_ALLOWANCE.customDomains} custom domains a month;
151− builds, and usage past that, at the prices above.
152− </p>
285+ <ul className="grid gap-3 text-sm sm:grid-cols-2">
286+ <li>
287+ <p className="font-medium">Consolidated invoicing</p>
288+ <p className="text-muted">One monthly invoice for every workspace, one limit, paid by card or bank transfer.</p>
289+ </li>
290+ <li>
291+ <p className="font-medium">Custom terms</p>
292+ <p className="text-muted">A limit of your own, discounts on usage, and terms until a date, set with you.</p>
293+ </li>
294+ <li>
295+ <p className="font-medium">Audit log export</p>
296+ <p className="text-muted">Every action by people and agents, as CSV or JSON, kept a year on Team.</p>
297+ </li>
298+ <li>
299+ <p className="font-medium">
300+ Single sign-on <span className="ml-1 rounded bg-raised px-1.5 py-0.5 text-xs text-muted">Coming</span>
301+ </p>
302+ <p className="text-muted">Sign in through your identity provider. Not available yet.</p>
303+ </li>
304+ </ul>
305+ <a
306+ href={ENTERPRISE_MAIL}
307+ className="mt-5 inline-flex items-center gap-1.5 rounded-md border border-line px-3 py-1.5 text-sm hover:border-line-strong"
308+ >
309+ Write to us about enterprise
310+ <ArrowUpRight size={14} />
311+ </a>
153312 </section>
154313
155314 <h2 className="mt-14 text-xl font-semibold tracking-tight">Price changes</h2>
161320 const before = change.oldCostMicros * (100 + (change.oldMarkupPercent ?? change.markupPercent));
162321 const after = change.newCostMicros * (100 + change.markupPercent);
163322 const up = after > before;
323+ const fresh = after === before;
164324 return (
165325 <li key={`${change.meter}-${change.createdAt}`} className="px-4 py-3 text-sm">
166326 <p>
167327 <span className="font-medium">{title}</span>{" "}
168− <span className={up ? "text-warn" : "text-accent"}>
169− {up ? "up" : "down"} {Math.abs((after / before - 1) * 100).toFixed(1)}%
170− </span>
328+ {fresh ? (
329+ <span className="text-muted">new</span>
330+ ) : (
331+ <span className={up ? "text-warn" : "text-accent"}>
332+ {up ? "up" : "down"} {Math.abs((after / before - 1) * 100).toFixed(1)}%
333+ </span>
334+ )}
171335 </p>
172336 <p className="mt-0.5 text-xs text-faint">
173337 {change.reason} · <TimeAgo at={change.createdAt} />
+2−1
3434 PullIcon,
3535 plainText,
3636 } from "../../components/work";
37+import { notFound } from "../../lib/not-found.server";
3738 import { identity, integrations, work } from "../../lib/services.server";
3839 import { assertSameOrigin, getViewer, requireUser, roleIn } from "../../lib/session.server";
3940
7374 // Issues and pull requests share numbers; this one may be a pull request.
7475 const pull = await work.getPull(path, number, viewer);
7576 if (pull.ok) throw redirect(`/${params.owner}/${params.repo}/pull/${number}`);
76− throw new Response("Issue not found.", { status: 404 });
77+ throw notFound("issue");
7778 }
7879 const { issue } = found.value;
7980 return {
+18−72
1−import { Box, CircleDot, Code2, GitPullRequest, History, LayoutGrid, ListTree, Lock, Rocket, Settings } from "lucide-react";
2−import { Link, NavLink, Outlet, data, useLocation, useRouteLoaderData } from "react-router";
1+import { Box, Lock } from "lucide-react";
2+import { Link, NavLink, Outlet, useLocation, useRouteLoaderData } from "react-router";
33
44 import type { Project } from "@g1t/contracts";
55
66 import type { Route } from "./+types/layout";
77 import { page } from "../../lib/meta";
88 import { type Tab as PageTab, tabsFor } from "../../lib/project-nav";
9−import { Pill, TabLink as Tab } from "../../components/ui";
9+import { Pill } from "../../components/ui";
10+import { notFound } from "../../lib/not-found.server";
1011 import { redirectIfRenamed } from "../../lib/renamed.server";
1112 import { projects, repos, work } from "../../lib/services.server";
1213 import { getViewer, roleIn, unwrap } from "../../lib/session.server";
2627 if (!repo.ok && !found.ok) {
2728 // Under a workspace's old name, after a rename: the project is at the new one.
2829 await redirectIfRenamed(request, params.owner);
29− throw data(null, { status: 404 });
30+ throw notFound("project");
3031 }
3132 let project: Project | null = found.ok ? found.value : null;
3233 // A repository made a moment ago, before its project: make it now.
107108 const current = (tab: PageTab) =>
108109 [tab.path, ...(tab.also ?? [])].some((path) => rest === path || rest.startsWith(`${path}/`));
109110 return (
110− <nav aria-label="Views" className="-mb-px flex gap-1 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
111+ <nav aria-label="Views" className="relative -mb-px flex gap-1 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
111112 {tabs.map((tab) => (
112113 <NavLink
113114 key={tab.path}
137138 }
138139
139140 export default function ProjectLayout({ loaderData }: Route.ComponentProps) {
140− const { repo, project, open, member } = loaderData;
141+ const { repo, project, member } = loaderData;
141142 const base = `/${repo.namespace}/${repo.name}`;
142− const signedIn = useRouteLoaderData("root")?.user != null;
143143 const description = project?.description ?? repo.description;
144144 const { pathname } = useLocation();
145145 const tabs = tabsFor(pathname.slice(base.length + 1), member);
146− if (signedIn) {
147− return (
148− <>
149− <div className="border-b border-line">
150− <div className={`mx-auto max-w-6xl px-4 sm:px-6 ${tabs ? "pt-3" : "py-3"}`}>
151− <Header
152− project={project}
153− isPrivate={repo.isPrivate}
154− namespace={repo.namespace}
155− name={repo.name}
156− description={description}
157− />
158− <Topics topics={repo.topics} />
159− {tabs && (
160− <div className="mt-3">
161− <PageTabs base={base} tabs={tabs} />
162− </div>
163− )}
164− </div>
165− </div>
166− <div className="mx-auto max-w-6xl px-4 py-6 sm:px-6">
167− <Outlet />
168− </div>
169− </>
170− );
171− }
146+ // Everyone, signed in or not, finds the project's pages in the sidebar;
147+ // the page shows its name, and the views of the page it is on as tabs.
172148 return (
173149 <>
174− {/* The project's own header band, under the site header. */}
175− <div className="border-b border-line bg-surface/60">
176− <div className="mx-auto max-w-6xl px-4 pt-6">
150+ <div className="border-b border-line">
151+ <div className={`mx-auto max-w-6xl px-4 sm:px-6 ${tabs ? "pt-3" : "py-3"}`}>
177152 <Header
178153 project={project}
179154 isPrivate={repo.isPrivate}
180155 namespace={repo.namespace}
181156 name={repo.name}
182− description={null}
183− large
157+ description={description}
184158 />
185− {description && <p className="mt-2 max-w-2xl text-sm text-muted">{description}</p>}
186159 <Topics topics={repo.topics} />
187− <nav className="mt-5 flex gap-6 overflow-x-auto">
188− <Tab to={base} end icon={<LayoutGrid size={15} />}>
189− Overview
190− </Tab>
191− <Tab to={`${base}/code`} also={`${base}/tree`} icon={<Code2 size={15} />}>
192− Code
193− </Tab>
194− <Tab to={`${base}/issues`} icon={<CircleDot size={15} />} count={open.issues}>
195− Issues
196− </Tab>
197− <Tab to={`${base}/pulls`} also={`${base}/pull`} icon={<GitPullRequest size={15} />} count={open.pulls}>
198− Pull requests
199− </Tab>
200− <Tab to={`${base}/commits`} icon={<History size={15} />}>
201− Commits
202− </Tab>
203− {member && (
204− <Tab to={`${base}/plans`} icon={<ListTree size={15} />}>
205− Plan
206− </Tab>
207− )}
208− {member && (
209− <Tab to={`${base}/deployments`} icon={<Rocket size={15} />}>
210− Deployments
211− </Tab>
212− )}
213− {member && (
214− <Tab to={`${base}/settings`} icon={<Settings size={15} />}>
215− Settings
216− </Tab>
217− )}
218− </nav>
160+ {tabs && (
161+ <div className="mt-3">
162+ <PageTabs base={base} tabs={tabs} />
163+ </div>
164+ )}
219165 </div>
220166 </div>
221− <div className="mx-auto max-w-6xl px-4 py-6">
167+ <div className="mx-auto max-w-6xl px-4 py-6 sm:px-6">
222168 <Outlet />
223169 </div>
224170 </>
+2−1
6767 } from "../../components/work";
6868 import { CatchUpProgress, ChecksSection, ConflictsSection, MergeabilityRow, runIdOf } from "../../components/merge-box";
6969 import { CATCH_UP_TIMEOUT_MS } from "../../lib/catch-up";
70+import { notFound } from "../../lib/not-found.server";
7071 import { actions, deployments, identity, projects, repos, work } from "../../lib/services.server";
7172 import { assertSameOrigin, getViewer, requireUser } from "../../lib/session.server";
7273
118119 // Issues and pull requests share numbers; this one may be an issue.
119120 const issue = await work.getIssue(path, number, viewer);
120121 if (issue.ok) throw redirect(`/${params.owner}/${params.repo}/issues/${number}`);
121− throw new Response("Pull request not found.", { status: 404 });
122+ throw notFound("pull");
122123 }
123124 const { pull } = found.value;
124125 const range = pullComparison(pull);
+3−2
2020 Pencil,
2121 } from "lucide-react";
2222 import type { ReactNode } from "react";
23−import { Form, Link, data, redirect, useNavigate, useSearchParams } from "react-router";
23+import { Form, Link, redirect, useNavigate, useSearchParams } from "react-router";
2424
2525 import type { Authored, AuthoredItem, AuthoredSort, AuthoredState, Profile } from "@g1t/contracts";
2626
3030 import { RadioGroup, RadioOption } from "../components/ui/radio-group";
3131 import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../components/ui/select";
3232 import { IssueIcon, PullIcon } from "../components/work";
33+import { notFound } from "../lib/not-found.server";
3334 import { identity, repos, work } from "../lib/services.server";
3435 import { getViewer } from "../lib/session.server";
3536
6970 const url = new URL(request.url);
7071 const username = params.username.toLowerCase();
7172 const profile = await identity.profile(username);
72− if (!profile) throw data(null, { status: 404 });
73+ if (!profile) throw notFound("person");
7374 // One address per person: `/u/Ada` is `/u/ada`.
7475 if (params.username !== profile.username) {
7576 throw redirect(`/u/${profile.username}${url.search}`);
+9−3
66 import { AuditTable } from "../../components/audit";
77 import { Button, ButtonLink, EmptyState, Field, Input } from "../../components/ui";
88 import { type AuditFilters, filterHref, parseFilters, toQuery } from "../../lib/audit";
9−import { auditPage } from "../../lib/audit.server";
9+import { auditPage, auditRetention } from "../../lib/audit.server";
1010 import { repos } from "../../lib/services.server";
1111 import { requireUser, roleIn } from "../../lib/session.server";
1212
3737 if (!role) throw data("Only members of this workspace can read its audit log.", { status: 404 });
3838 const filters = parseFilters(new URL(request.url).searchParams);
3939 const { visibility: _, ...query } = toQuery(workspace, { kind: "all" }, filters, PAGE_SIZE);
40− const [found, projects] = await Promise.all([
40+ const [found, projects, retention] = await Promise.all([
4141 auditPage(viewer, query),
4242 repos.list(viewer, { namespace: workspace }).catch(() => []),
43+ auditRetention(workspace),
4344 ]);
4445 return {
4546 workspace,
4849 entries: found?.entries ?? [],
4950 next: found?.next ?? null,
5051 projects: projects.map((repo) => repo.name),
52+ retention,
5153 };
5254 }
5355
7577 "w-full rounded-md border border-line bg-bg px-3 py-2 text-sm outline-none transition-colors hover:border-line-strong focus:border-accent-dim";
7678
7779 export default function WorkspaceAudit({ loaderData }: Route.ComponentProps) {
78− const { workspace, role, filters, entries, next, projects } = loaderData;
80+ const { workspace, role, filters, entries, next, projects, retention } = loaderData;
7981 const base = `/${workspace}/-/audit`;
8082 const filtered = Object.entries(filters).some(([key, value]) => key !== "before" && value);
8183 const exportHref = (format: "csv" | "json") => filterHref(`${base}/export`, { ...filters, before: "" }) + `${filtered ? "&" : "?"}format=${format}`;
8890 {role === "owner"
8991 ? " As an owner you see the whole workspace."
9092 : " As a member you see what was done to the workspace's projects, and what was done by you or on your behalf."}
93+ {retention != null &&
94+ (retention >= 365
95+ ? ` The log goes back ${retention === 365 ? "a year" : `${retention} days`}, on the Team plan.`
96+ : ` The log goes back ${retention} days; the Team plan keeps a year.`)}
9197 </p>
9298
9399 <Form method="get" className="mt-6 rounded-xl border border-line bg-surface p-4">
+64−12
1−import { ArrowUpRight, CreditCard, FileText, Receipt, Rocket } from "lucide-react";
1+import { ArrowUpRight, CreditCard, FileText, Receipt, Rocket, Users } from "lucide-react";
22 import { Form, Link, data, redirect, useNavigation } from "react-router";
33
44 import {
55 DEPLOYMENTS_ALLOWANCE,
66 MICROS_PER_DOLLAR,
77 type DeployUsage,
8+ type Entitlements,
89 type Feature,
910 type FeatureState,
1011 type Limit,
5455 const group: "day" | "project" = url.searchParams.get("group") === "project" ? "project" : "day";
5556 const now = new Date();
5657 const monthStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)).toISOString();
57− const [account, statement, features, deployUsage, limit, invoices, thisMonth, allTime] = await Promise.all([
58+ const [account, statement, features, deployUsage, limit, invoices, thisMonth, allTime, entitlements] = await Promise.all([
5859 billing.account(slug, viewer),
5960 billing.statement(slug, viewer, url.searchParams.get("month"), group),
6061 billing.features(slug, viewer),
6364 billing.invoices(slug, viewer).catch(() => null),
6465 billing.usage(slug, viewer, monthStart).catch(() => null),
6566 billing.usage(slug, viewer, "1970-01-01T00:00:00.000Z").catch(() => null),
67+ billing.entitlements(slug).catch(() => null),
6668 ]);
6769 return {
6870 slug,
7981 total: allTime?.ok ? allTime.value.spentMicros : null,
8082 added: allTime?.ok ? allTime.value.addedMicros : null,
8183 },
84+ entitlements,
8285 added: url.searchParams.has("added"),
8386 subscribed: url.searchParams.has("subscribed"),
8487 };
140143 }
141144
142145 export default function WorkspaceBilling({ loaderData, actionData }: Route.ComponentProps) {
143− const { slug, role, account, statement, group, spent, features, deployUsage, limit, invoices, added, subscribed } = loaderData;
146+ const { slug, role, account, statement, group, spent, features, deployUsage, limit, invoices, entitlements, added, subscribed } =
147+ loaderData;
144148 const { status } = account;
145149 const paying = useNavigation().state === "submitting";
146150 const empty = account.balanceMicros <= 0;
242246
243247 <h2 className="font-medium">Plans</h2>
244248 <p className="mt-1 max-w-2xl text-sm text-muted">
245− Paid features are turned on per workspace with a monthly plan. They are never free, including while the rest of
246− g1t is.
249+ Plans are turned on per workspace, at one flat price a month for everyone in it: never per person. They are
250+ charged including while the rest of g1t is free.
247251 </p>
248252 {subscribed && <p className="mt-3 text-sm text-accent">Payment received. The plan is on.</p>}
249253 <div className="mt-5 space-y-4">
256260 live={account.status.live}
257261 busy={paying}
258262 usage={state.plan.feature === "deployments" ? deployUsage : null}
263+ entitlements={entitlements}
259264 />
260265 ))}
261266 </div>
371376 </Link>
372377 , the provider bills you for the model, and a run here is charged only its sandbox time.
373378 </li>
379+ <li>
380+ Work on public repositories is paid for by g1t's open-source pool first, up to a monthly cap per
381+ repository; the statement says so on each line it paid.
382+ </li>
383+ <li>
384+ No card is charged less than {dollars(entitlements?.minChargeMicros ?? 5 * MICROS_PER_DOLLAR, 0)}: smaller
385+ amounts carry over to the next invoice.
386+ </li>
374387 <li>No seats: add as many people and agents as you like.</li>
375388 </ul>
376389 <div className="mt-4 space-y-1.5 border-t border-line pt-4 text-sm">
401414 live,
402415 busy,
403416 usage,
417+ entitlements,
404418 }: {
405419 state: FeatureState;
406420 owner: boolean;
408422 live: boolean;
409423 busy: boolean;
410424 usage: DeployUsage | null;
425+ entitlements: Entitlements | null;
411426 }) {
412427 const { plan, subscription } = state;
413428 const ending = subscription?.status === "canceling";
414429 const owed = subscription?.status === "past_due";
430+ const team = plan.feature === "team";
431+ const Icon = team ? Users : Rocket;
415432 return (
416433 <section
417434 className={`rounded-xl border p-5 ${
418− state.on && subscription
435+ (state.on && subscription) || state.included
419436 ? "border-accent/40 bg-accent/5"
420437 : owed
421438 ? "border-warn/40 bg-warn/5"
425442 <div className="flex flex-wrap items-start justify-between gap-4">
426443 <div className="min-w-0">
427444 <h3 className="flex flex-wrap items-center gap-2 font-medium">
428− <Rocket size={15} className="text-accent" />
445+ <Icon size={15} className="text-accent" />
429446 {plan.title}
447+ {state.included && (
448+ <span className="rounded-full bg-accent/15 px-2 py-0.5 text-xs text-accent">Included, no charge</span>
449+ )}
430450 {state.on && subscription && (
431451 <span className="rounded-full bg-accent/15 px-2 py-0.5 text-xs text-accent">
432452 {ending ? "Ends " : "On"}
441461 {owed && <span className="rounded-full bg-warn/15 px-2 py-0.5 text-xs text-warn">Payment failed</span>}
442462 </h3>
443463 <p className="mt-1 text-sm text-muted">
444− Every pull request gets a live preview on g1t.page, and the default branch goes to production on merge.
464+ {team
465+ ? "For a workspace that works here every day: a monthly usage credit, more private storage and a longer audit log, at one price for everyone in it."
466+ : "Every pull request gets a live preview on g1t.page, and the default branch goes to production on merge."}
445467 </p>
446468 </div>
447469 <p className="shrink-0 text-right">
449471 ${(plan.monthlyCents / 100).toFixed(0)}
450472 </span>
451473 <span className="text-sm text-muted"> / month</span>
474+ {team && <span className="block text-xs text-faint">per workspace</span>}
452475 </p>
453476 </div>
454477 <ul className="mt-4 grid gap-1.5 text-sm text-muted sm:grid-cols-2">
460483 ))}
461484 </ul>
462485 <p className="mt-3 text-xs text-faint">{plan.overage}</p>
463− {state.on && usage && <DeployMeter usage={usage} />}
464− {!enabled ? (
486+ {state.on && usage && <DeployMeter usage={usage} entitlements={entitlements} />}
487+ {team && state.on && entitlements?.team && <TeamMeter entitlements={entitlements} />}
488+ {state.included ? (
489+ <p className="mt-4 text-sm text-muted">
490+ {plan.title} is on for this workspace at no charge, under terms g1t set with it.
491+ </p>
492+ ) : !enabled ? (
465493 <p className="mt-4 text-sm text-muted">Payments are not set up on this g1t, so {plan.title} is already on.</p>
466494 ) : !owner ? (
467495 !state.on && <p className="mt-4 text-sm text-muted">An owner can turn it on.</p>
491519 );
492520 }
493521
522+/** This month's Team credit: what of it the month's usage has drawn. */
523+function TeamMeter({ entitlements }: { entitlements: Entitlements }) {
524+ const used = entitlements.teamCreditUsedMicros;
525+ const of = entitlements.teamCreditMicros;
526+ return (
527+ <div className="mt-4 rounded-lg border border-line bg-bg/40 p-4">
528+ <div className="flex justify-between gap-4 text-sm">
529+ <span className="text-muted">Credit used this month</span>
530+ <span className="tabular-nums">
531+ {dollars(used)} <span className="text-faint">of {dollars(of)}</span>
532+ </span>
533+ </div>
534+ <Meter used={used} of={of} state={used >= of ? "warning" : "ok"} />
535+ <p className="mt-3 text-xs text-faint">
536+ Usage draws on the credit first, at cost plus 20%; past it, usage is charged as usual. It starts again on the
537+ 1st, and what is unused does not carry over.
538+ </p>
539+ </div>
540+ );
541+}
542+
494543 /** This month's use of the Deployments plan against what it includes. */
495−function DeployMeter({ usage }: { usage: DeployUsage }) {
544+function DeployMeter({ usage, entitlements }: { usage: DeployUsage; entitlements: Entitlements | null }) {
496545 const a = DEPLOYMENTS_ALLOWANCE;
546+ const buildMinutes = (entitlements?.buildSecondsIncluded ?? a.buildSeconds) / 60;
497547 const rows: [string, number, number, (n: number) => string][] = [
498548 ["Apps up at once (most this month)", usage.peakApps, a.apps, (n) => String(n)],
549+ ["Build minutes", Math.ceil(usage.buildSeconds / 60), buildMinutes, (n) => n.toLocaleString("en-US")],
499550 ["Requests", usage.requests, a.requests, (n) => n.toLocaleString("en-US")],
500551 ["CPU milliseconds", usage.cpuMs, a.cpuMs, (n) => n.toLocaleString("en-US")],
501552 ];
521572 ))}
522573 </ul>
523574 <p className="mt-3 text-xs text-faint">
524− Builds: {Math.ceil(usage.buildSeconds / 60)} min, {dollars(usage.buildMicros, 4)} at cost.
575+ Builds past the included minutes are charged by the second; this month's builds cost g1t{" "}
576+ {dollars(usage.buildMicros, 4)} in all.
525577 {usage.countedAt ? " Requests and CPU time are counted every few minutes." : " Requests are counted once apps get visits."}
526578 </p>
527579 </div>
+5−6
2424
2525 import type { Route } from "./+types/integrations";
2626 import { page } from "../../lib/meta";
27+import { trialClosed } from "../../lib/trial";
2728 import { MODEL_CATALOG, ModelCatalog, ModelProviderFields, ProviderMark, ProviderTiles, Routing } from "../../components/model-providers";
2829 import { Avatar, Button, CopyLine, ErrorText, Field, Input, TimeAgo } from "../../components/ui";
2930 import { CheckboxOption } from "../../components/ui/checkbox";
203204 </span>
204205 <p className="text-sm text-muted">
205206 <span className="font-medium text-fg">Choose how your agents reach a model.</span>{" "}
206− {trial?.reason === "used"
207− ? `${slug} has used its free allowance on g1t's models.`
208− : trial?.reason === "pool" || trial?.reason === "ended"
209− ? "The free allowance on g1t's models has ended."
210− : `g1t's hosted models are not open to ${slug}.`}{" "}
207+ {trialClosed(trial, slug) ?? `g1t's hosted models are not open to ${slug}.`}{" "}
211208 Connect a provider of your own below and your agents start at once, billed by that provider.
212209 </p>
213210 </div>
217214 <p className="flex items-center gap-2 rounded-xl border border-line bg-surface px-4 py-3 text-sm text-muted">
218215 <CheckCircle2 size={15} className="shrink-0 text-merged" />
219216 {trial?.open
220− ? `All work runs on g1t's models, free: ${slug} has ${dollars(Math.max(0, trial.limitMicros - trial.usedMicros))} of its ${dollars(trial.limitMicros)} allowance left${trial.endsAt ? `, until ${new Date(trial.endsAt).toLocaleDateString("en-US", { month: "long", day: "numeric", timeZone: "America/Los_Angeles" })}` : ""}. Connect a provider of your own for more, or to choose models.`
217+ ? trial.granted
218+ ? `All work runs on g1t's models, paid by ${slug}'s trial credit first: ${dollars(Math.max(0, trial.limitMicros - trial.usedMicros))} of ${dollars(trial.limitMicros)} left. Connect a provider of your own for more, or to choose models.`
219+ : `All work runs on g1t's models. ${slug} gets ${dollars(trial.limitMicros)} of trial credit the first time its agents work. Connect a provider of your own for more, or to choose models.`
221220 : free
222221 ? "All work runs on g1t's models, free while g1t is being built out. Connect a provider of your own to choose models and pay for them there."
223222 : `All work runs on g1t's models, charged to your credit at cost plus ${marginPercent}%. Connect a provider of your own to choose models and pay for them there.`}
+16−46
1−import { CreditCard, KeyRound, LayoutGrid, Plug, Plus, Settings, Users } from "lucide-react";
2−import { Outlet, data, useLocation, useRouteLoaderData } from "react-router";
1+import { Plus } from "lucide-react";
2+import { Outlet, data, useLocation } from "react-router";
33
44 import type { Route } from "./+types/layout";
55 import { page } from "../../lib/meta";
6−import { Avatar, ButtonLink, Pill, TabLink } from "../../components/ui";
6+import { Avatar, ButtonLink, Pill } from "../../components/ui";
7+import { notFound } from "../../lib/not-found.server";
78 import { redirectIfRenamed } from "../../lib/renamed.server";
9+import { rememberWorkspace } from "../../lib/workspace-choice";
810 import { identity } from "../../lib/services.server";
911 import { getViewer, roleIn } from "../../lib/session.server";
1012
1719 if (!workspace) {
1820 // A workspace's old name, after a rename: its pages are at the new one.
1921 await redirectIfRenamed(request, params.owner);
20− throw data(null, { status: 404 });
22+ throw notFound("workspace");
2123 }
22− return { workspace, role: roleIn(getViewer(context), workspace.slug) };
24+ const role = roleIn(getViewer(context), workspace.slug);
25+ // Opening one of your workspaces makes it the one you are in.
26+ if (!role) return { workspace, role };
27+ const secure = new URL(request.url).protocol === "https:";
28+ return data({ workspace, role }, { headers: { "Set-Cookie": rememberWorkspace(workspace.slug, secure) } });
2329 }
2430
2531 /** A workspace's own pages, each with its title and what it is for. */
7177
7278 export default function WorkspaceLayout({ loaderData }: Route.ComponentProps) {
7379 const { workspace, role } = loaderData;
74− const base = `/${workspace.slug}`;
75− const signedIn = useRouteLoaderData("root")?.user != null;
76− // The sidebar finds the workspace's pages for someone signed in, so its
77− // pages need a title, not the workspace's whole header again.
80+ // The sidebar finds the workspace's pages, for everyone, so its pages
81+ // need a title, not the workspace's whole header again.
7882 const page = PAGES[useLocation().pathname.split("/-/")[1]?.split("/")[0] ?? ""];
79− if (signedIn && page) {
83+ if (page) {
8084 return (
8185 <div className="mx-auto max-w-5xl px-4 py-10 sm:px-8">
8286 <header className="mb-8 border-b border-line pb-6">
8993 }
9094 return (
9195 <>
92− {/* The workspace's own header band, under the site header. */}
96+ {/* The workspace's own header band. */}
9397 <div className="border-b border-line bg-surface/60">
94− <div className="mx-auto max-w-6xl px-4 pt-8">
98+ <div className="mx-auto max-w-6xl px-4 pt-8 pb-8">
9599 <div className="flex flex-wrap items-center gap-4">
96100 <Avatar name={workspace.slug} image={workspace.avatar} size={52} square />
97101 <div className="min-w-0 grow">
112116 </div>
113117 {workspace.description && (
114118 <p className="mt-4 max-w-2xl text-sm text-muted">{workspace.description}</p>
115− )}
116− {signedIn ? (
117− <div className="pb-8" />
118− ) : (
119− <nav className="mt-6 flex flex-wrap gap-x-6">
120− <TabLink to={base} end icon={<LayoutGrid size={15} />}>
121− Overview
122− </TabLink>
123− {role && (
124− <>
125− <TabLink
126− to={`${base}/-/people`}
127− icon={<Users size={15} />}
128− count={workspace.memberCount}
129− >
130− Members
131− </TabLink>
132− <TabLink to={`${base}/-/tokens`} icon={<KeyRound size={15} />}>
133− Access tokens
134− </TabLink>
135− <TabLink to={`${base}/-/billing`} icon={<CreditCard size={15} />}>
136− Billing
137− </TabLink>
138− <TabLink to={`${base}/-/integrations`} icon={<Plug size={15} />}>
139− Integrations
140− </TabLink>
141− </>
142− )}
143− {role === "owner" && (
144− <TabLink to={`${base}/-/settings`} icon={<Settings size={15} />}>
145− Settings
146− </TabLink>
147− )}
148− </nav>
149119 )}
150120 </div>
151121 </div>
+18−1
1515 const statement = await billing.statement(params.owner, viewer, month, "day");
1616 if (!statement.ok) throw data(null, { status: 404 });
1717 const kinds = [...new Set(statement.value.groups.flatMap((group) => group.lines.map((line) => line.kind)))];
18− const rows: string[][] = [["date", "kind", "description", "project", "pull request", "model", "by", "amount (USD)"]];
18+ const rows: string[][] = [
19+ [
20+ "date",
21+ "kind",
22+ "description",
23+ "project",
24+ "pull request",
25+ "model",
26+ "by",
27+ "amount (USD)",
28+ "paid by Team credit (USD)",
29+ "paid by trial credit (USD)",
30+ "paid by open-source pool (USD)",
31+ ],
32+ ];
1933 for (const kind of kinds) {
2034 let before: string | null = null;
2135 for (let page = 0; page < MAX_PAGES; page++) {
5165 entry.createdBy ?? "",
5266 // Charges positive, as on the statement.
5367 (-entry.amountMicros / MICROS_PER_DOLLAR).toFixed(6),
68+ ((entry.creditMicros ?? 0) / MICROS_PER_DOLLAR).toFixed(6),
69+ ((entry.trialMicros ?? 0) / MICROS_PER_DOLLAR).toFixed(6),
70+ ((entry.ossMicros ?? 0) / MICROS_PER_DOLLAR).toFixed(6),
5471 ];
5572 }
5673
+4−3
154154
155155 Each workspace decides how its agents reach a model: its own provider
156156 (connected under Integrations, billed by the provider) works for any
157−workspace; until October 22, 2026, every workspace also gets $1 of free
158−agent time on g1t's hosted models, no key needed. Without either, these
159−calls answer with a message saying so.
157+workspace; every new workspace also gets $1 of trial credit for g1t's
158+hosted models and sandboxes, no key needed, from a budget that renews
159+each month. Work on public repositories is paid by g1t's open-source pool
160+first. Without either, these calls answer with a message saying so.
160161
161162 - **Hand off an outcome:** `POST {repo}/plans` with `brief`: what should be
162163 true when the work is done. A planner reads the repository and proposes
+193−13
3434 pub free: bool,
3535 }
3636
37−/// `trial`: the free allowance on g1t's hosted models for a workspace that
38−/// is not otherwise open to them, so people can try g1t's agents without a
39−/// key of their own. Each workspace gets a few dollars of model cost, out
40−/// of one pool, until an end date. Returns `Trial`.
37+/// `trial`: a workspace's trial credit, so people can try g1t (its agents on
38+/// g1t's hosted models among it) without a key or a card of their own. Each
39+/// new workspace gets one grant of usage credit (`TRIAL_WORKSPACE_MICROS`),
40+/// made when it first uses something, out of a pool for everyone that
41+/// resets each calendar month (`TRIAL_MONTHLY_POOL_MICROS`). When this
42+/// month's pool is given out, new grants wait for the next month. Returns
43+/// `Trial`.
4144 #[derive(Debug, Serialize, Deserialize)]
4245 #[serde(rename_all = "camelCase")]
4346 pub struct TrialArgs {
5154 #[derive(Clone, Debug, Serialize, Deserialize)]
5255 #[serde(rename_all = "camelCase")]
5356 pub struct Trial {
54− /// Whether its agents may use g1t's hosted models on the allowance now.
57+ /// Whether its agents may use g1t's hosted models on the trial now: it
58+ /// has credit left, or this month's pool can still grant it some.
5559 pub open: bool,
56− /// What its runs on g1t's models have cost, in millionths of a dollar.
60+ /// What the trial has paid for so far, in millionths of a dollar.
5761 pub used_micros: i64,
62+ /// Its grant, or what it would be granted.
5863 pub limit_micros: i64,
59− /// RFC 3339; when the allowance ends for everyone.
64+ /// No longer used: the trial does not end on a date. Kept for older
65+ /// readers; always null.
6066 pub ends_at: Option<String>,
61− /// Why it is closed: `off` (no allowance), `ended`, `used` (this
62− /// workspace's is spent) or `pool` (everyone's is).
67+ /// Why it is closed: `off` (no trials), `used` (this workspace's grant
68+ /// is spent) or `pool` (this month's grants are all given out; see
69+ /// `waits_until`). `ended` is no longer sent.
6370 pub reason: Option<String>,
71+ /// Whether the workspace has its grant already.
72+ #[serde(default)]
73+ pub granted: bool,
74+ /// RFC 3339: when a workspace waiting for a grant can get one, the
75+ /// first of next month. Only with reason `pool`.
76+ #[serde(default)]
77+ pub waits_until: Option<String>,
6478 }
6579
6680 /// A workspace's standing.
162176 /// lines apart.
163177 #[serde(default, skip_serializing_if = "Option::is_none")]
164178 pub workspace: Option<String>,
179+ /// For usage: what the Team plan's monthly credit paid of it. The
180+ /// entry's `amount_micros` is what is left to pay.
181+ #[serde(default)]
182+ pub credit_micros: i64,
183+ /// For usage: what the workspace's trial credit paid of it.
184+ #[serde(default)]
185+ pub trial_micros: i64,
186+ /// For usage: what g1t's open-source pool paid of it.
187+ #[serde(default)]
188+ pub oss_micros: i64,
165189 }
166190
167191 fn g1t() -> String {
320344 pub enum Feature {
321345 /// Previews per pull request and production on g1t.page.
322346 Deployments,
347+ /// The Team plan: a flat price per workspace, never per person, with a
348+ /// monthly usage credit, more private storage and a longer audit log.
349+ Team,
323350 }
324351
325352 impl Feature {
326− pub const ALL: [Feature; 1] = [Feature::Deployments];
353+ pub const ALL: [Feature; 2] = [Feature::Team, Feature::Deployments];
327354
328355 pub fn as_str(self) -> &'static str {
329356 match self {
330357 Feature::Deployments => "deployments",
358+ Feature::Team => "team",
331359 }
332360 }
333361
338366 pub fn title(self) -> &'static str {
339367 match self {
340368 Feature::Deployments => "Deployments",
369+ Feature::Team => "Team",
341370 }
342371 }
343372 }
356385 pub const MICROS_PER_MILLION_CPU_MS: i64 = 20_000;
357386 /// What one second of a build's sandbox costs g1t (Cloudflare
358387 /// Containers, standard-1: half a vCPU, 4 GiB, 8 GB disk), rounded up.
359− /// Builds are not in the allowance: each is charged at this plus the
360− /// margin.
361388 pub const MICROS_PER_BUILD_SECOND: i64 = 21;
389+ /// Build time the plan includes each month: 200 minutes, about $0.25 of
390+ /// the plan's price at cost. Builds past it are charged by the second
391+ /// at cost plus the margin. Billing's `DEPLOYMENTS_BUILD_SECONDS`
392+ /// overrides it.
393+ pub const BUILD_SECONDS: u32 = 12_000;
362394 /// Custom domains across the workspace (Cloudflare for SaaS custom
363395 /// hostnames); each one past these is charged by the month.
364396 pub const CUSTOM_DOMAINS: u32 = 3;
481513 #[serde(rename_all = "camelCase")]
482514 pub struct NotePendingArgs {
483515 pub workspace: String,
484− /// `deployments`.
516+ /// `deployments`, `security` (scans), `context` (search embeddings) or
517+ /// `storage`. Billing charges `security`, `context` and `storage`
518+ /// itself once the month is over; `deployments` charges its own.
485519 pub source: String,
486520 /// What it cost g1t so far this month, before the margin.
487521 pub cost_micros: i64,
555589 /// The margin on model usage, which is charged at what AI Gateway
556590 /// priced each request at.
557591 pub model_margin_percent: u32,
592+ /// Every plan, as it is sold now.
593+ #[serde(default)]
594+ pub plans: Vec<Plan>,
595+ /// What is free, and what pays for it.
596+ #[serde(default)]
597+ pub free: Option<FreeTier>,
558598 }
559599
600+/// What g1t gives without a plan, each with what pays for it: a capped
601+/// budget, never an open-ended allowance.
602+#[derive(Clone, Debug, Default, Serialize, Deserialize)]
603+#[serde(rename_all = "camelCase")]
604+pub struct FreeTier {
605+ /// Each new workspace's trial credit, once.
606+ pub trial_workspace_micros: i64,
607+ /// Trial grants each month, in all; new trials wait when it is spent.
608+ pub trial_monthly_pool_micros: i64,
609+ /// g1t's open-source pool each month, and any one repository's share.
610+ pub oss_pool_micros: i64,
611+ pub oss_repo_micros: i64,
612+ /// Private repository storage before it is charged.
613+ pub free_private_storage_bytes: i64,
614+ /// Days of audit log without Team.
615+ pub audit_retention_days: u32,
616+ /// The smallest amount a card is charged; less carries over.
617+ pub min_charge_micros: i64,
618+}
619+
560620 /// Who pays: a billing account. Every workspace has one; by default its
561621 /// own. An enterprise account pays for several workspaces at once, as
562622 /// GitHub Enterprise does: one bill, one limit, one set of terms.
577637 #[serde(default)]
578638 pub invoices: Vec<EnterpriseInvoice>,
579639 pub created_at: String,
640+ /// What g1t staff set for the account beyond its terms.
641+ #[serde(default)]
642+ pub allowances: Allowances,
643+}
644+
645+/// Set per account by g1t staff in sudo, on top of its terms.
646+#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
647+#[serde(rename_all = "camelCase")]
648+pub struct Allowances {
649+ /// The Team plan without paying for it, such as for a partner.
650+ /// Comped accounts have it anyway.
651+ #[serde(default)]
652+ pub team: bool,
653+ /// Each of the account's public repositories' monthly cap on g1t's
654+ /// open-source pool, in place of `OSS_REPO_MICROS`. None: the default.
655+ #[serde(default)]
656+ pub oss_repo_micros: Option<i64>,
657+ /// The trial credit each of its workspaces gets, in place of
658+ /// `TRIAL_WORKSPACE_MICROS`, outside the monthly pool. None: the default.
659+ #[serde(default)]
660+ pub trial_micros: Option<i64>,
661+}
662+
663+/// `admin_set_allowances`: the Team plan on or off without charge, and the
664+/// account's share of g1t's pools. Recorded with who and why. Returns
665+/// `Outcome<BillingAccount>`.
666+#[derive(Debug, Serialize, Deserialize)]
667+pub struct AdminSetAllowancesArgs {
668+ pub id: String,
669+ pub allowances: Allowances,
670+ pub note: String,
671+ pub by: String,
672+}
673+
674+/// `entitlements`: what a workspace's plans give it now, for the services
675+/// and pages that apply them (the audit log's retention, private storage,
676+/// the Team credit). Returns `Entitlements`.
677+#[derive(Debug, Serialize, Deserialize)]
678+pub struct EntitlementsArgs {
679+ pub workspace: String,
680+}
681+
682+#[derive(Clone, Debug, Serialize, Deserialize)]
683+#[serde(rename_all = "camelCase")]
684+pub struct Entitlements {
685+ pub workspace: String,
686+ /// Whether the Team plan is on: paid for, comped, or given by g1t.
687+ pub team: bool,
688+ /// How far back the audit log can be read and exported.
689+ pub audit_retention_days: u32,
690+ /// Private repository storage included before it is charged.
691+ pub free_private_storage_bytes: i64,
692+ /// The last daily measure of the workspace's private repositories.
693+ pub private_storage_bytes: i64,
694+ /// The Team credit each month, and what of it is used this month.
695+ pub team_credit_micros: i64,
696+ pub team_credit_used_micros: i64,
697+ /// What g1t's open-source pool paid for the workspace this month.
698+ pub oss_paid_micros: i64,
699+ /// Build time the Deployments plan includes each month, and used.
700+ pub build_seconds_included: u32,
701+ pub build_seconds_used: u32,
702+ /// The smallest amount a card is charged; less carries over.
703+ pub min_charge_micros: i64,
580704 }
581705
582706 #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
825949 /// Charges positive; money in (payments, credits) negative.
826950 pub charged_micros: i64,
827951 pub cost_micros: i64,
952+ /// Of the usage on the line, what was paid for before it was charged:
953+ /// by the Team plan's credit, the trial credit or g1t's open-source
954+ /// pool. Not in `charged_micros`.
955+ #[serde(default)]
956+ pub covered_micros: i64,
828957 }
829958
830959 #[derive(Clone, Debug, Serialize, Deserialize)]
834963 pub paid_micros: i64,
835964 pub cost_micros: i64,
836965 pub entries: u32,
966+ /// What paid for usage before it was charged, one line per source,
967+ /// such as "Paid by g1t's open-source pool".
968+ #[serde(default)]
969+ pub covered: Vec<Covered>,
970+ /// Owed when the month closed but under the minimum charge, so it
971+ /// carries over to the next invoice. Zero when nothing carried.
972+ #[serde(default)]
973+ pub carried_micros: i64,
974+}
975+
976+/// One source that paid for usage before it was charged.
977+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
978+#[serde(rename_all = "camelCase")]
979+pub struct Covered {
980+ /// `team_credit`, `trial` or `oss_pool`.
981+ pub source: String,
982+ /// "Paid by your Team plan's credit", "Paid by your trial credit",
983+ /// "Paid by g1t's open-source pool".
984+ pub label: String,
985+ pub micros: i64,
837986 }
838987
839988 /// `statement_entries`: one statement line's entries, newest first, 50 at
10201169 pub open_invoices_micros: i64,
10211170 /// Follow-ups due today or earlier.
10221171 pub follow_ups_due: u32,
1172+ /// The capped budgets g1t pays from, this month.
1173+ #[serde(default)]
1174+ pub pools: Option<Pools>,
10231175 }
10241176
1177+/// g1t's capped budgets for free usage, this calendar month (UTC).
1178+#[derive(Clone, Debug, Default, Serialize, Deserialize)]
1179+#[serde(rename_all = "camelCase")]
1180+pub struct Pools {
1181+ /// YYYY-MM.
1182+ pub month: String,
1183+ /// Trial grants made this month, against the month's pool.
1184+ pub trial_granted_micros: i64,
1185+ pub trial_pool_micros: i64,
1186+ pub trial_grants: u32,
1187+ /// What the open-source pool paid this month, against its cap.
1188+ pub oss_used_micros: i64,
1189+ pub oss_pool_micros: i64,
1190+ /// Each public repository's monthly cap on the pool.
1191+ pub oss_repo_micros: i64,
1192+}
1193+
10251194 #[derive(Clone, Debug, Serialize, Deserialize)]
10261195 #[serde(rename_all = "camelCase")]
10271196 pub struct KindFigures {
12111380 pub subscription: Option<Subscription>,
12121381 /// Whether the feature works for the workspace now.
12131382 pub on: bool,
1383+ /// On without a plan: comped terms, or given by g1t. Nothing to pay
1384+ /// and nothing to turn off.
1385+ #[serde(default)]
1386+ pub included: bool,
12141387 }
12151388
12161389 /// `features`: every paid feature and the workspace's plan for each.
12821455 pub repo: Option<String>,
12831456 /// Unique to this charge, e.g. `deployments/acme/2026-10`.
12841457 pub reference: String,
1458+ /// For a build: how long it ran. The plan's included build time this
1459+ /// month pays for what it can, and only the rest of `cost_micros` is
1460+ /// charged.
1461+ #[serde(default)]
1462+ pub build_seconds: Option<u32>,
12851463 }
12861464
12871465 #[cfg(test)]
13261504 serde_json::json!("deployments")
13271505 );
13281506 assert_eq!(Feature::parse("deployments"), Some(Feature::Deployments));
1507+ assert_eq!(serde_json::to_value(Feature::Team).unwrap(), serde_json::json!("team"));
1508+ assert_eq!(Feature::parse("team"), Some(Feature::Team));
13291509 assert!(SubscriptionStatus::Canceling.on());
13301510 assert!(!SubscriptionStatus::PastDue.on());
13311511 }
+35−0
670670 pub next: Option<String>,
671671 }
672672
673+/// `visibility`: which of these repositories (`namespace/name`) are
674+/// private, for billing, which pays for work on public ones from g1t's
675+/// open-source pool. A pull request's working copy answers as the
676+/// repository it is a copy of. Unknown paths are left out. Returns
677+/// `Vec<RepoVisibility>`.
678+#[derive(Debug, Default, Serialize, Deserialize)]
679+pub struct VisibilityArgs {
680+ pub paths: Vec<String>,
681+}
682+
683+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
684+pub struct RepoVisibility {
685+ pub path: String,
686+ pub is_private: bool,
687+}
688+
689+/// `storage`: what each workspace's private repositories hold, as far as
690+/// g1t can measure it, for billing's daily storage meter. Returns
691+/// `Vec<WorkspaceStorage>`.
692+///
693+/// The git store does not report a repository's size. What is counted is
694+/// the bytes of every pack pushed through g1t's git endpoints to the
695+/// repository or to its pull requests' working copies. Pushes made from
696+/// agents' sandboxes, which go to the store directly, and imports are not
697+/// counted, so it is a lower bound on what is stored.
698+#[derive(Debug, Default, Serialize, Deserialize)]
699+pub struct StorageArgs {}
700+
701+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
702+pub struct WorkspaceStorage {
703+ pub namespace: String,
704+ pub private_bytes: i64,
705+ pub public_bytes: i64,
706+}
707+
673708 #[cfg(test)]
674709 mod topic_tests {
675710 use super::*;
+132−10
5959 createdAt: string;
6060 /** The workspace the line belongs to, which tells an enterprise's lines apart. */
6161 workspace?: string | null;
62+ /** For usage: what the Team plan's credit paid of it. `amountMicros` is what is left to pay. */
63+ creditMicros?: number;
64+ /** For usage: what the workspace's trial credit paid of it. */
65+ trialMicros?: number;
66+ /** For usage: what g1t's open-source pool paid of it. */
67+ ossMicros?: number;
6268 };
6369
6470 /** What lets a sandbox, and nothing else, report what its run cost. */
100106 /** An enterprise's invoices, newest first. */
101107 invoices?: EnterpriseInvoice[];
102108 createdAt: string;
109+ /** What g1t staff set for the account beyond its terms. */
110+ allowances?: Allowances;
111+};
112+
113+/** Set per account by g1t staff in sudo, on top of its terms. */
114+export type Allowances = {
115+ /** The Team plan without paying for it. Comped accounts have it anyway. */
116+ team: boolean;
117+ /** Each public repository's monthly cap on g1t's open-source pool; null for the default. */
118+ ossRepoMicros: number | null;
119+ /** Each workspace's trial credit, outside the monthly pool; null for the default. */
120+ trialMicros: number | null;
103121 };
104122
123+/** What a workspace's plans give it now. Mirrors `Entitlements` in `crates/contracts/src/billing.rs`. */
124+export type Entitlements = {
125+ workspace: string;
126+ /** Whether the Team plan is on: paid for, comped, or given by g1t. */
127+ team: boolean;
128+ /** How far back the audit log can be read and exported. */
129+ auditRetentionDays: number;
130+ /** Private repository storage included before it is charged. */
131+ freePrivateStorageBytes: number;
132+ /** The last daily measure of the workspace's private repositories (a lower bound). */
133+ privateStorageBytes: number;
134+ /** The Team credit each month, and what of it is used this month. */
135+ teamCreditMicros: number;
136+ teamCreditUsedMicros: number;
137+ /** What g1t's open-source pool paid for the workspace this month. */
138+ ossPaidMicros: number;
139+ /** Build time the Deployments plan includes each month, and used. */
140+ buildSecondsIncluded: number;
141+ buildSecondsUsed: number;
142+ /** The smallest amount a card is charged; less carries over. */
143+ minChargeMicros: number;
144+};
145+
146+/** g1t's capped budgets for free usage this month. */
147+export type Pools = {
148+ month: string;
149+ trialGrantedMicros: number;
150+ trialPoolMicros: number;
151+ trialGrants: number;
152+ ossUsedMicros: number;
153+ ossPoolMicros: number;
154+ ossRepoMicros: number;
155+};
156+
105157 export type AccountSummary = {
106158 account: PayingAccount;
107159 limit: Limit;
157209 groups: {
158210 key: string;
159211 label: string;
160− lines: { kind: string; count: number; chargedMicros: number; costMicros: number }[];
212+ /** `coveredMicros`: what the Team credit, the trial or the open-source pool paid, not in `chargedMicros`. */
213+ lines: { kind: string; count: number; chargedMicros: number; costMicros: number; coveredMicros?: number }[];
161214 chargedMicros: number;
162215 }[];
163− totals: { chargedMicros: number; paidMicros: number; costMicros: number; entries: number };
216+ totals: {
217+ chargedMicros: number;
218+ paidMicros: number;
219+ costMicros: number;
220+ entries: number;
221+ /** What paid for usage before it was charged, such as "Paid by g1t's open-source pool". */
222+ covered?: { source: "team_credit" | "trial" | "oss_pool" | string; label: string; micros: number }[];
223+ /** Owed when the month closed but under the minimum charge: on the next invoice. */
224+ carriedMicros?: number;
225+ };
164226 };
165227
166228 export type MonthFigures = { month: string; chargedMicros: number; costMicros: number; paidMicros: number };
217279 declined: number;
218280 openInvoicesMicros: number;
219281 followUpsDue: number;
282+ /** g1t's capped budgets for free usage, this month. */
283+ pools?: Pools | null;
220284 };
221285
222286 /** A customer's Stripe billing page, for staff to send them. */
243307 accounts(query?: string): Promise<AccountSummary[]>;
244308 account(id: string): Promise<Result<AccountDetail>>;
245309 setTerms(id: string, terms: Terms, by: string): Promise<Result<PayingAccount>>;
310+ /** Team on or off without charge, and the account's share of the pools. Needs a note. */
311+ setAllowances(id: string, allowances: Allowances, note: string, by: string): Promise<Result<PayingAccount>>;
246312 createEnterprise(name: string, workspaces: string[], by: string): Promise<Result<PayingAccount>>;
247313 attach(workspace: string, account: string | null, by: string): Promise<Result<PayingAccount>>;
248314 credit(workspace: string, amountMicros: number, note: string, by: string): Promise<Result<LedgerEntry>>;
306372
307373 /** One metered unit: what it costs g1t and what it is sold at; the price follows the cost. */
308374 export type Price = {
309− meter: "sandbox_second" | "build_second" | "app_requests" | "app_cpu" | "app_month" | "custom_domain_month" | string;
375+ meter:
376+ | "sandbox_second"
377+ | "build_second"
378+ | "app_requests"
379+ | "app_cpu"
380+ | "app_month"
381+ | "custom_domain_month"
382+ | "private_storage"
383+ | "embedding_tokens"
384+ | "scan_cpu"
385+ | "scan_rows"
386+ | string;
310387 title: string;
311388 unit: string;
312389 costMicros: number;
329406 createdAt: string;
330407 };
331408
332−export type PriceBook = { prices: Price[]; changes: PriceChange[]; modelMarginPercent: number };
409+export type PriceBook = {
410+ prices: Price[];
411+ changes: PriceChange[];
412+ modelMarginPercent: number;
413+ /** Every plan, as sold now. */
414+ plans?: FeaturePlan[];
415+ /** What is free, and the capped budgets that pay for it. */
416+ free?: FreeTier | null;
417+};
333418
419+/** What g1t gives without a plan; each is paid for by a capped budget. */
420+export type FreeTier = {
421+ /** Each new workspace's trial credit, once. */
422+ trialWorkspaceMicros: number;
423+ /** Trial grants each month, in all; new trials wait when it is spent. */
424+ trialMonthlyPoolMicros: number;
425+ /** g1t's open-source pool each month, and any one repository's share. */
426+ ossPoolMicros: number;
427+ ossRepoMicros: number;
428+ /** Private repository storage before it is charged. */
429+ freePrivateStorageBytes: number;
430+ /** Days of audit log without Team. */
431+ auditRetentionDays: number;
432+ /** The smallest amount a card is charged; less carries over. */
433+ minChargeMicros: number;
434+};
435+
436+/**
437+ * A workspace's trial credit: one grant per workspace, made the first time
438+ * it uses something, out of a pool that resets each calendar month. Mirrors
439+ * `Trial` in `crates/contracts/src/billing.rs`.
440+ */
334441 export type Trial = {
335442 open: boolean;
336443 usedMicros: number;
337444 limitMicros: number;
445+ /** No longer used: trials do not end on a date. */
338446 endsAt: string | null;
339− /** Why it is closed: `off`, `ended`, `used` (this workspace's) or `pool` (everyone's). */
447+ /** Why it is closed: `off`, `used` (this workspace's grant is spent) or `pool` (this month's are given out). */
340448 reason: "off" | "ended" | "used" | "pool" | null;
449+ /** Whether the workspace has its grant already. */
450+ granted?: boolean;
451+ /** With `pool`: when new trials start again, the first of next month. */
452+ waitsUntil?: string | null;
341453 };
342454
343455 /**
346458 * `free` nor the model allowance covers it. Mirrors `Feature` in
347459 * `crates/contracts/src/billing.rs`.
348460 */
349−export type Feature = "deployments";
461+export type Feature = "deployments" | "team";
350462
351463 /** What the Deployments plan includes each month. Mirrors `deployments_allowance`. */
352464 export const DEPLOYMENTS_ALLOWANCE = {
353465 apps: 10,
354466 requests: 1_000_000,
355467 cpuMs: 3_000_000,
468+ /** Build time included: 200 minutes. Billing's `DEPLOYMENTS_BUILD_SECONDS` decides. */
469+ buildSeconds: 12_000,
356470 /** What Cloudflare charges g1t past that, in millionths of a dollar. */
357471 microsPerAppMonth: 20_000,
358472 microsPerMillionRequests: 300_000,
359473 microsPerMillionCpuMs: 20_000,
360− /** One second of a build's sandbox; builds are charged, not included. */
474+ /** One second of a build's sandbox, past the included build time. */
361475 microsPerBuildSecond: 21,
362476 /** Custom domains across the workspace, and what each one past that costs g1t a month. */
363477 customDomains: 3,
392506 subscription: Subscription | null;
393507 /** Whether the feature works for the workspace now. */
394508 on: boolean;
509+ /** On without a plan: comped terms, or given by g1t. Nothing to pay or turn off. */
510+ included?: boolean;
395511 };
396512
397513 export interface BillingApi {
458574 description: string;
459575 repo?: string | null;
460576 reference: string;
577+ /** For a build: how long it ran, so the plan's included build time pays for what it can. */
578+ buildSeconds?: number | null;
461579 }): Promise<Result<boolean>>;
462580 /**
463− * Usage this month to be charged later (app traffic past a plan), so the
464− * workspace's limit counts it now. Replaces the last report.
581+ * What a source cost g1t so far this month, so the workspace's limit
582+ * counts it now. Replaces the last report. Billing charges `context`
583+ * and `security` itself once the month is over; `deployments` charges
584+ * its own.
465585 */
466− notePending(workspace: string, source: "deployments", costMicros: number): Promise<boolean>;
586+ notePending(workspace: string, source: "deployments" | "context" | "security", costMicros: number): Promise<boolean>;
587+ /** What the workspace's plans give it now: Team, audit retention, storage, credit used. */
588+ entitlements(workspace: string): Promise<Entitlements>;
467589 /** Every metered price and the recent changes. Public. */
468590 prices(): Promise<PriceBook>;
469591 /** A workspace's limit, for its members. */
+2−0
264264 setSpendLimit: (actor, workspace, spendLimitMicros, useFullLimit = false) =>
265265 call("set_spend_limit", { actor, workspace, spendLimitMicros, use_full_limit: useFullLimit }),
266266 invoices: (workspace, viewer) => call("invoices", { workspace, viewer }),
267+ entitlements: (workspace) => call("entitlements", { workspace }),
267268 };
268269 }
269270
273274 accounts: (query) => call("admin_accounts", { query: query ?? null }),
274275 account: (id) => call("admin_account", { id }),
275276 setTerms: (id, terms, by) => call("admin_set_terms", { id, terms, by }),
277+ setAllowances: (id, allowances, note, by) => call("admin_set_allowances", { id, allowances, note, by }),
276278 createEnterprise: (name, workspaces, by) => call("admin_create_enterprise", { name, workspaces, by }),
277279 attach: (workspace, account, by) => call("admin_attach", { workspace, account, by }),
278280 credit: (workspace, amountMicros, note, by) => call("admin_credit", { workspace, amount_micros: amountMicros, note, by }),
+93−0
1+-- The Team plan, g1t's capped pools (trials and open source), the minimum
2+-- charge, and meters for what was free by accident: private storage,
3+-- search embeddings and security scans. See src/credits.rs.
4+
5+-- What paid for a usage entry before it was charged: the Team plan's
6+-- monthly credit, the workspace's trial credit, or g1t's open-source
7+-- pool. `amount_micros` stays what the workspace is charged.
8+ALTER TABLE ledger ADD COLUMN credit_micros INTEGER NOT NULL DEFAULT 0;
9+ALTER TABLE ledger ADD COLUMN trial_micros INTEGER NOT NULL DEFAULT 0;
10+ALTER TABLE ledger ADD COLUMN oss_micros INTEGER NOT NULL DEFAULT 0;
11+
12+-- Monthly allowances, drawn down as usage comes in, and new each calendar
13+-- month (UTC):
14+-- team_credit scope = workspace Team credit used, in micros
15+-- build_seconds scope = workspace Deployments build seconds included
16+-- oss_pool scope = '' g1t's open-source pool, in micros
17+-- oss_repo scope = owner/name one public repository's share of it
18+CREATE TABLE allowance_use (
19+ kind TEXT NOT NULL,
20+ scope TEXT NOT NULL,
21+ -- YYYY-MM.
22+ month TEXT NOT NULL,
23+ used INTEGER NOT NULL DEFAULT 0,
24+ PRIMARY KEY (kind, scope, month)
25+);
26+
27+-- Each workspace's one trial grant, made when it first uses something, out
28+-- of the month's pool (`TRIAL_MONTHLY_POOL_MICROS`).
29+CREATE TABLE trial_grants (
30+ workspace TEXT PRIMARY KEY,
31+ -- YYYY-MM of the pool it came from; 'legacy' for grants from before
32+ -- pools reset monthly, 'staff' for ones set in sudo. Only YYYY-MM
33+ -- grants count against a month's pool.
34+ month TEXT NOT NULL,
35+ granted_micros INTEGER NOT NULL,
36+ used_micros INTEGER NOT NULL DEFAULT 0,
37+ created_at TEXT NOT NULL
38+);
39+CREATE INDEX trial_grants_by_month ON trial_grants (month);
40+
41+-- The free allowance that ended on a date becomes these grants, intact:
42+-- each workspace that used it keeps what it had left of its $1.
43+INSERT INTO trial_grants (workspace, month, granted_micros, used_micros, created_at)
44+SELECT workspace, 'legacy', 1000000, MIN(1000000, SUM(COALESCE(cost_micros, 0))), MIN(created_at)
45+FROM ledger
46+WHERE kind = 'usage' AND COALESCE(billed_to, 'g1t') = 'g1t' AND COALESCE(task, '') NOT IN ('sandbox', 'deployments')
47+GROUP BY workspace
48+HAVING SUM(COALESCE(cost_micros, 0)) > 0;
49+
50+-- Set per account in sudo: the Team plan without charge, and its share of
51+-- the pools (null: the default).
52+ALTER TABLE billing_accounts ADD COLUMN team_granted INTEGER NOT NULL DEFAULT 0;
53+ALTER TABLE billing_accounts ADD COLUMN oss_repo_micros INTEGER;
54+ALTER TABLE billing_accounts ADD COLUMN trial_micros INTEGER;
55+
56+-- Usage other services meter through the month (security scans, search
57+-- embeddings) and storage measured daily: what it cost g1t, and when
58+-- billing charged it once the month was over.
59+ALTER TABLE pending_usage ADD COLUMN cost_micros INTEGER NOT NULL DEFAULT 0;
60+ALTER TABLE pending_usage ADD COLUMN charged_at TEXT;
61+-- Months before this one were never charged, and are not now: charging
62+-- starts with this month.
63+UPDATE pending_usage SET charged_at = 'never: before metering'
64+WHERE source <> 'deployments' AND month < '2026-10';
65+
66+-- What each workspace's private repositories held each day, and what was
67+-- free that day (more on Team).
68+CREATE TABLE storage_days (
69+ workspace TEXT NOT NULL,
70+ -- YYYY-MM-DD.
71+ day TEXT NOT NULL,
72+ private_bytes INTEGER NOT NULL,
73+ free_bytes INTEGER NOT NULL,
74+ PRIMARY KEY (workspace, day)
75+);
76+
77+-- month_closes.status gains 'carried': owed less than the minimum charge,
78+-- so it waits for the next invoice.
79+
80+-- The new meters, at Cloudflare's published prices.
81+INSERT INTO prices (meter, title, unit, cost_micros, markup_percent, source, updated_at) VALUES
82+ ('private_storage', 'Private repository storage past the free amount', 'GB-month', 500000, 20, 'list', '2026-10-05T00:00:00Z'),
83+ ('embedding_tokens', 'Search embeddings', 'million tokens', 67000, 20, 'list', '2026-10-05T00:00:00Z'),
84+ ('scan_cpu', 'Security scans: CPU time', 'million CPU ms', 20000, 20, 'list', '2026-10-05T00:00:00Z'),
85+ ('scan_rows', 'Security scans: rows written', 'million rows', 1000000, 20, 'list', '2026-10-05T00:00:00Z')
86+ON CONFLICT (meter) DO NOTHING;
87+
88+INSERT INTO price_changes (id, meter, old_cost_micros, new_cost_micros, markup_percent, reason, created_at) VALUES
89+ ('prc_new_private_storage', 'private_storage', 500000, 500000, 20, 'Now metered: private repository storage past the free amount, at Cloudflare Artifacts'' storage price', '2026-10-05T00:00:00Z'),
90+ ('prc_new_embedding_tokens', 'embedding_tokens', 67000, 67000, 20, 'Now metered: search embeddings, at Workers AI''s price for the embedding model', '2026-10-05T00:00:00Z'),
91+ ('prc_new_scan_cpu', 'scan_cpu', 20000, 20000, 20, 'Now metered at Cloudflare''s prices: security scans, which were placeholder costs', '2026-10-05T00:00:00Z'),
92+ ('prc_new_scan_rows', 'scan_rows', 1000000, 1000000, 20, 'Now metered at Cloudflare''s prices: security scans, which were placeholder costs', '2026-10-05T00:00:00Z')
93+ON CONFLICT (id) DO NOTHING;
+98−2
1616
1717 use g1t_contracts::billing::{
1818 AccountDetail, AccountKind, AccountSummary, AdminAccountArgs, AdminAccountsArgs, AdminAction, AdminAttachArgs,
19− AdminCreateEnterpriseArgs, AdminCreditArgs, AdminSetTermsArgs, BillingAccount, EntryKind, LedgerEntry, Terms,
20− TermsKind, WorkspaceFigures,
19+ AdminCreateEnterpriseArgs, AdminCreditArgs, AdminSetAllowancesArgs, AdminSetTermsArgs, Allowances, BillingAccount,
20+ EntryKind, LedgerEntry, Terms, TermsKind, WorkspaceFigures,
2121 };
2222 use g1t_contracts::time::rfc3339;
2323 use g1t_contracts::{FailureCode, Outcome, new_id};
4343 created_at: String,
4444 #[serde(default)]
4545 billing_email: Option<String>,
46+ #[serde(default)]
47+ team_granted: Option<i64>,
48+ #[serde(default)]
49+ oss_repo_micros: Option<i64>,
50+ #[serde(default)]
51+ trial_micros: Option<i64>,
4652 }
4753
4854 impl AccountRow {
55+ fn allowances(&self) -> Allowances {
56+ Allowances {
57+ team: self.team_granted.unwrap_or(0) != 0,
58+ oss_repo_micros: self.oss_repo_micros,
59+ trial_micros: self.trial_micros,
60+ }
61+ }
62+}
63+
64+impl AccountRow {
4965 fn terms(&self) -> Terms {
5066 let expired = self.terms_until.as_deref().is_some_and(|until| until < rfc3339(now_ms()).as_str());
5167 if expired {
151167 created_at: row.created_at.clone(),
152168 billing_email: row.billing_email.clone(),
153169 invoices: vec![],
170+ allowances: row.allowances(),
154171 }
155172 }
156173
185202 created_at: String::new(),
186203 billing_email: None,
187204 invoices: vec![],
205+ allowances: Allowances::default(),
188206 },
189207 })
190208 }
462480 Ok(Outcome::Ok(self.find_account(&account.id).await?.unwrap_or(account)))
463481 }
464482
483+ /// Team without charge, and the account's share of g1t's pools.
484+ pub(crate) async fn admin_set_allowances(&self, a: AdminSetAllowancesArgs) -> Result<Outcome<BillingAccount>> {
485+ if a.by.trim().is_empty() || a.note.trim().is_empty() {
486+ return Ok(Outcome::fail(FailureCode::Invalid, "Say who is making the change, and why, in the note."));
487+ }
488+ let money = |m: Option<i64>| m.is_none_or(|m| (0..=1_000 * g1t_contracts::billing::MICROS_PER_DOLLAR).contains(&m));
489+ if !money(a.allowances.oss_repo_micros) || !money(a.allowances.trial_micros) {
490+ return Ok(Outcome::fail(FailureCode::Invalid, "A pool share is between $0 and $1,000."));
491+ }
492+ let Some(account) = self.find_account(&a.id).await? else {
493+ return Ok(Outcome::fail(FailureCode::NotFound, "No such account."));
494+ };
495+ let now = rfc3339(now_ms());
496+ let opt = |m: Option<i64>| m.map_or(JsValue::NULL, |m| (m as f64).into());
497+ // A workspace's own account gets a row the first time anything is set.
498+ self.db
499+ .prepare(
500+ "INSERT INTO billing_accounts (id, kind, name, terms_kind, discount_percent, note, created_by, created_at,
501+ team_granted, oss_repo_micros, trial_micros)
502+ VALUES (?1, ?2, ?3, 'standard', 0, '', ?4, ?5, ?6, ?7, ?8)
503+ ON CONFLICT (id) DO UPDATE SET team_granted = ?6, oss_repo_micros = ?7, trial_micros = ?8",
504+ )
505+ .bind(&[
506+ account.id.as_str().into(),
507+ if account.kind == AccountKind::Enterprise { "enterprise" } else { "workspace" }.into(),
508+ account.name.as_str().into(),
509+ a.by.as_str().into(),
510+ now.as_str().into(),
511+ u32::from(a.allowances.team).into(),
512+ opt(a.allowances.oss_repo_micros),
513+ opt(a.allowances.trial_micros),
514+ ])?
515+ .run()
516+ .await?;
517+ // A trial amount from staff replaces each workspace's grant, outside
518+ // the monthly pool; what was used stays used.
519+ if let Some(amount) = a.allowances.trial_micros {
520+ for workspace in &account.workspaces {
521+ self.db
522+ .prepare(
523+ "INSERT INTO trial_grants (workspace, month, granted_micros, used_micros, created_at)
524+ VALUES (?1, 'staff', ?2, 0, ?3)
525+ ON CONFLICT (workspace) DO UPDATE SET month = 'staff', granted_micros = ?2",
526+ )
527+ .bind(&[workspace.as_str().into(), (amount as f64).into(), now.as_str().into()])?
528+ .run()
529+ .await?;
530+ }
531+ }
532+ self.audit(
533+ &account.id,
534+ "allowances",
535+ &format!("{} → {}: {}", describe_allowances(&account.allowances), describe_allowances(&a.allowances), a.note.trim()),
536+ &a.by,
537+ )
538+ .await?;
539+ Ok(Outcome::Ok(self.find_account(&account.id).await?.unwrap_or(account)))
540+ }
541+
465542 pub(crate) async fn admin_create_enterprise(&self, a: AdminCreateEnterpriseArgs) -> Result<Outcome<BillingAccount>> {
466543 let name = a.name.trim();
467544 if name.is_empty() || a.by.trim().is_empty() {
597674 }
598675 }
599676
677+/// Allowances as the audit log reads them.
678+fn describe_allowances(a: &Allowances) -> String {
679+ let mut parts = vec![if a.team { "Team on" } else { "Team off" }.to_owned()];
680+ if let Some(m) = a.oss_repo_micros {
681+ parts.push(format!("open-source share {} a repository", crate::features::dollars(m)));
682+ }
683+ if let Some(m) = a.trial_micros {
684+ parts.push(format!("trial {}", crate::features::dollars(m)));
685+ }
686+ parts.join(", ")
687+}
688+
600689 /// A workspace's figures in `list`, added at the end the first time.
601690 fn figures_for<'a>(list: &'a mut Vec<WorkspaceFigures>, workspace: &str) -> &'a mut WorkspaceFigures {
602691 let i = match list.iter().position(|f| f.workspace == workspace) {
633722 }
634723
635724 #[test]
725+ fn allowances_read_plainly_in_the_audit_log() {
726+ assert_eq!(describe_allowances(&Allowances::default()), "Team off");
727+ let given = Allowances { team: true, oss_repo_micros: Some(5_000_000), trial_micros: Some(2_000_000) };
728+ assert_eq!(describe_allowances(&given), "Team on, open-source share $5.00 a repository, trial $2.00");
729+ }
730+
731+ #[test]
636732 fn each_workspace_gets_one_share() {
637733 let mut list = vec![];
638734 figures_for(&mut list, "acme").charged_micros += 5;
+645−0
1+//! What pays for usage before the workspace does.
2+//!
3+//! Every charge is worked out the same way: its cost plus the margin, then
4+//! the account's terms. What is left is drawn down, in this order, from:
5+//!
6+//! 1. **The Team plan's credit** (`TEAM_INCLUDED_MICROS` a month), when
7+//! the workspace has Team. Any usage draws on it. Unused credit does not
8+//! roll over.
9+//! 2. **The trial credit**: one grant per workspace
10+//! (`TRIAL_WORKSPACE_MICROS`), made the first time it uses something,
11+//! out of a pool for everyone that resets each calendar month
12+//! (`TRIAL_MONTHLY_POOL_MICROS`). Never for deployments, which are never
13+//! free.
14+//! 3. **g1t's open-source pool** (`OSS_POOL_MICROS` a month, at most
15+//! `OSS_REPO_MICROS` for any one repository): only sandbox time and
16+//! model cost for work on a public repository.
17+//!
18+//! Whatever is left is charged. Each source is a fixed, capped budget that
19+//! something pays for: the plan, or g1t. Nothing here is an open-ended
20+//! allowance per workspace.
21+//!
22+//! Months are calendar months in UTC, the same as the limits'. Every draw
23+//! is one D1 batch, which runs as a transaction, so two charges at once
24+//! never take more than a budget holds.
25+
26+use g1t_contracts::billing::{Feature, MICROS_PER_DOLLAR, Pools, TermsKind, Trial, TrialArgs};
27+use g1t_contracts::time::rfc3339;
28+use g1t_kit::now_ms;
29+use serde::Deserialize;
30+use worker::{Env, Result};
31+
32+use crate::Billing;
33+use crate::features::dollars;
34+
35+/// Every number of the plans and pools, from the billing service's
36+/// variables, each with its default.
37+#[derive(Clone, Debug)]
38+pub(crate) struct Config {
39+ /// `TEAM_MONTHLY_CENTS`: the Team plan's price, per workspace.
40+ pub team_monthly_cents: u32,
41+ /// `TEAM_INCLUDED_MICROS`: its usage credit each month.
42+ pub team_included_micros: i64,
43+ /// `OSS_POOL_MICROS`: g1t's open-source pool each month, in all.
44+ pub oss_pool_micros: i64,
45+ /// `OSS_REPO_MICROS`: any one public repository's share of it.
46+ pub oss_repo_micros: i64,
47+ /// `TRIAL_WORKSPACE_MICROS`: each new workspace's trial credit.
48+ pub trial_workspace_micros: i64,
49+ /// `TRIAL_MONTHLY_POOL_MICROS`: trial grants each month, in all.
50+ pub trial_monthly_pool_micros: i64,
51+ /// `MIN_CHARGE_MICROS`: no card is charged less; smaller amounts carry
52+ /// over to the next invoice.
53+ pub min_charge_micros: i64,
54+ /// `DEPLOYMENTS_BUILD_SECONDS`: build time the Deployments plan
55+ /// includes each month.
56+ pub build_seconds: u32,
57+ /// `FREE_PRIVATE_STORAGE_BYTES` and `TEAM_PRIVATE_STORAGE_BYTES`:
58+ /// private repository storage before it is charged.
59+ pub free_storage_bytes: i64,
60+ pub team_storage_bytes: i64,
61+ /// `AUDIT_RETENTION_DAYS` and `TEAM_AUDIT_RETENTION_DAYS`.
62+ pub audit_days: u32,
63+ pub team_audit_days: u32,
64+}
65+
66+impl Default for Config {
67+ fn default() -> Self {
68+ Config {
69+ team_monthly_cents: 2_000,
70+ team_included_micros: 5_000_000,
71+ oss_pool_micros: 10_000_000,
72+ oss_repo_micros: 1_000_000,
73+ trial_workspace_micros: 1_000_000,
74+ trial_monthly_pool_micros: 40_000_000,
75+ min_charge_micros: 5_000_000,
76+ build_seconds: g1t_contracts::billing::deployments_allowance::BUILD_SECONDS,
77+ free_storage_bytes: 1_000_000_000,
78+ team_storage_bytes: 50_000_000_000,
79+ audit_days: 30,
80+ team_audit_days: 365,
81+ }
82+ }
83+}
84+
85+impl Config {
86+ pub(crate) fn from_env(env: &Env) -> Self {
87+ let d = Config::default();
88+ let number = |name: &str, default: i64| -> i64 {
89+ env.var(name).ok().and_then(|v| v.to_string().trim().parse::<i64>().ok()).filter(|n| *n >= 0).unwrap_or(default)
90+ };
91+ Config {
92+ team_monthly_cents: number("TEAM_MONTHLY_CENTS", d.team_monthly_cents.into()) as u32,
93+ team_included_micros: number("TEAM_INCLUDED_MICROS", d.team_included_micros),
94+ oss_pool_micros: number("OSS_POOL_MICROS", d.oss_pool_micros),
95+ oss_repo_micros: number("OSS_REPO_MICROS", d.oss_repo_micros),
96+ trial_workspace_micros: number("TRIAL_WORKSPACE_MICROS", d.trial_workspace_micros),
97+ trial_monthly_pool_micros: number("TRIAL_MONTHLY_POOL_MICROS", d.trial_monthly_pool_micros),
98+ min_charge_micros: number("MIN_CHARGE_MICROS", d.min_charge_micros),
99+ build_seconds: number("DEPLOYMENTS_BUILD_SECONDS", d.build_seconds.into()) as u32,
100+ free_storage_bytes: number("FREE_PRIVATE_STORAGE_BYTES", d.free_storage_bytes),
101+ team_storage_bytes: number("TEAM_PRIVATE_STORAGE_BYTES", d.team_storage_bytes),
102+ audit_days: number("AUDIT_RETENTION_DAYS", d.audit_days.into()) as u32,
103+ team_audit_days: number("TEAM_AUDIT_RETENTION_DAYS", d.team_audit_days.into()) as u32,
104+ }
105+ }
106+}
107+
108+/// What may pay for a charge besides the Team credit, which any usage may
109+/// draw on.
110+#[derive(Clone, Debug, Default)]
111+pub(crate) struct Eligible {
112+ /// The trial credit: everything but deployments.
113+ pub trial: bool,
114+ /// The open-source pool: sandbox time and model cost for work on this
115+ /// repository (`owner/name`), if it is public.
116+ pub repo: Option<String>,
117+}
118+
119+/// What paid for a charge before the workspace did.
120+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
121+pub(crate) struct Drawn {
122+ pub credit: i64,
123+ pub trial: i64,
124+ pub oss: i64,
125+}
126+
127+impl Drawn {
128+ pub fn total(&self) -> i64 {
129+ self.credit + self.trial + self.oss
130+ }
131+
132+ /// For the statement: what paid for the entry, e.g. ` ($0.12 paid by
133+ /// g1t's open-source pool)`. Empty when nothing did.
134+ pub fn note(&self) -> String {
135+ let parts: Vec<String> = [
136+ (self.credit, "your Team plan's credit"),
137+ (self.trial, "your trial credit"),
138+ (self.oss, "g1t's open-source pool"),
139+ ]
140+ .iter()
141+ .filter(|(micros, _)| *micros > 0)
142+ .map(|(micros, by)| format!("{} paid by {by}", dollars(*micros)))
143+ .collect();
144+ if parts.is_empty() { String::new() } else { format!(" ({})", parts.join(", ")) }
145+ }
146+}
147+
148+/// How `gross` is paid for from sources with `available` left each, in
149+/// order: each takes what it can of what is still unpaid. The rest is
150+/// charged.
151+pub(crate) fn split(gross: i64, available: &[i64]) -> Vec<i64> {
152+ let mut left = gross.max(0);
153+ available
154+ .iter()
155+ .map(|available| {
156+ let take = left.min((*available).max(0));
157+ left -= take;
158+ take
159+ })
160+ .collect()
161+}
162+
163+/// What a budget with `cap` and `used` so far has left.
164+pub(crate) fn left(cap: i64, used: i64) -> i64 {
165+ (cap - used).max(0)
166+}
167+
168+/// `YYYY-MM` of an RFC 3339 time.
169+pub(crate) fn month_of(timestamp: &str) -> String {
170+ timestamp[..7].to_owned()
171+}
172+
173+/// The first instant of the month after `month`: when this month's pools
174+/// reset.
175+pub(crate) fn next_month_start(month: &str) -> String {
176+ let year: i32 = month[..4].parse().unwrap_or(1970);
177+ let number: u32 = month[5..7].parse().unwrap_or(1);
178+ if number == 12 {
179+ format!("{}-01-01T00:00:00Z", year + 1)
180+ } else {
181+ format!("{year}-{:02}-01T00:00:00Z", number + 1)
182+ }
183+}
184+
185+/// The last second of `month`, for a charge that belongs to a month that
186+/// is over.
187+pub(crate) fn month_end(month: &str) -> String {
188+ let year: i32 = month[..4].parse().unwrap_or(1970);
189+ let number: u32 = month[5..7].parse().unwrap_or(1);
190+ let leap = (year % 4 == 0 && year % 100 != 0) || year % 400 == 0;
191+ let days = match number {
192+ 2 if leap => 29,
193+ 2 => 28,
194+ 4 | 6 | 9 | 11 => 30,
195+ _ => 31,
196+ };
197+ format!("{month}-{days:02}T23:59:59Z")
198+}
199+
200+/// What a trial grant would be: the account's own amount from sudo, or the
201+/// default.
202+pub(crate) fn grant_size(config: &Config, staff: Option<i64>) -> i64 {
203+ staff.unwrap_or(config.trial_workspace_micros).max(0)
204+}
205+
206+/// Whether this month's pool can still make a grant of `amount`.
207+pub(crate) fn pool_has_room(pool: i64, granted_this_month: i64, amount: i64) -> bool {
208+ amount > 0 && granted_this_month + amount <= pool
209+}
210+
211+#[derive(Deserialize)]
212+struct Used {
213+ used: Option<i64>,
214+}
215+
216+#[derive(Deserialize)]
217+pub(crate) struct Grant {
218+ pub granted_micros: i64,
219+ pub used_micros: i64,
220+}
221+
222+impl Billing {
223+ /// Whether the workspace has the Team plan now: paid for, comped, or
224+ /// given by g1t in sudo.
225+ pub(crate) async fn team_on(&self, workspace: &str) -> Result<bool> {
226+ let account = self.account_of(workspace).await?;
227+ if account.terms.kind == TermsKind::Comped || account.allowances.team {
228+ return Ok(true);
229+ }
230+ if self.stripe.is_none() {
231+ return Ok(false);
232+ }
233+ self.plan_on(workspace, Feature::Team).await
234+ }
235+
236+ /// What one monthly allowance has used.
237+ pub(crate) async fn allowance_used(&self, kind: &str, scope: &str, month: &str) -> Result<i64> {
238+ Ok(self
239+ .db
240+ .prepare("SELECT used FROM allowance_use WHERE kind = ? AND scope = ? AND month = ?")
241+ .bind(&[kind.into(), scope.into(), month.into()])?
242+ .first::<Used>(None)
243+ .await?
244+ .and_then(|u| u.used)
245+ .unwrap_or(0))
246+ }
247+
248+ /// Takes up to `want` from a monthly allowance with `cap`, as one
249+ /// transaction. Returns what it took.
250+ pub(crate) async fn draw_allowance(&self, kind: &str, scope: &str, month: &str, want: i64, cap: i64) -> Result<i64> {
251+ if want <= 0 || cap <= 0 {
252+ return Ok(0);
253+ }
254+ let key = [kind.into(), scope.into(), month.into()];
255+ let results = self
256+ .db
257+ .batch(vec![
258+ self.db
259+ .prepare("INSERT OR IGNORE INTO allowance_use (kind, scope, month, used) VALUES (?1, ?2, ?3, 0)")
260+ .bind(&key)?,
261+ self.db
262+ .prepare("SELECT used FROM allowance_use WHERE kind = ?1 AND scope = ?2 AND month = ?3")
263+ .bind(&key)?,
264+ self.db
265+ .prepare(
266+ "UPDATE allowance_use SET used = MIN(?4, used + ?5)
267+ WHERE kind = ?1 AND scope = ?2 AND month = ?3 AND used < ?4",
268+ )
269+ .bind(&[kind.into(), scope.into(), month.into(), (cap as f64).into(), (want as f64).into()])?,
270+ self.db
271+ .prepare("SELECT used FROM allowance_use WHERE kind = ?1 AND scope = ?2 AND month = ?3")
272+ .bind(&key)?,
273+ ])
274+ .await?;
275+ let read = |i: usize| -> Result<i64> {
276+ Ok(results[i].results::<Used>()?.first().and_then(|u| u.used).unwrap_or(0))
277+ };
278+ Ok((read(3)? - read(1)?).max(0))
279+ }
280+
281+ /// Gives back what was drawn and not used.
282+ async fn return_allowance(&self, kind: &str, scope: &str, month: &str, amount: i64) -> Result<()> {
283+ if amount > 0 {
284+ self.db
285+ .prepare("UPDATE allowance_use SET used = MAX(0, used - ?4) WHERE kind = ?1 AND scope = ?2 AND month = ?3")
286+ .bind(&[kind.into(), scope.into(), month.into(), (amount as f64).into()])?
287+ .run()
288+ .await?;
289+ }
290+ Ok(())
291+ }
292+
293+ // --- Trials -----------------------------------------------------------
294+
295+ pub(crate) async fn grant_of(&self, workspace: &str) -> Result<Option<Grant>> {
296+ self.db
297+ .prepare("SELECT granted_micros, used_micros FROM trial_grants WHERE workspace = ?")
298+ .bind(&[workspace.into()])?
299+ .first::<Grant>(None)
300+ .await
301+ }
302+
303+ /// Trial grants made this month, in all.
304+ pub(crate) async fn trial_granted(&self, month: &str) -> Result<(i64, u32)> {
305+ #[derive(Deserialize)]
306+ struct Row {
307+ micros: Option<i64>,
308+ n: Option<u32>,
309+ }
310+ let row = self
311+ .db
312+ .prepare("SELECT SUM(granted_micros) AS micros, COUNT(*) AS n FROM trial_grants WHERE month = ?")
313+ .bind(&[month.into()])?
314+ .first::<Row>(None)
315+ .await?;
316+ Ok(row.map_or((0, 0), |r| (r.micros.unwrap_or(0), r.n.unwrap_or(0))))
317+ }
318+
319+ /// The workspace's grant, made now out of this month's pool if it has
320+ /// none and the pool has room. A grant g1t staff set comes from no pool.
321+ async fn ensure_grant(&self, workspace: &str) -> Result<Option<Grant>> {
322+ if let Some(grant) = self.grant_of(workspace).await? {
323+ return Ok(Some(grant));
324+ }
325+ if !self.trials_on {
326+ return Ok(None);
327+ }
328+ let staff = self.account_of(workspace).await?.allowances.trial_micros;
329+ let amount = grant_size(&self.plans, staff);
330+ if amount <= 0 {
331+ return Ok(None);
332+ }
333+ let now = rfc3339(now_ms());
334+ let month = if staff.is_some() { "staff".to_owned() } else { month_of(&now) };
335+ // One statement: the pool is checked and the grant made together.
336+ self.db
337+ .prepare(
338+ "INSERT INTO trial_grants (workspace, month, granted_micros, used_micros, created_at)
339+ SELECT ?1, ?2, ?3, 0, ?4
340+ WHERE ?2 = 'staff'
341+ OR (SELECT COALESCE(SUM(granted_micros), 0) FROM trial_grants WHERE month = ?2) + ?3 <= ?5
342+ ON CONFLICT (workspace) DO NOTHING",
343+ )
344+ .bind(&[
345+ workspace.into(),
346+ month.as_str().into(),
347+ (amount as f64).into(),
348+ now.as_str().into(),
349+ (self.plans.trial_monthly_pool_micros as f64).into(),
350+ ])?
351+ .run()
352+ .await?;
353+ self.grant_of(workspace).await
354+ }
355+
356+ /// Takes up to `want` from the workspace's trial credit.
357+ async fn draw_trial(&self, workspace: &str, want: i64) -> Result<i64> {
358+ if want <= 0 || self.ensure_grant(workspace).await?.is_none() {
359+ return Ok(0);
360+ }
361+ #[derive(Deserialize)]
362+ struct Row {
363+ used_micros: i64,
364+ }
365+ let results = self
366+ .db
367+ .batch(vec![
368+ self.db.prepare("SELECT used_micros FROM trial_grants WHERE workspace = ?1").bind(&[workspace.into()])?,
369+ self.db
370+ .prepare(
371+ "UPDATE trial_grants SET used_micros = MIN(granted_micros, used_micros + ?2)
372+ WHERE workspace = ?1 AND used_micros < granted_micros",
373+ )
374+ .bind(&[workspace.into(), (want as f64).into()])?,
375+ self.db.prepare("SELECT used_micros FROM trial_grants WHERE workspace = ?1").bind(&[workspace.into()])?,
376+ ])
377+ .await?;
378+ let read = |i: usize| -> Result<i64> { Ok(results[i].results::<Row>()?.first().map_or(0, |r| r.used_micros)) };
379+ Ok((read(2)? - read(0)?).max(0))
380+ }
381+
382+ /// `trial`: where the workspace's trial credit stands.
383+ pub(crate) async fn trial(&self, a: TrialArgs) -> Result<Trial> {
384+ let workspace = a.workspace.to_lowercase();
385+ let closed = |reason: &str| Trial {
386+ open: false,
387+ used_micros: 0,
388+ limit_micros: 0,
389+ ends_at: None,
390+ reason: Some(reason.to_owned()),
391+ granted: false,
392+ waits_until: None,
393+ };
394+ if let Some(grant) = self.grant_of(&workspace).await? {
395+ let open = grant.used_micros < grant.granted_micros;
396+ return Ok(Trial {
397+ open,
398+ used_micros: grant.used_micros,
399+ limit_micros: grant.granted_micros,
400+ ends_at: None,
401+ reason: (!open).then(|| "used".to_owned()),
402+ granted: true,
403+ waits_until: None,
404+ });
405+ }
406+ if !self.trials_on {
407+ return Ok(closed("off"));
408+ }
409+ let staff = self.account_of(&workspace).await?.allowances.trial_micros;
410+ let amount = grant_size(&self.plans, staff);
411+ if amount <= 0 {
412+ return Ok(closed("off"));
413+ }
414+ let month = month_of(&rfc3339(now_ms()));
415+ let (granted, _) = self.trial_granted(&month).await?;
416+ let room = staff.is_some() || pool_has_room(self.plans.trial_monthly_pool_micros, granted, amount);
417+ Ok(Trial {
418+ open: room,
419+ used_micros: 0,
420+ limit_micros: amount,
421+ ends_at: None,
422+ reason: (!room).then(|| "pool".to_owned()),
423+ granted: false,
424+ waits_until: (!room).then(|| next_month_start(&month)),
425+ })
426+ }
427+
428+ // --- The open-source pool ---------------------------------------------
429+
430+ /// Whether `repo` (`owner/name`) is public, asked of the repos service.
431+ /// Unknown counts as private: the pool pays only for what is known to
432+ /// be open.
433+ async fn is_public(&self, repo: &str) -> bool {
434+ let Some(repos) = &self.repos else { return false };
435+ let found: Result<Vec<g1t_contracts::repos::RepoVisibility>> = g1t_kit::call(
436+ repos,
437+ "visibility",
438+ &g1t_contracts::repos::VisibilityArgs { paths: vec![repo.to_owned()] },
439+ )
440+ .await;
441+ match found {
442+ Ok(list) => list.iter().any(|v| v.path.eq_ignore_ascii_case(repo) && !v.is_private),
443+ Err(error) => {
444+ worker::console_error!("could not ask whether {repo} is public: {error}");
445+ false
446+ }
447+ }
448+ }
449+
450+ /// A public repository's monthly cap on the pool: its account's own
451+ /// from sudo, or `OSS_REPO_MICROS`.
452+ async fn oss_repo_cap(&self, workspace: &str) -> Result<i64> {
453+ Ok(self.account_of(workspace).await?.allowances.oss_repo_micros.unwrap_or(self.plans.oss_repo_micros))
454+ }
455+
456+ /// Takes up to `want` from the open-source pool for `repo`, within the
457+ /// pool's cap and the repository's.
458+ async fn draw_oss(&self, workspace: &str, repo: &str, month: &str, want: i64) -> Result<i64> {
459+ let repo = repo.to_lowercase();
460+ let cap = self.oss_repo_cap(workspace).await?;
461+ let room = left(cap, self.allowance_used("oss_repo", &repo, month).await?);
462+ let from_pool = self.draw_allowance("oss_pool", "", month, want.min(room), self.plans.oss_pool_micros).await?;
463+ let for_repo = self.draw_allowance("oss_repo", &repo, month, from_pool, cap).await?;
464+ // The repository's cap filled up meanwhile: give the pool back the rest.
465+ self.return_allowance("oss_pool", "", month, from_pool - for_repo).await?;
466+ Ok(for_repo)
467+ }
468+
469+ // --- Drawing down -----------------------------------------------------
470+
471+ /// Pays for a `gross` charge from the Team credit, the trial credit and
472+ /// the open-source pool, in that order, for usage in `month`. Returns
473+ /// what each paid; the rest is the workspace's to pay.
474+ pub(crate) async fn draw(&self, workspace: &str, gross: i64, month: &str, eligible: &Eligible) -> Result<Drawn> {
475+ if gross <= 0 {
476+ return Ok(Drawn::default());
477+ }
478+ let team = self.team_on(workspace).await?;
479+ let credit_left = if team {
480+ left(self.plans.team_included_micros, self.allowance_used("team_credit", workspace, month).await?)
481+ } else {
482+ 0
483+ };
484+ let trial_left = if eligible.trial {
485+ match self.grant_of(workspace).await? {
486+ Some(grant) => left(grant.granted_micros, grant.used_micros),
487+ // Granted on first use, if the pool has room.
488+ None => match self.trial(TrialArgs { workspace: workspace.to_owned(), exempt: vec![] }).await? {
489+ trial if trial.open => trial.limit_micros,
490+ _ => 0,
491+ },
492+ }
493+ } else {
494+ 0
495+ };
496+ // Asked only when the rest has not paid for it all.
497+ let public_repo = match &eligible.repo {
498+ Some(repo) if gross > credit_left + trial_left && self.is_public(repo).await => Some(repo.clone()),
499+ _ => None,
500+ };
501+ let oss_left = match &public_repo {
502+ Some(repo) => left(self.plans.oss_pool_micros, self.allowance_used("oss_pool", "", month).await?)
503+ .min(left(self.oss_repo_cap(workspace).await?, self.allowance_used("oss_repo", &repo.to_lowercase(), month).await?)),
504+ None => 0,
505+ };
506+ let planned = split(gross, &[credit_left, trial_left, oss_left]);
507+ let mut drawn = Drawn::default();
508+ drawn.credit = self.draw_allowance("team_credit", workspace, month, planned[0], self.plans.team_included_micros).await?;
509+ drawn.trial = self.draw_trial(workspace, planned[1]).await?;
510+ if let Some(repo) = &public_repo {
511+ drawn.oss = self.draw_oss(workspace, repo, month, planned[2]).await?;
512+ }
513+ Ok(drawn)
514+ }
515+
516+ /// Writes down on a usage entry what paid for it.
517+ pub(crate) async fn record_drawn(&self, reference: &str, drawn: &Drawn) -> Result<()> {
518+ if drawn.total() == 0 {
519+ return Ok(());
520+ }
521+ self.db
522+ .prepare("UPDATE ledger SET credit_micros = ?, trial_micros = ?, oss_micros = ? WHERE reference = ?")
523+ .bind(&[
524+ (drawn.credit as f64).into(),
525+ (drawn.trial as f64).into(),
526+ (drawn.oss as f64).into(),
527+ reference.into(),
528+ ])?
529+ .run()
530+ .await?;
531+ Ok(())
532+ }
533+
534+ /// g1t's pools this month, for sudo.
535+ pub(crate) async fn pools(&self) -> Result<Pools> {
536+ let month = month_of(&rfc3339(now_ms()));
537+ let (granted, grants) = self.trial_granted(&month).await?;
538+ Ok(Pools {
539+ oss_used_micros: self.allowance_used("oss_pool", "", &month).await?,
540+ oss_pool_micros: self.plans.oss_pool_micros,
541+ oss_repo_micros: self.plans.oss_repo_micros,
542+ trial_granted_micros: granted,
543+ trial_pool_micros: self.plans.trial_monthly_pool_micros,
544+ trial_grants: grants,
545+ month,
546+ })
547+ }
548+}
549+
550+/// A charge in millionths of a dollar for `micros` of cost plus `margin`.
551+pub(crate) fn with_margin(cost_micros: i64, margin_percent: u32) -> i64 {
552+ crate::charge_micros(cost_micros.max(0) as f64 / MICROS_PER_DOLLAR as f64, margin_percent)
553+}
554+
555+#[cfg(test)]
556+mod tests {
557+ use super::*;
558+
559+ #[test]
560+ fn team_credit_pays_first_then_the_trial_then_the_pool_then_the_workspace() {
561+ // $0.50 of usage; $0.20 of Team credit, $1 of trial, $1 of pool.
562+ assert_eq!(split(500_000, &[200_000, 1_000_000, 1_000_000]), [200_000, 300_000, 0]);
563+ // No Team: the trial pays all of it.
564+ assert_eq!(split(500_000, &[0, 1_000_000, 1_000_000]), [0, 500_000, 0]);
565+ // Trial spent: the pool pays, where it applies.
566+ assert_eq!(split(500_000, &[0, 0, 1_000_000]), [0, 0, 500_000]);
567+ // Everything spent: the workspace pays all of it.
568+ let planned = split(500_000, &[0, 0, 0]);
569+ assert_eq!(planned, [0, 0, 0]);
570+ assert_eq!(500_000 - planned.iter().sum::<i64>(), 500_000);
571+ // Each pays what it can, and the rest is charged.
572+ let planned = split(500_000, &[100_000, 150_000, 50_000]);
573+ assert_eq!(planned, [100_000, 150_000, 50_000]);
574+ assert_eq!(500_000 - planned.iter().sum::<i64>(), 200_000);
575+ // Nothing is drawn for nothing, nor from a negative balance.
576+ assert_eq!(split(0, &[1, 1, 1]), [0, 0, 0]);
577+ assert_eq!(split(100, &[-5, 50, 100]), [0, 50, 50]);
578+ }
579+
580+ #[test]
581+ fn a_budget_never_gives_more_than_its_cap() {
582+ assert_eq!(left(1_000_000, 400_000), 600_000);
583+ assert_eq!(left(1_000_000, 1_000_000), 0);
584+ assert_eq!(left(1_000_000, 1_200_000), 0);
585+ // The open-source pool: the repository's share and the pool's both bound it.
586+ let pool = left(10_000_000, 9_900_000);
587+ let repo = left(1_000_000, 300_000);
588+ assert_eq!(split(800_000, &[pool.min(repo)]), [100_000]);
589+ }
590+
591+ #[test]
592+ fn pools_reset_each_calendar_month() {
593+ assert_eq!(month_of("2026-10-31T23:59:59Z"), "2026-10");
594+ assert_eq!(month_of("2026-11-01T00:00:00Z"), "2026-11");
595+ assert_eq!(next_month_start("2026-10"), "2026-11-01T00:00:00Z");
596+ assert_eq!(next_month_start("2026-12"), "2027-01-01T00:00:00Z");
597+ // A pool given out this month has room again next month, since
598+ // grants count only against the month they were made in.
599+ assert!(!pool_has_room(40_000_000, 40_000_000, 1_000_000));
600+ assert!(!pool_has_room(40_000_000, 39_500_000, 1_000_000));
601+ assert!(pool_has_room(40_000_000, 0, 1_000_000));
602+ assert!(pool_has_room(40_000_000, 39_000_000, 1_000_000));
603+ assert!(!pool_has_room(40_000_000, 0, 0));
604+ }
605+
606+ #[test]
607+ fn a_trial_grant_is_the_default_unless_staff_set_one() {
608+ let config = Config::default();
609+ assert_eq!(grant_size(&config, None), 1_000_000);
610+ assert_eq!(grant_size(&config, Some(5_000_000)), 5_000_000);
611+ assert_eq!(grant_size(&config, Some(-1)), 0);
612+ }
613+
614+ #[test]
615+ fn a_month_ends_on_its_last_day() {
616+ assert_eq!(month_end("2026-10"), "2026-10-31T23:59:59Z");
617+ assert_eq!(month_end("2026-09"), "2026-09-30T23:59:59Z");
618+ assert_eq!(month_end("2028-02"), "2028-02-29T23:59:59Z");
619+ assert_eq!(month_end("2027-02"), "2027-02-28T23:59:59Z");
620+ }
621+
622+ #[test]
623+ fn what_paid_is_said_on_the_statement() {
624+ assert_eq!(Drawn::default().note(), "");
625+ let drawn = Drawn { credit: 0, trial: 0, oss: 120_000 };
626+ assert_eq!(drawn.note(), " ($0.12 paid by g1t's open-source pool)");
627+ let drawn = Drawn { credit: 50_000, trial: 20_000, oss: 0 };
628+ assert_eq!(drawn.note(), " ($0.05 paid by your Team plan's credit, $0.02 paid by your trial credit)");
629+ assert_eq!(drawn.total(), 70_000);
630+ }
631+
632+ #[test]
633+ fn the_defaults_are_the_published_ones() {
634+ let c = Config::default();
635+ assert_eq!(c.team_monthly_cents, 2_000);
636+ assert_eq!(c.team_included_micros, 5_000_000);
637+ assert_eq!(c.oss_pool_micros, 10_000_000);
638+ assert_eq!(c.oss_repo_micros, 1_000_000);
639+ assert_eq!(c.trial_monthly_pool_micros, 40_000_000);
640+ assert_eq!(c.min_charge_micros, 5_000_000);
641+ assert_eq!(c.build_seconds, 12_000);
642+ assert_eq!(c.free_storage_bytes, 1_000_000_000);
643+ assert_eq!(c.team_storage_bytes, 50_000_000_000);
644+ }
645+}
+149−43
66 use g1t_contracts::billing::deployments_allowance as allowance;
77 use g1t_contracts::billing::*;
88 use g1t_contracts::time::rfc3339;
9−use g1t_contracts::{FailureCode, Outcome, Role, new_id};
9+use g1t_contracts::{FailureCode, Outcome, Role};
1010 use g1t_kit::now_ms;
1111 use serde::Deserialize;
1212 use worker::Result;
5959 }
6060 }
6161
62+/// `1 GB`, `50 GB`, or `500 MB`, as storage is priced (powers of ten).
63+pub(crate) fn bytes(bytes: i64) -> String {
64+ if bytes >= 1_000_000_000 && bytes % 1_000_000_000 == 0 {
65+ format!("{} GB", bytes / 1_000_000_000)
66+ } else if bytes >= 1_000_000_000 {
67+ format!("{:.1} GB", bytes as f64 / 1e9)
68+ } else {
69+ format!("{} MB", bytes / 1_000_000)
70+ }
71+}
72+
6273 /// Dollars to the cent, or finer for prices under a cent, so that a
6374 /// build minute's $0.0015 does not read as nothing.
6475 pub(crate) fn dollars(micros: i64) -> String {
8394 impl Billing {
8495 pub(crate) fn plan(&self, feature: Feature) -> Plan {
8596 match feature {
97+ Feature::Team => Plan {
98+ feature,
99+ title: feature.title().to_owned(),
100+ monthly_cents: self.plans.team_monthly_cents,
101+ includes: vec![
102+ format!(
103+ "{} of usage credit each month, drawn first by the month's usage at cost plus {}%. Unused credit does not roll over.",
104+ dollars(self.plans.team_included_micros),
105+ self.margin_percent
106+ ),
107+ format!(
108+ "{} of private repository storage, rather than {}",
109+ bytes(self.plans.team_storage_bytes),
110+ bytes(self.plans.free_storage_bytes)
111+ ),
112+ format!(
113+ "The audit log kept {}, rather than {} days",
114+ if self.plans.team_audit_days % 365 == 0 {
115+ match self.plans.team_audit_days / 365 {
116+ 1 => "for 1 year".to_owned(),
117+ years => format!("for {years} years"),
118+ }
119+ } else {
120+ format!("for {} days", self.plans.team_audit_days)
121+ },
122+ self.plans.audit_days
123+ ),
124+ "Everyone in the workspace, at one price: never per person".to_owned(),
125+ ],
126+ overage: format!(
127+ "Usage past the credit is charged as it is without the plan: at cost plus {}%.",
128+ self.margin_percent
129+ ),
130+ },
86131 Feature::Deployments => Plan {
87132 feature,
88133 title: feature.title().to_owned(),
92137 "{} apps deployed at once, production and previews together",
93138 allowance::APPS
94139 ),
140+ format!("{} build minutes", self.plans.build_seconds / 60),
95141 format!("{} million requests", allowance::REQUESTS / 1_000_000),
96142 format!("{} million CPU milliseconds", allowance::CPU_MS / 1_000_000),
97143 format!(
105151 "Previews that cost nothing while no one visits them".to_owned(),
106152 ],
107153 overage: format!(
108− "Builds, and usage past that, come from credit at Cloudflare's price plus {3}%: {4} per build minute, {0} per extra app a month, {1} per million requests and {2} per million CPU milliseconds.",
154+ "Usage past that is charged at Cloudflare's price plus {3}%: {4} per build minute, by the second, {0} per extra app a month, {1} per million requests and {2} per million CPU milliseconds.",
109155 dollars(crate::charge_micros(
110156 allowance::MICROS_PER_APP_MONTH as f64 / MICROS_PER_DOLLAR as f64,
111157 self.margin_percent
204250 .current(workspace, feature)
205251 .await?
206252 .and_then(|row| row.subscription());
253+ let included = self.included(workspace, feature).await?;
207254 Ok(FeatureState {
208255 plan: self.plan(feature),
209− on: self.stripe.is_none() || subscription.as_ref().is_some_and(|s| s.status.on()),
256+ on: included || self.stripe.is_none() || subscription.as_ref().is_some_and(|s| s.status.on()),
210257 subscription,
258+ included,
211259 })
212260 }
213261
262+ /// Whether the feature is on without a plan: comped terms have every
263+ /// feature, and g1t staff can give an account Team.
264+ async fn included(&self, workspace: &str, feature: Feature) -> Result<bool> {
265+ let account = self.account_of(workspace).await?;
266+ Ok(account.terms.kind == g1t_contracts::billing::TermsKind::Comped
267+ || (feature == Feature::Team && account.allowances.team))
268+ }
269+
270+ /// Whether the workspace's plan for the feature is paid up.
271+ pub(crate) async fn plan_on(&self, workspace: &str, feature: Feature) -> Result<bool> {
272+ Ok(self
273+ .current(workspace, feature)
274+ .await?
275+ .and_then(|row| row.subscription())
276+ .is_some_and(|s| s.status.on()))
277+ }
278+
214279 pub(crate) async fn features(&self, a: FeaturesArgs) -> Result<Outcome<Vec<FeatureState>>> {
215280 let workspace = a.workspace.to_lowercase();
216281 if !a.viewer.is_some_and(|viewer| viewer.is_member(&workspace)) {
237302 "Payments are not set up on this g1t, so every feature is already on.",
238303 ));
239304 };
240− if self.state(&workspace, a.feature).await?.subscription.is_some_and(|s| s.status.on()) {
305+ let state = self.state(&workspace, a.feature).await?;
306+ if state.included {
307+ return Ok(Outcome::fail(
308+ FailureCode::Conflict,
309+ format!("{} is included for {workspace} already, at no charge.", a.feature.title()),
310+ ));
311+ }
312+ if state.subscription.is_some_and(|s| s.status.on()) {
241313 return Ok(Outcome::fail(
242314 FailureCode::Conflict,
243315 format!("{} is already on for {workspace}.", a.feature.title()),
376448
377449 pub(crate) async fn has_feature(&self, a: HasFeatureArgs) -> Result<Outcome<bool>> {
378450 let workspace = a.workspace.to_lowercase();
379− // Comped accounts have every feature without a plan.
380− if self.terms_of(&workspace).await?.kind == g1t_contracts::billing::TermsKind::Comped {
451+ // Comped accounts have every feature without a plan, and staff can
452+ // give an account Team.
453+ if self.included(&workspace, a.feature).await? {
381454 return Ok(Outcome::Ok(true));
382455 }
383456 if self.state(&workspace, a.feature).await?.on {
406479 if seen.is_some() {
407480 return Ok(Outcome::Ok(false));
408481 }
409− let cost = a.cost_micros as f64 / MICROS_PER_DOLLAR as f64;
482+ let timestamp = rfc3339(now_ms());
483+ let month = crate::credits::month_of(&timestamp);
484+ let mut description = a.description.clone();
485+ // A build: the plan's build time this month pays for what it can.
486+ let cost_micros = match a.build_seconds.filter(|s| *s > 0 && a.feature == Feature::Deployments) {
487+ Some(seconds) => {
488+ let included = self
489+ .draw_allowance("build_seconds", &workspace, &month, seconds.into(), self.plans.build_seconds.into())
490+ .await?;
491+ if included > 0 {
492+ description.push_str(&format!(
493+ ", {} of it included in the plan",
494+ if included == i64::from(seconds) { "all".to_owned() } else { format!("{included} s") }
495+ ));
496+ }
497+ billable_build_cost(a.cost_micros, seconds, included)
498+ }
499+ None => a.cost_micros,
500+ };
501+ let cost = cost_micros as f64 / MICROS_PER_DOLLAR as f64;
410502 // Never free: the margin applies whatever FREE_WHILE_BUILDING says,
411− // and only the account's terms change it.
503+ // and only the account's terms change it. The Team credit pays what
504+ // it can; the trial and the open-source pool never pay for
505+ // deployments.
412506 let charge = self.terms_of(&workspace).await?.apply(crate::charge_micros(cost, self.margin_percent));
413− let now = now_ms();
414− let timestamp = rfc3339(now);
415− self.db
416− .batch(vec![
417− self.db
418− .prepare(
419− "INSERT INTO ledger
420− (id, workspace, kind, amount_micros, description, repo, task,
421− cost_micros, reference, created_at, billed_to)
422− VALUES (?, ?, 'usage', ?, ?, ?, ?, ?, ?, ?, 'g1t')",
423− )
424− .bind(&[
425− new_id("led", now).into(),
426− workspace.as_str().into(),
427− (-(charge as f64)).into(),
428− a.description.as_str().into(),
429− optional(a.repo.as_deref()),
430− a.feature.as_str().into(),
431− (a.cost_micros as f64).into(),
432− a.reference.as_str().into(),
433− timestamp.as_str().into(),
434− ])?,
435− self.db
436− .prepare(
437− "INSERT INTO accounts (workspace, balance_micros, created_at)
438− VALUES (?1, ?2, ?3)
439− ON CONFLICT (workspace) DO UPDATE SET balance_micros = balance_micros + ?2",
440− )
441− .bind(&[
442− workspace.as_str().into(),
443− (-(charge as f64)).into(),
444− timestamp.as_str().into(),
445− ])?,
446− ])
447− .await?;
507+ let drawn = self.draw(&workspace, charge, &month, &crate::credits::Eligible::default()).await?;
508+ description.push_str(&drawn.note());
509+ self.post_usage(crate::storage::UsageLine {
510+ workspace: &workspace,
511+ charged: charge - drawn.total(),
512+ description: &description,
513+ repo: a.repo.as_deref(),
514+ task: a.feature.as_str(),
515+ cost: cost_micros,
516+ reference: &a.reference,
517+ created_at: &timestamp,
518+ drawn,
519+ })
520+ .await?;
448521 Ok(Outcome::Ok(true))
449522 }
450523 }
451524
525+/// What of a build's cost is charged when `included` of its `seconds` were
526+/// paid for by the plan: the rest, in proportion, rounded up.
527+pub(crate) fn billable_build_cost(cost_micros: i64, seconds: u32, included: i64) -> i64 {
528+ if seconds == 0 {
529+ return cost_micros;
530+ }
531+ let billable = (i64::from(seconds) - included.max(0)).max(0);
532+ (cost_micros as f64 * billable as f64 / f64::from(seconds)).ceil() as i64
533+}
534+
452535 #[cfg(test)]
453536 mod tests {
454537 use super::*;
455538
456539 #[test]
540+ fn the_plan_pays_for_its_build_minutes_and_the_rest_is_charged() {
541+ // A 5-minute build at 21 millionths a second costs 6,300.
542+ assert_eq!(billable_build_cost(6_300, 300, 300), 0);
543+ assert_eq!(billable_build_cost(6_300, 300, 0), 6_300);
544+ // The allowance ran out a minute into it: four minutes are charged.
545+ assert_eq!(billable_build_cost(6_300, 300, 60), 5_040);
546+ // Then at cost plus 20%.
547+ assert_eq!(crate::credits::with_margin(5_040, 20), 6_048);
548+ // 200 minutes a month cost g1t about $0.25 of the plan's $5.
549+ let month = crate::credits::Config::default().build_seconds;
550+ assert_eq!(month, 12_000);
551+ assert_eq!(i64::from(month) * allowance::MICROS_PER_BUILD_SECOND, 252_000);
552+ }
553+
554+ #[test]
555+ fn storage_reads_in_gigabytes() {
556+ assert_eq!(bytes(1_000_000_000), "1 GB");
557+ assert_eq!(bytes(50_000_000_000), "50 GB");
558+ assert_eq!(bytes(1_500_000_000), "1.5 GB");
559+ assert_eq!(bytes(500_000_000), "500 MB");
560+ }
561+
562+ #[test]
457563 fn prices_under_a_cent_keep_their_digits() {
458564 assert_eq!(dollars(1512), "$0.0015");
459565 assert_eq!(dollars(24_000), "$0.024");
+13−5
1818
1919 use crate::Billing;
2020
21−/// Stripe will not charge a card less than this.
22−const MIN_INVOICE_MICROS: i64 = 500_000;
23−
2421 /// The invoice's lines: what was used since the last one, by kind, then
2522 /// whatever makes the total what is owed.
2623 pub(crate) fn invoice_lines(used: &[(String, i64)], owed: i64) -> Vec<InvoiceItem> {
8683 let Some(account) = self.row(workspace).await? else { return Ok(Err("Nothing billed yet.".into())) };
8784 let Some(customer) = account.customer_id else { return Ok(Err("No card on file.".into())) };
8885 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()));
86+ // A month's close charges no less than the minimum
87+ // (`MIN_CHARGE_MICROS`), so a payment's fee is never most of it;
88+ // less carries over. A charge because a limit was reached always
89+ // goes through, so a new workspace's small limit never strands it.
90+ if reason == "month" && !crate::limits::worth_charging(owed, self.plans.min_charge_micros) {
91+ return Ok(Err(format!(
92+ "{} is owed, under the {} minimum charge; it carries over to the next invoice.",
93+ crate::features::dollars(owed),
94+ crate::features::dollars(self.plans.min_charge_micros)
95+ )));
9196 }
9297 // What was used since the last invoice, by kind.
9398 #[derive(Deserialize)]
114119 "SELECT CASE
115120 WHEN task = 'sandbox' THEN 'Sandbox time'
116121 WHEN task = 'deployments' THEN 'Deployments: builds and usage past the plan'
122+ WHEN task = 'security' THEN 'Security scans'
123+ WHEN task = 'context' THEN 'Search embeddings'
124+ WHEN task = 'storage' THEN 'Private repository storage'
117125 WHEN billed_to = 'workspace' THEN 'Runs on your own model provider'
118126 ELSE 'Agents on g1t''s models' END AS kind,
119127 -SUM(amount_micros) AS charged
+60−9
292292 struct Charged {
293293 cost_micros: Option<i64>,
294294 description: String,
295+ amount_micros: i64,
296+}
297+
298+/// What a settled run's correction comes to, from the charges at the
299+/// reported and the gateway's cost and what the workspace was charged at
300+/// first. A charge up is drawn down like any charge; a charge down is
301+/// given back only up to what the workspace paid, since what a credit or
302+/// a pool paid was never the workspace's money.
303+pub(crate) fn correction(reported_charge: i64, gateway_charge: i64, first_charged: i64) -> i64 {
304+ let delta = gateway_charge - reported_charge;
305+ if delta >= 0 { delta } else { delta.max(-first_charged.max(0)) }
295306 }
296307
297308 fn ms(timestamp: &str) -> u64 {
341352 })
342353 .collect(),
343354 model_margin_percent: self.margin_percent,
355+ plans: g1t_contracts::billing::Feature::ALL.iter().map(|feature| self.plan(*feature)).collect(),
356+ free: Some(g1t_contracts::billing::FreeTier {
357+ trial_workspace_micros: if self.trials_on { self.plans.trial_workspace_micros } else { 0 },
358+ trial_monthly_pool_micros: if self.trials_on { self.plans.trial_monthly_pool_micros } else { 0 },
359+ oss_pool_micros: self.plans.oss_pool_micros,
360+ oss_repo_micros: self.plans.oss_repo_micros,
361+ free_private_storage_bytes: self.plans.free_storage_bytes,
362+ audit_retention_days: self.plans.audit_days,
363+ min_charge_micros: self.plans.min_charge_micros,
364+ }),
344365 })
345366 }
346367
420441 let gateway_micros = charge_micros(cost_usd, 0);
421442 let charged = self
422443 .db
423− .prepare("SELECT cost_micros, description FROM ledger WHERE reference = ?")
444+ .prepare("SELECT cost_micros, description, amount_micros FROM ledger WHERE reference = ?")
424445 .bind(&[run.id.as_str().into()])?
425446 .first::<Charged>(None)
426447 .await?;
452473 match charged {
453474 // Never reported: charged now, from the gateway's figure.
454475 None => {
476+ let charge = charge_for(gateway_micros);
477+ let eligible = crate::credits::Eligible { trial: true, repo: Some(run.repo.clone()) };
478+ let drawn = self.draw(&run.workspace, charge, &settled_at[..7], &eligible).await?;
455479 let description = format!(
456− "Work on {}#{}, settled from AI Gateway after the sandbox stopped without reporting{free_note}",
457− run.repo, run.number
480+ "Work on {}#{}, settled from AI Gateway after the sandbox stopped without reporting{free_note}{}",
481+ run.repo,
482+ run.number,
483+ drawn.note()
458484 );
459− self.enter(&run.workspace, EntryKind::Usage, -charge_for(gateway_micros), &description, &run.id, Some(&row), Some(gateway_micros), None, None)
485+ self.enter(&run.workspace, EntryKind::Usage, -(charge - drawn.total()), &description, &run.id, Some(&row), Some(gateway_micros), None, None)
460486 .await?;
487+ self.record_drawn(&run.id, &drawn).await?;
461488 }
462489 Some(charged) => {
463490 let reported = charged.cost_micros.unwrap_or(0);
465492 if delta == 0 {
466493 return Ok(());
467494 }
468− let amount = -(charge_for(gateway_micros) - charge_for(reported));
495+ let change = correction(charge_for(reported), charge_for(gateway_micros), -charged.amount_micros);
496+ // A charge up is paid for like any other charge.
497+ let drawn = if change > 0 {
498+ let eligible = crate::credits::Eligible { trial: true, repo: Some(run.repo.clone()) };
499+ self.draw(&run.workspace, change, &settled_at[..7], &eligible).await?
500+ } else {
501+ crate::credits::Drawn::default()
502+ };
469503 let description = format!(
470− "Correction to “{}”: AI Gateway priced its {requests} model requests at {}, not {}",
504+ "Correction to “{}”: AI Gateway priced its {requests} model requests at {}, not {}{}",
471505 charged.description,
472506 crate::features::dollars(gateway_micros),
473507 crate::features::dollars(reported),
508+ drawn.note(),
474509 );
510+ let reference = format!("{}/settled", run.id);
475511 self.enter(
476512 &run.workspace,
477513 EntryKind::Usage,
478− amount,
514+ -(change - drawn.total()),
479515 &description,
480− &format!("{}/settled", run.id),
516+ &reference,
481517 Some(&row),
482518 Some(delta),
483519 None,
484520 None,
485521 )
486522 .await?;
523+ self.record_drawn(&reference, &drawn).await?;
487524 }
488525 }
489526 Ok(())
564601 }
565602 // Apps run as Workers: per million requests and CPU milliseconds,
566603 // once the bill shows them charged.
567− let app_meters: [(&str, &[&str], &str); 2] = [
604+ // Security scans' CPU follows the same Workers CPU rate.
605+ let app_meters: [(&str, &[&str], &str); 3] = [
568606 ("app_requests", &["workers", "requests"], "requests"),
569607 ("app_cpu", &["workers cpu"], "CPU ms"),
608+ ("scan_cpu", &["workers cpu"], "CPU ms"),
570609 ];
571610 for (meter, words, unit) in app_meters {
572611 if let Some(rate) = billed_rate(&named(words)) {
623662 use super::*;
624663
625664 #[test]
665+ fn a_correction_never_gives_back_what_the_workspace_did_not_pay() {
666+ // Up by 2 cents: charged in full (then drawn down like any charge).
667+ assert_eq!(correction(100_000, 120_000, 100_000), 20_000);
668+ // Down by 2 cents, all of it paid by the workspace: given back.
669+ assert_eq!(correction(120_000, 100_000, 120_000), -20_000);
670+ // Down, but the open-source pool paid all but a cent: a cent back.
671+ assert_eq!(correction(120_000, 100_000, 10_000), -10_000);
672+ // Paid entirely by a credit or pool: nothing back.
673+ assert_eq!(correction(120_000, 100_000, 0), 0);
674+ }
675+
676+ #[test]
626677 fn small_moves_are_noise_and_wild_ones_are_not_believed() {
627678 assert_eq!(adopt(21.0, 21.2), Ok(None));
628679 assert_eq!(adopt(21.0, 25.0), Ok(Some(25.0)));
+56−111
1717 //! the methods and their arguments.
1818
1919 mod accounts;
20+mod credits;
21+mod storage;
2022 mod invoices;
2123 mod sales;
2224 mod statement;
8284 billed_to: Option<String>,
8385 #[serde(default)]
8486 workspace: Option<String>,
87+ #[serde(default)]
88+ credit_micros: Option<i64>,
89+ #[serde(default)]
90+ trial_micros: Option<i64>,
91+ #[serde(default)]
92+ oss_micros: Option<i64>,
8593 }
8694
8795 impl From<LedgerRow> for LedgerEntry {
99107 created_by: row.created_by,
100108 created_at: row.created_at,
101109 workspace: row.workspace,
110+ credit_micros: row.credit_micros.unwrap_or(0),
111+ trial_micros: row.trial_micros.unwrap_or(0),
112+ oss_micros: row.oss_micros.unwrap_or(0),
102113 }
103114 }
104115 }
140151 margin_percent: u32,
141152 /// While g1t is being built out, nothing is charged (`FREE_WHILE_BUILDING`).
142153 free: bool,
143− /// The free allowance on g1t's hosted models, when there is one.
144− trial: Option<TrialConfig>,
154+ /// Whether new workspaces get trial credit (`TRIAL_WORKSPACE_MICROS`
155+ /// and `TRIAL_MONTHLY_POOL_MICROS` both above zero).
156+ trials_on: bool,
157+ /// The plans' and pools' numbers; see `credits`.
158+ plans: credits::Config,
159+ /// The repos service: which repositories are public, and what private
160+ /// ones hold. Absent where it is not bound.
161+ repos: Option<worker::Fetcher>,
145162 /// The Deployments plan's monthly price (`DEPLOYMENTS_MONTHLY_CENTS`).
146163 deployments_monthly_cents: u32,
147164 /// How far unpaid usage may go; see `limits`.
150167 prepaid_only: bool,
151168 }
152169
153−/// `TRIAL_WORKSPACE_MICROS`, `TRIAL_TOTAL_MICROS` and `TRIAL_UNTIL`.
154−struct TrialConfig {
155− per_workspace_micros: i64,
156− total_micros: i64,
157− /// RFC 3339, in UTC.
158− until: String,
159−}
160−
161−#[derive(serde::Deserialize)]
162−struct Sum {
163− micros: Option<i64>,
164−}
165−
166170 impl Billing {
167171 fn status(&self) -> Status {
168172 Status {
576580 ),
577581 )
578582 }))
579− }
580−
581− /// A workspace's free allowance on g1t's hosted models: what its runs
582− /// there have cost against its share, and the pool everyone draws on.
583− /// How much of a hosted model run's cost the workspace's free allowance
584− /// covers, if it is still open.
585− async fn trial_covers(&self, workspace: &str, cost_micros: i64) -> Result<i64> {
586− let trial = self.trial(TrialArgs { workspace: workspace.to_owned(), exempt: vec![] }).await?;
587− if !trial.open {
588− return Ok(0);
589− }
590− Ok(cost_micros.min((trial.limit_micros - trial.used_micros).max(0)))
591583 }
592584
593− async fn trial(&self, a: TrialArgs) -> Result<Trial> {
594− let workspace = a.workspace.to_lowercase();
595− let Some(config) = &self.trial else {
596− return Ok(Trial {
597− open: false,
598− used_micros: 0,
599− limit_micros: 0,
600− ends_at: None,
601− reason: Some("off".to_owned()),
602− });
603− };
604− let used = self
605− .db
606− .prepare(
607− "SELECT SUM(cost_micros) AS micros FROM ledger
608− WHERE kind = 'usage' AND COALESCE(billed_to, 'g1t') = 'g1t' AND COALESCE(task, '') NOT IN ('sandbox', 'deployments') AND workspace = ?",
609− )
610− .bind(&[workspace.as_str().into()])?
611− .first::<Sum>(None)
612− .await?
613− .and_then(|sum| sum.micros)
614− .unwrap_or_default();
615− // Everyone's, but for the workspaces open to hosted models anyway.
616− let exempt: Vec<String> = a.exempt.iter().map(|name| name.trim().to_lowercase()).collect();
617− let marks = vec!["?"; exempt.len().max(1)].join(", ");
618− let mut values: Vec<JsValue> = exempt.iter().map(|name| JsValue::from(name.as_str())).collect();
619− if values.is_empty() {
620− values.push(JsValue::from(""));
621− }
622− let pooled = self
623− .db
624− .prepare(format!(
625− "SELECT SUM(cost_micros) AS micros FROM ledger
626− WHERE kind = 'usage' AND COALESCE(billed_to, 'g1t') = 'g1t' AND COALESCE(task, '') NOT IN ('sandbox', 'deployments') AND workspace NOT IN ({marks})"
627− ))
628− .bind(&values)?
629− .first::<Sum>(None)
630− .await?
631− .and_then(|sum| sum.micros)
632− .unwrap_or_default();
633− let reason = if rfc3339(now_ms()) >= config.until {
634− Some("ended")
635− } else if used >= config.per_workspace_micros {
636− Some("used")
637− } else if pooled >= config.total_micros {
638− Some("pool")
639− } else {
640− None
641− };
642− Ok(Trial {
643− open: reason.is_none(),
644− used_micros: used,
645− limit_micros: config.per_workspace_micros,
646− ends_at: Some(config.until.clone()),
647− reason: reason.map(str::to_owned),
648− })
649− }
650−
651585 async fn can_start(&self, a: CanStartArgs) -> Result<Outcome<bool>> {
652586 if self.stripe.is_none() {
653587 return Ok(Outcome::Ok(true));
733667 if run.own_provider() {
734668 return Ok(Outcome::Ok(true));
735669 }
736− // The free allowance on g1t's models covers what it can.
737− let cost = charge_micros(a.cost_usd, 0);
738− let covered = self.trial_covers(&run.workspace, cost).await?;
739− let base = charge_micros((cost - covered) as f64 / MICROS_PER_DOLLAR as f64, self.margin_percent);
670+ // Its cost plus the margin, on the account's terms; then the Team
671+ // credit, the trial credit and, for a public repository, g1t's
672+ // open-source pool pay what they can (see `credits`).
673+ let base = charge_micros(a.cost_usd, self.margin_percent);
740674 let (charge, terms_note) = self.charged(&run.workspace, base).await?;
675+ let month = credits::month_of(&rfc3339(now_ms()));
676+ let eligible = credits::Eligible { trial: true, repo: Some(run.repo.clone()) };
677+ let drawn = self.draw(&run.workspace, charge, &month, &eligible).await?;
741678 let mut description = match run.task.as_str() {
742679 "plan" => format!("Planning for {}", run.repo),
743680 "review" => format!("Review of {}#{}", run.repo, run.number),
745682 _ => format!("Work on {}#{}", run.repo, run.number),
746683 };
747684 description.push_str(&terms_note);
685+ description.push_str(&drawn.note());
748686 self.enter(
749687 &run.workspace,
750688 EntryKind::Usage,
751− -charge,
689+ -(charge - drawn.total()),
752690 &description,
753691 &a.run_id,
754692 Some(&run),
757695 None,
758696 )
759697 .await?;
698+ self.record_drawn(&a.run_id, &drawn).await?;
760699 Ok(Outcome::Ok(true))
761700 }
762701 }
789728 (cost, Price::price_for(cost, self.margin_percent))
790729 });
791730 let (charge, terms_note) = self.charged(&workspace, sandbox_charge(seconds, price_per_second)).await?;
792− let description = format!("{}: {} of sandbox time{terms_note}", a.description, duration(seconds));
731+ let eligible = credits::Eligible { trial: true, repo: a.repo.clone() };
732+ let drawn = self.draw(&workspace, charge, &credits::month_of(&timestamp), &eligible).await?;
733+ let charge = charge - drawn.total();
734+ let description = format!("{}: {} of sandbox time{terms_note}{}", a.description, duration(seconds), drawn.note());
793735 self.db
794736 .batch(vec![
795737 self.db
796738 .prepare(
797739 "INSERT INTO ledger
798740 (id, workspace, kind, amount_micros, description, repo, task,
799− cost_micros, reference, created_at, billed_to)
800− VALUES (?, ?, 'usage', ?, ?, ?, 'sandbox', ?, ?, ?, 'g1t')",
741+ cost_micros, reference, created_at, billed_to, credit_micros, trial_micros, oss_micros)
742+ VALUES (?, ?, 'usage', ?, ?, ?, 'sandbox', ?, ?, ?, 'g1t', ?, ?, ?)",
801743 )
802744 .bind(&[
803745 new_id("led", now).into(),
808750 (seconds as f64 * cost_per_second).ceil().into(),
809751 a.reference.as_str().into(),
810752 timestamp.as_str().into(),
753+ (drawn.credit as f64).into(),
754+ (drawn.trial as f64).into(),
755+ (drawn.oss as f64).into(),
811756 ])?,
812757 self.db
813758 .prepare(
893838 .ok()
894839 .and_then(|cents| cents.to_string().parse().ok())
895840 .unwrap_or(500),
896− trial: {
897− let number = |name: &str| env.var(name).ok().and_then(|v| v.to_string().parse::<i64>().ok());
898− match (
899− number("TRIAL_WORKSPACE_MICROS"),
900− number("TRIAL_TOTAL_MICROS"),
901− env.var("TRIAL_UNTIL").ok().map(|v| v.to_string()),
902− ) {
903− (Some(per_workspace_micros), Some(total_micros), Some(until))
904− if per_workspace_micros > 0 && !until.is_empty() =>
905− {
906− Some(TrialConfig {
907− per_workspace_micros,
908− total_micros,
909− until,
910− })
911− }
912− _ => None,
913− }
841+ trials_on: {
842+ let plans = credits::Config::from_env(env);
843+ plans.trial_workspace_micros > 0 && plans.trial_monthly_pool_micros > 0
914844 },
845+ plans: credits::Config::from_env(env),
846+ repos: env.service("REPOS").ok(),
915847 })
916848 }
917849 }
928860 if let Err(error) = billing.autopay().await {
929861 worker::console_error!("paying at the limit failed: {error}");
930862 }
863+ // Last month's metered usage (scans, embeddings, storage) goes on the
864+ // ledger before the month is closed and invoiced.
865+ if let Err(error) = billing.charge_pending().await {
866+ worker::console_error!("charging last month's metered usage failed: {error}");
867+ }
931868 if let Err(error) = billing.close_months().await {
932869 worker::console_error!("closing the month failed: {error}");
933870 }
946883 worker::console_error!("checking costs against Cloudflare failed: {error}");
947884 }
948885 }
886+ // Once a day: what each workspace's private repositories hold.
887+ if event.cron() == keeper::DAILY {
888+ if let Err(error) = billing.measure_storage().await {
889+ worker::console_error!("measuring storage failed: {error}");
890+ }
891+ }
949892 }
950893
951894 /// Events from the bus, on billing's own queue: only a workspace's rename
1017960 "admin_create_enterprise" => reply(&billing.admin_create_enterprise(args(body)?).await?),
1018961 "admin_attach" => reply(&billing.admin_attach(args(body)?).await?),
1019962 "admin_credit" => reply(&billing.admin_credit(args(body)?).await?),
963+ "admin_set_allowances" => reply(&billing.admin_set_allowances(args(body)?).await?),
964+ "entitlements" => reply(&billing.entitlements(args(body)?).await?),
1020965 _ => Response::error("Unknown method", 404),
1021966 }
1022967 }
+36−18
139139 with_month.push(month_start.as_str().into());
140140 // Each usage entry at its cost to g1t or its charge, whichever is
141141 // more; on the workspace's own provider, only g1t's fee is g1t's.
142+ // What a plan's credit, the trial or the open-source pool paid for
143+ // is not unpaid: those are capped budgets already paid for.
142144 let month = self
143145 .db
144146 .prepare(format!(
145147 "SELECT
146148 SUM(CASE WHEN kind = 'usage' THEN
147149 CASE WHEN COALESCE(billed_to, 'g1t') = 'g1t'
148− THEN MAX(COALESCE(cost_micros, 0), -amount_micros)
150+ THEN MAX(COALESCE(cost_micros, 0) - COALESCE(credit_micros, 0)
151+ - COALESCE(trial_micros, 0) - COALESCE(oss_micros, 0),
152+ -amount_micros)
149153 ELSE -amount_micros END
150154 END) AS used,
151155 SUM(CASE WHEN kind = 'top_up' THEN amount_micros END) AS paid
430434 Ok(Outcome::Ok(self.limit_of(&workspace).await?))
431435 }
432436
437+ /// What a source cost so far this month. `security`, `context` and
438+ /// `storage` are charged by billing once the month is over (see
439+ /// `storage`); `deployments` charges its own.
433440 pub(crate) async fn note_pending(&self, a: NotePendingArgs) -> Result<bool> {
434441 let now = rfc3339(now_ms());
435− let charge = crate::charge_micros(a.cost_micros.max(0) as f64 / g1t_contracts::billing::MICROS_PER_DOLLAR as f64, self.margin_percent);
436− self.db
437− .prepare(
438− "INSERT INTO pending_usage (workspace, source, month, charge_micros, updated_at) VALUES (?1, ?2, ?3, ?4, ?5)
439− ON CONFLICT (workspace, source, month) DO UPDATE SET charge_micros = ?4, updated_at = ?5",
440− )
441− .bind(&[
442− a.workspace.to_lowercase().into(),
443− a.source.as_str().into(),
444− now[..7].into(),
445− (charge as f64).into(),
446− now.as_str().into(),
447− ])?
448− .run()
449− .await?;
442+ self.set_pending(&a.workspace, &a.source, &now[..7], a.cost_micros).await?;
450443 Ok(true)
451444 }
452445
553546 continue;
554547 }
555548 let owed = (-account.balance.unwrap_or(0)).max(0);
556− if owed < 10_000 {
557− // Under a cent: nothing worth charging.
549+ if owed == 0 {
558550 record("nothing", 0, None, None)?.run().await?;
559551 continue;
560552 }
553+ if !worth_charging(owed, self.plans.min_charge_micros) {
554+ // Under the minimum charge: a card payment's fee would be
555+ // too much of it. It stays owed and goes on the next
556+ // invoice that reaches the minimum.
557+ record("carried", owed, None, None)?.run().await?;
558+ continue;
559+ }
561560 match self.invoice_workspace(&account.workspace, "month", &closing).await? {
562561 Ok(invoice) if invoice.status == "paid" => {
563562 record("paid", invoice.amount_micros, Some(&invoice.invoice_id), None)?.run().await?;
701700 }
702701 }
703702
703+/// Whether `owed` is enough to charge a card: at least the minimum charge
704+/// (`MIN_CHARGE_MICROS`). Less carries over to the next invoice.
705+pub(crate) fn worth_charging(owed: i64, min_charge: i64) -> bool {
706+ owed > 0 && owed >= min_charge
707+}
708+
704709 /// Which warning a workspace has reached: 100, 80, 50 or none (0).
705710 pub(crate) fn warning_level(exposure: i64, ceiling: i64) -> i64 {
706711 if exposure >= ceiling {
773778 }
774779
775780 #[test]
781+ fn amounts_under_the_minimum_carry_over() {
782+ let min = 5_000_000;
783+ assert!(!worth_charging(0, min));
784+ assert!(!worth_charging(4_990_000, min));
785+ assert!(worth_charging(5_000_000, min));
786+ assert!(worth_charging(12_000_000, min));
787+ // $3 carried from last month and $2.50 this month: charged together.
788+ let carried = 3_000_000;
789+ assert!(!worth_charging(carried, min));
790+ assert!(worth_charging(carried + 2_500_000, min));
791+ }
792+
793+ #[test]
776794 fn the_month_before_wraps_the_year() {
777795 assert_eq!(previous_month("2026-10"), "2026-09");
778796 assert_eq!(previous_month("2026-01"), "2025-12");
+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

This change is too large to show in full.