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.
| 139 | 139 | member of the workspace chooses to merge anyway. | |
| 140 | 140 | ||
| 141 | 141 | 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. | |
| 145 | 145 | ||
| 146 | 146 | ## Review | |
| 147 | 147 | ||
| 234 | 234 | - **Milestones.** | |
| 235 | 235 | - **g1t agents for everyone.** g1t can put its own agents on an issue, each | |
| 236 | 236 | 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). |
| 123 | 123 | ## Who may run workflows | |
| 124 | 124 | ||
| 125 | 125 | 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 | |
| 131 | 132 | recorded with its jobs failed and the reason, and the Actions page says so | |
| 132 | 133 | before the first run. | |
| 133 | 134 |
| 52 | 52 | order, and a pull request's **Agent** panel shows the latest of what its | |
| 53 | 53 | runs did. Both link to the full log, filtered to the run. | |
| 54 | 54 | ||
| 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 | + | ||
| 55 | 61 | ## Export | |
| 56 | 62 | ||
| 57 | 63 | **CSV** and **JSON** on the Audit log page download what the current |
| 273 | 273 | | 1 million requests | To all of the workspace's apps. | | |
| 274 | 274 | | 3 million CPU milliseconds | Time your code spends computing. Waiting on the network is not counted. | | |
| 275 | 275 | | 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. | | |
| 276 | 277 | ||
| 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%: | |
| 280 | 279 | ||
| 281 | 280 | | | Price | | |
| 282 | 281 | | --- | --- | | |
| 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. | | |
| 284 | 283 | | Each app past 10 | $0.024 a month | | |
| 285 | 284 | | Each million requests past 1 million | $0.36 | | |
| 286 | 285 | | Each million CPU milliseconds past 3 million | $0.024 | | |
| 287 | 286 | | Each custom domain past 3 | $0.12 a month, by the most the workspace had at once that month | | |
| 288 | 287 | ||
| 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*. | |
| 292 | 293 | ||
| 293 | 294 | An app that no one visits costs nothing beyond counting toward the 10. That | |
| 294 | 295 | is why previews come down when their pull request closes and after their | |
| 296 | 297 | ||
| 297 | 298 | ### Seeing what you use | |
| 298 | 299 | ||
| 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 | |
| 302 | 303 | every 10 minutes. | |
| 303 | 304 | - 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. | |
| 305 | 307 | - Each build's page shows how long it ran. | |
| 306 | 308 | ||
| 307 | 309 | ## Turn it off |
| 259 | 259 | ||
| 260 | 260 | **Who can.** Members of the project's workspace. Anyone else who mentions | |
| 261 | 261 | 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 | |
| 263 | 263 | up and it has no model provider of its own), g1t-agent replies with why. | |
| 264 | 264 | ||
| 265 | 265 | Each comment starts one run at most; to ask again, write a new comment. | |
| 334 | 334 | ||
| 335 | 335 | - g1t's agents, and the sandboxes that run acceptance checks and the merge | |
| 336 | 336 | 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). | |
| 340 | 340 | - A run has two hours. After that its credentials expire and it can no | |
| 341 | 341 | longer push or report. | |
| 342 | 342 |
| 53 | 53 | everyone else it looks exactly like a repository that does not exist, both | |
| 54 | 54 | on the site and to git. | |
| 55 | 55 | ||
| 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 | + | ||
| 56 | 77 | ## Protected branches | |
| 57 | 78 | ||
| 58 | 79 | A repository can protect its default branch under **Settings → Repository**. Pushing to |
| 7 | 7 | ||
| 8 | 8 | - **g1t's hosted models.** g1t chooses the model for each kind of work, pays | |
| 9 | 9 | 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. | |
| 13 | 13 | - **Your own providers.** Connect as many as you use, then choose, for each | |
| 14 | 14 | kind of work, which provider and model it runs on. Each provider bills | |
| 15 | 15 | you directly. Open to every workspace now. |
| 3 | 3 | description: What g1t costs, how a workspace pays, the limits that keep unpaid usage in check, and enterprise billing. | |
| 4 | 4 | --- | |
| 5 | 5 | ||
| 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. | |
| 11 | 14 | ||
| 12 | 15 | Some features are paid for with a monthly plan the workspace turns on, and | |
| 13 | 16 | are never free, including while the rest of g1t is. See | |
| 15 | 18 | ||
| 16 | 19 | ## Plans | |
| 17 | 20 | ||
| 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. | |
| 20 | 23 | ||
| 21 | 24 | | Plan | Price | Includes each month | | |
| 22 | 25 | | --- | --- | --- | | |
| 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 | | |
| 24 | 40 | ||
| 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 | + | ||
| 25 | 49 | Only an owner can turn a plan on or off. | |
| 26 | 50 | ||
| 27 | 51 | 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. | |
| 30 | 54 | ||
| 31 | 55 | Back on Billing, the plan says **On** and when it renews; the card is kept | |
| 32 | 56 | 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. | |
| 36 | 60 | ||
| 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. | |
| 39 | 64 | ||
| 40 | − | ## The free allowance | |
| 65 | + | ## What is free, and what pays for it | |
| 41 | 66 | ||
| 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. | |
| 47 | 96 | ||
| 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. | |
| 48 | 101 | - 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. | |
| 55 | 108 | ||
| 56 | 109 | ## What is charged | |
| 57 | 110 | ||
| 66 | 119 | | The [merge queue](/guides/merge-queue/) | [Sandbox time](#sandbox-time) | | |
| 67 | 120 | | [Workflow](/guides/actions/) jobs | [Sandbox time](#sandbox-time) | | |
| 68 | 121 | | [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 | | |
| 70 | 127 | ||
| 71 | 128 | Each run is charged when it finishes: what the model provider charged for | |
| 72 | 129 | it, plus 20%. A small change costs a few cents. | |
| 117 | 174 | | Deploy builds | 20% | | |
| 118 | 175 | | App requests, CPU and apps | 20% | | |
| 119 | 176 | | Custom domains past the plan | 20% | | |
| 177 | + | | Private storage, search embeddings and security scans | 20% | | |
| 120 | 178 | ||
| 121 | 179 | ## Sandbox time | |
| 122 | 180 | ||
| 141 | 199 | Each sandbox is one line on the [statement](#the-statement), such as | |
| 142 | 200 | *Checks on acme/api#12: 3m 12s of sandbox time*. | |
| 143 | 201 | ||
| 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 | + | ||
| 144 | 222 | ## Usage limits | |
| 145 | 223 | ||
| 146 | 224 | Every workspace has two limits. One protects you from a surprise bill; | |
| 167 | 245 | ||
| 168 | 246 | | | Ceiling on what is unpaid | | |
| 169 | 247 | | --- | --- | | |
| 170 | − | | **New**: has not paid g1t yet | $3: the free allowances and a little more | | |
| 248 | + | | **New**: has not paid g1t yet | $3 | | |
| 171 | 249 | | **Paid** | twice what you have paid, from $25 up to $1,000 | | |
| 172 | 250 | | **Established**: three steady months | three times your monthly spend, up to $10,000, by itself | | |
| 173 | 251 | | **Reviewed** | what g1t set with you; contact us | | |
| 192 | 270 | What counts as unpaid is each item at what it cost g1t or what it is | |
| 193 | 271 | charged, whichever is more, less what was paid this month, plus anything | |
| 194 | 272 | 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. | |
| 195 | 275 | ||
| 196 | 276 | A **declined card** stops work until it is paid, as does a payment | |
| 197 | 277 | disputed with the card's bank. Paying under Billing with another card | |
| 212 | 292 | Each is charged to the card on file. The Billing page lists them, with | |
| 213 | 293 | links to view each on Stripe and download its PDF. | |
| 214 | 294 | ||
| 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 | + | ||
| 215 | 304 | ## Enterprises and custom terms | |
| 216 | 305 | ||
| 217 | 306 | Some accounts are billed differently, set up by g1t with you: | |
| 225 | 314 | invoice page by card or bank transfer. Paying it clears every workspace | |
| 226 | 315 | on it; if it goes overdue, their work stops until it is paid. | |
| 227 | 316 | - **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. | |
| 230 | 321 | - **Custom**: a discount on every usage charge, a limit of its own, or | |
| 231 | 322 | both, sometimes until a date, after which standard terms apply. | |
| 232 | 323 | ||
| 233 | 324 | 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). | |
| 235 | 327 | ||
| 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 | + | ||
| 236 | 333 | ## Your card, invoices and billing details | |
| 237 | 334 | ||
| 238 | 335 | These live on **Stripe's billing page**, not on g1t: g1t never sees or | |
| 344 | 441 | | Runs on your own model provider | Older months only: the flat fee runs on your own provider used to carry. | | |
| 345 | 442 | | Sandbox time | Each sandbox's time, memory and disk. | | |
| 346 | 443 | | 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. | | |
| 347 | 447 | | Payments | Card payments and invoices paid. | | |
| 348 | 448 | | Credits from g1t | Credit g1t added, such as a goodwill credit. | | |
| 349 | 449 | | Refunds | Money given back to your card. | | |
| 350 | 450 | ||
| 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. | |
| 351 | 457 | - **Open a line** to see its entries, 50 at a time, newest first. Each | |
| 352 | 458 | run links to the pull request it was for. | |
| 353 | 459 | - **Pick a month** to see an earlier one; months with no entries are not | |
| 354 | 460 | listed. | |
| 355 | 461 | - **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 | |
| 357 | 464 | what was charged to your card. | |
| 358 | 465 | ||
| 359 | 466 | Each pull request's session also ends with what its run cost before the | |
| 371 | 478 | provider bills you for the models; g1t charges nothing for now, and only | |
| 372 | 479 | each run's sandbox time once pricing starts. | |
| 373 | 480 | - **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. | |
| 378 | 485 | ||
| 379 | 486 | Each workspace decides where its model spend goes. A workspace that can use | |
| 380 | 487 | neither sees a message saying so, with the way to connect its own provider. |
| 57 | 57 | Add **acceptance checks**, commands that must pass, to hold the work to | |
| 58 | 58 | them. | |
| 59 | 59 | ||
| 60 | − | 5. **Use the free allowance, or connect a model** | |
| 60 | + | 5. **Use your trial credit, or connect a model** | |
| 61 | 61 | ||
| 62 | 62 | 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). | |
| 67 | 67 | ||
| 68 | 68 | For more, or to choose models, connect your Anthropic or OpenAI key, or | |
| 69 | 69 | any compatible endpoint, in your workspace's **Settings → |
| 7 | 7 | import type { ReactNode } from "react"; | |
| 8 | 8 | import { Link } from "react-router"; | |
| 9 | 9 | ||
| 10 | − | import type { AdminAction, AdminOwner, BillingLink, LedgerEntry, Terms } from "@g1t/contracts"; | |
| 10 | + | import type { AdminAction, AdminOwner, Allowances, BillingLink, LedgerEntry, Terms } from "@g1t/contracts"; | |
| 11 | 11 | ||
| 12 | 12 | import { Avatar, Badge, Button, EmptyState, Field, Input, Notice, Section, Select, Textarea, When } from "~/components/ui"; | |
| 13 | 13 | import { actionLabel } from "~/lib/ledgers"; | |
| 259 | 259 | ); | |
| 260 | 260 | } | |
| 261 | 261 | ||
| 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 | + | ||
| 262 | 320 | // --- Credit ------------------------------------------------------------------- | |
| 263 | 321 | ||
| 264 | 322 | export function CreditForm({ workspaces, pathname, error }: { workspaces: string[]; pathname: string; error: SectionError }) { |
| 8 | 8 | import type { Terms } from "@g1t/contracts"; | |
| 9 | 9 | import { data, redirect } from "react-router"; | |
| 10 | 10 | ||
| 11 | − | import { fields, parseCredit, parseEmail, parseNote, parseSlug, parseTerms, text } from "./forms"; | |
| 11 | + | import { fields, parseAllowances, parseCredit, parseEmail, parseNote, parseSlug, parseTerms, text } from "./forms"; | |
| 12 | 12 | import type { ActionData } from "./review"; | |
| 13 | 13 | import { admin, identity } from "./services.server"; | |
| 14 | 14 | import type { Staff } from "./staff"; | |
| 43 | 43 | return back("terms"); | |
| 44 | 44 | } | |
| 45 | 45 | ||
| 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 | + | ||
| 46 | 61 | if (intent === "attach") { | |
| 47 | 62 | const values = fields(form, "workspace", "target"); | |
| 48 | 63 | const section = isEnterprise ? "members" : "billed-to"; |
| 1 | 1 | import assert from "node:assert/strict"; | |
| 2 | 2 | import { test } from "node:test"; | |
| 3 | 3 | ||
| 4 | − | import { parseCredit, parseEmail, parseSales, parseSalesNote, parseSlugList, parseTerms } from "./forms.ts"; | |
| 4 | + | import { parseAllowances, parseCredit, parseEmail, parseSales, parseSalesNote, parseSlugList, parseTerms } from "./forms.ts"; | |
| 5 | 5 | ||
| 6 | 6 | const NOW = new Date("2026-10-04T12:00:00Z"); | |
| 7 | 7 | ||
| 86 | 86 | assert.equal(parseSalesNote("").ok, false); | |
| 87 | 87 | assert.equal(parseSalesNote("x".repeat(2001)).ok, false); | |
| 88 | 88 | }); | |
| 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 | + | }); |
| 2 | 2 | * Reading sudo's forms. Everything typed is checked here before it goes | |
| 3 | 3 | * to the billing service, which checks it again. | |
| 4 | 4 | */ | |
| 5 | − | import type { SalesStage, Terms } from "@g1t/contracts"; | |
| 5 | + | import type { Allowances, SalesStage, Terms } from "@g1t/contracts"; | |
| 6 | 6 | ||
| 7 | 7 | import { MICROS_PER_DOLLAR, parseDollars } from "./money.ts"; | |
| 8 | 8 | import { isStage } from "./signals.ts"; | |
| 176 | 176 | 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." }; | |
| 177 | 177 | return { ok: true, value: micros }; | |
| 178 | 178 | } | |
| 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 | + | } |
| 85 | 85 | /** Each kind of change, as staff read it. */ | |
| 86 | 86 | export const AUDIT_ACTIONS: Record<string, string> = { | |
| 87 | 87 | terms: "Terms changed", | |
| 88 | + | allowances: "Plan and pools changed", | |
| 88 | 89 | create: "Enterprise created", | |
| 89 | 90 | attach: "Workspace added", | |
| 90 | 91 | detach: "Workspace removed", |
| 24 | 24 | /** The flash messages a change redirects back with (`?done=`). */ | |
| 25 | 25 | export const DONE: Record<string, string> = { | |
| 26 | 26 | terms: "Terms saved. They apply to charges from now on.", | |
| 27 | + | allowances: "Plan and pools saved. They apply to charges from now on.", | |
| 27 | 28 | attach: "Workspace moved onto the enterprise.", | |
| 28 | 29 | detach: "Workspace moved off the enterprise. It pays for itself again.", | |
| 29 | 30 | credit: "Credit issued.", |
| 4 | 4 | import { type AdminOwner, type EnterpriseInvoice, type Limit, httpStatus } from "@g1t/contracts"; | |
| 5 | 5 | ||
| 6 | 6 | 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"; | |
| 8 | 8 | import { Avatar, Badge, Button, EmptyState, ExposureBar, Field, Input, Notice, Section, StateBadge, TermsBadge, TrustBadge, When } from "~/components/ui"; | |
| 9 | 9 | import { type Subject, billingAction } from "~/lib/billing-actions.server"; | |
| 10 | 10 | import { usd } from "~/lib/money"; | |
| 150 | 150 | error={error("invoices")} | |
| 151 | 151 | /> | |
| 152 | 152 | <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 | + | /> | |
| 153 | 159 | <LedgerSection ledger={detail.ledger} showWorkspace description="Recent lines for every workspace it pays for, newest first." /> | |
| 154 | 160 | </div> | |
| 155 | 161 | <div className="space-y-6"> |
| 74 | 74 | <Stat label="Paying workspaces" value={String(overview.payingWorkspaces)} hint="Charged something this month" /> | |
| 75 | 75 | </div> | |
| 76 | 76 | ||
| 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 | + | ||
| 77 | 94 | <div className="mt-6 grid gap-6 lg:grid-cols-[minmax(0,3fr)_minmax(0,2fr)]"> | |
| 78 | 95 | <Section title="Last six months" description="Charged against what it cost g1t, every workspace together."> | |
| 79 | 96 | <MonthsChart months={months} /> |
| 6 | 6 | ||
| 7 | 7 | import type { Route } from "./+types/workspace"; | |
| 8 | 8 | import { | |
| 9 | + | AllowancesForm, | |
| 9 | 10 | AuditSection, | |
| 10 | 11 | BillingLinkSection, | |
| 11 | 12 | CreditForm, | |
| 89 | 90 | person, | |
| 90 | 91 | billedTo, | |
| 91 | 92 | terms: subject.terms, | |
| 93 | + | allowances: summary.account.allowances, | |
| 92 | 94 | limit, | |
| 93 | 95 | figures, | |
| 94 | 96 | months: summary.months ?? [], | |
| 124 | 126 | ]; | |
| 125 | 127 | ||
| 126 | 128 | 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 } = | |
| 128 | 130 | loaderData; | |
| 129 | 131 | const { pathname } = useLocation(); | |
| 130 | 132 | const result = actionData as ActionData | undefined; | |
| 274 | 276 | </div> | |
| 275 | 277 | </Section> | |
| 276 | 278 | ) : ( | |
| 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 | + | </> | |
| 278 | 283 | )} | |
| 279 | 284 | <WorkspaceInvoicesSection invoices={invoices} unavailable={invoicesError} /> | |
| 280 | 285 | <div id="ledger" className="scroll-mt-20"> |
| 9 | 9 | import { RepoList } from "./repo-list"; | |
| 10 | 10 | import { ButtonLink, CopyLine } from "./ui"; | |
| 11 | 11 | ||
| 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"); | |
| 14 | 12 | ||
| 15 | 13 | /** A small label above a heading, in the mono face. */ | |
| 16 | 14 | function Eyebrow({ children }: { children: ReactNode }) { | |
| 163 | 161 | Watch agents at work | |
| 164 | 162 | </ButtonLink> | |
| 165 | 163 | </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> | |
| 172 | 168 | </div> | |
| 173 | 169 | <div className="relative mx-auto max-w-5xl px-4 pb-4"> | |
| 174 | 170 | <ConvergeArt className="w-full" /> |
| 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 | + | } |
| 24 | 24 | LayoutGrid, | |
| 25 | 25 | ListTree, | |
| 26 | 26 | Lock, | |
| 27 | + | LogIn, | |
| 27 | 28 | LogOut, | |
| 28 | 29 | Menu, | |
| 29 | 30 | Plus, | |
| 49 | 50 | import { MICROS_PER_DOLLAR } from "@g1t/contracts"; | |
| 50 | 51 | ||
| 51 | 52 | import { CommandPalette, type PaletteCommand, usePaletteShortcut } from "./command-palette"; | |
| 52 | − | import { Mark } from "./logo"; | |
| 53 | + | import { Logo, Mark } from "./logo"; | |
| 53 | 54 | import { Avatar, notACredential } from "./ui"; | |
| 54 | 55 | import { | |
| 55 | 56 | DropdownMenu, | |
| 60 | 61 | DropdownMenuTrigger, | |
| 61 | 62 | } from "./ui/dropdown-menu"; | |
| 62 | 63 | import { type RoadmapItem, roadmapIn } from "../lib/roadmap"; | |
| 64 | + | import { VISITOR_LINKS, projectPages } from "../lib/chrome"; | |
| 65 | + | import { withNext } from "../lib/next"; | |
| 63 | 66 | ||
| 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 | + | */ | |
| 65 | 72 | export type ShellData = { | |
| 66 | 73 | /** The workspace the sidebar is about: the one being looked at, or their first. */ | |
| 67 | 74 | workspace: Membership | null; | |
| 459 | 466 | * as it does for settings: everything about the project, running and its | |
| 460 | 467 | * code, and nothing else, with the way back to everything. | |
| 461 | 468 | */ | |
| 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 | + | }) { | |
| 463 | 481 | 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)); | |
| 464 | 484 | return ( | |
| 465 | 485 | <nav aria-label={`${repo.namespace}/${repo.name}`} inert={!open} className={PANEL}> | |
| 466 | 486 | <Link | |
| 467 | − | to="/" | |
| 487 | + | to={visitor ? "/explore" : "/"} | |
| 468 | 488 | 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" | |
| 469 | 489 | > | |
| 470 | 490 | <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"} | |
| 472 | 492 | </Link> | |
| 473 | 493 | <NavLink | |
| 474 | 494 | to={base} | |
| 504 | 524 | <SidebarLink to={`${base}/actions`} icon={<PlayCircle size={15} />}> | |
| 505 | 525 | Workflows | |
| 506 | 526 | </SidebarLink> | |
| 507 | − | {repo.member ? ( | |
| 527 | + | {shows.has("deployments") ? ( | |
| 508 | 528 | <SidebarLink to={`${base}/deployments`} also={soonPaths(base, "Deployments")} icon={<Rocket size={15} />}> | |
| 509 | 529 | Deployments | |
| 510 | 530 | </SidebarLink> | |
| 512 | 532 | <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."> | |
| 513 | 533 | Observability | |
| 514 | 534 | </SidebarSoonLink> | |
| 515 | − | {repo.member ? ( | |
| 535 | + | {shows.has("security") ? ( | |
| 516 | 536 | <SidebarLink to={`${base}/security`} also={soonPaths(base, "Security")} icon={<ShieldCheck size={15} />}> | |
| 517 | 537 | Security | |
| 518 | 538 | </SidebarLink> | |
| 520 | 540 | <SidebarSoonLink to={`${base}/soon/delivery`} also={soonPaths(base, "Insights")} icon={<BarChart3 size={15} />} about="Delivery metrics, costs and the work agents do."> | |
| 521 | 541 | Insights | |
| 522 | 542 | </SidebarSoonLink> | |
| 523 | − | {repo.member && ( | |
| 543 | + | {shows.has("settings") && ( | |
| 524 | 544 | <SidebarLink to={`${base}/settings`} icon={<Settings size={15} />}> | |
| 525 | 545 | Settings | |
| 526 | 546 | </SidebarLink> | |
| 624 | 644 | ); | |
| 625 | 645 | } | |
| 626 | 646 | ||
| 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 }) { | |
| 628 | 676 | const ws = shell.workspace; | |
| 629 | 677 | const { pathname } = useLocation(); | |
| 630 | 678 | const going = useNavigation().location?.pathname; | |
| 649 | 697 | ? active | |
| 650 | 698 | : { | |
| 651 | 699 | ...targetRepo, | |
| 652 | − | member: (user.workspaces ?? []).some((m) => m.slug === targetRepo.namespace.toLowerCase()), | |
| 700 | + | member: (user?.workspaces ?? []).some((m) => m.slug === targetRepo.namespace.toLowerCase()), | |
| 653 | 701 | } | |
| 654 | 702 | : null; | |
| 655 | 703 | // What sits on the far side of the track. Kept while sliding back, so it | |
| 684 | 732 | <div className="flex h-full flex-col"> | |
| 685 | 733 | {/* The same height and rule as the top bar, so the two read as one line. */} | |
| 686 | 734 | <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 | + | )} | |
| 694 | 750 | </div> | |
| 695 | 751 | <div className="px-2 pt-3"> | |
| 696 | 752 | <button | |
| 706 | 762 | <div className="relative min-h-0 grow overflow-hidden"> | |
| 707 | 763 | <div className={`${LAYER} ${away ? "pointer-events-none -translate-x-1/4 opacity-0" : "translate-x-0 opacity-100"}`}> | |
| 708 | 764 | <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 | + | <> | |
| 709 | 776 | <div className="mt-3 space-y-px"> | |
| 710 | 777 | <SidebarLink to="/" end icon={<LayoutDashboard size={15} />}> | |
| 711 | 778 | Mission control | |
| 777 | 844 | ))} | |
| 778 | 845 | </SidebarGroup> | |
| 779 | 846 | )} | |
| 847 | + | </> | |
| 848 | + | )} | |
| 780 | 849 | </nav> | |
| 781 | 850 | </div> | |
| 782 | 851 | <div className={`${LAYER} ${away ? "translate-x-0 opacity-100" : "pointer-events-none translate-x-full opacity-0"}`}> | |
| 788 | 857 | repo={shown.current} | |
| 789 | 858 | isPrivate={shell.repos.some((repo) => sameRepo(repo, shown.current) && repo.isPrivate)} | |
| 790 | 859 | open={inRepo} | |
| 860 | + | visitor={!user} | |
| 791 | 861 | /> | |
| 792 | 862 | ) : side.current === "account" || !ws ? ( | |
| 793 | 863 | <AccountSettingsMenu open={inAccount} /> | |
| 821 | 891 | </div> | |
| 822 | 892 | )} | |
| 823 | 893 | {ws && <UsageCard slug={ws.slug} shell={shell} />} | |
| 824 | − | <AccountMenu user={user} /> | |
| 894 | + | {user ? <AccountMenu user={user} /> : <VisitorPanel />} | |
| 825 | 895 | </div> | |
| 826 | 896 | </div> | |
| 827 | 897 | ); | |
| 867 | 937 | }; | |
| 868 | 938 | return <span className="text-sm font-medium">{words[parts[0]!]}</span>; | |
| 869 | 939 | } | |
| 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 | + | } | |
| 870 | 944 | const [owner, second, third, fourth] = parts; | |
| 871 | 945 | const trail: { label: string; to: string; mono?: boolean }[] = [{ label: owner!, to: `/${owner}`, mono: true }]; | |
| 872 | 946 | if (second === "-") { | |
| 911 | 985 | type Command = PaletteCommand; | |
| 912 | 986 | ||
| 913 | 987 | /** 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); | |
| 915 | 990 | const commands: Command[] = [ | |
| 916 | 991 | { label: "Mission control", to: "/", icon: <LayoutDashboard size={15} /> }, | |
| 917 | 992 | { label: "Explore repositories", to: "/explore", icon: <Compass size={15} /> }, | |
| 962 | 1037 | return commands; | |
| 963 | 1038 | } | |
| 964 | 1039 | ||
| 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 | + | ||
| 965 | 1066 | /** | |
| 966 | 1067 | * A bar across the top of the page while the next one loads, as GitHub | |
| 967 | 1068 | * has: it appears at once, creeps towards the end while waiting, then fills | |
| 1004 | 1105 | } | |
| 1005 | 1106 | ||
| 1006 | 1107 | /** | |
| 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. | |
| 1010 | 1113 | */ | |
| 1011 | 1114 | export function AppShell({ | |
| 1012 | 1115 | user, | |
| 1014 | 1117 | banner, | |
| 1015 | 1118 | children, | |
| 1016 | 1119 | }: { | |
| 1017 | − | user: User; | |
| 1120 | + | user: User | null; | |
| 1018 | 1121 | shell: ShellData; | |
| 1019 | 1122 | banner?: ReactNode; | |
| 1020 | 1123 | children: ReactNode; | |
| 1021 | 1124 | }) { | |
| 1022 | − | const submit = useSubmit(); | |
| 1023 | − | const { pathname } = useLocation(); | |
| 1125 | + | const { pathname, search } = useLocation(); | |
| 1024 | 1126 | const [drawer, setDrawer] = useState(false); | |
| 1025 | 1127 | 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]); | |
| 1027 | 1130 | ||
| 1028 | 1131 | // A new page closes the drawer on small screens. | |
| 1029 | 1132 | useEffect(() => setDrawer(false), [pathname]); | |
| 1088 | 1191 | > | |
| 1089 | 1192 | Docs | |
| 1090 | 1193 | </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 | + | ) : ( | |
| 1091 | 1203 | <DropdownMenu> | |
| 1092 | 1204 | <DropdownMenuTrigger | |
| 1093 | 1205 | aria-label="Create" | |
| 1127 | 1239 | </DropdownMenuItem> | |
| 1128 | 1240 | </DropdownMenuContent> | |
| 1129 | 1241 | </DropdownMenu> | |
| 1242 | + | )} | |
| 1130 | 1243 | </div> | |
| 1131 | 1244 | </header> | |
| 1132 | 1245 | {banner} |
| 105 | 105 | </div> | |
| 106 | 106 | </dl> | |
| 107 | 107 | ||
| 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 | + | ||
| 108 | 125 | <div className="mt-3"> | |
| 109 | 126 | {statement.groups.length === 0 ? ( | |
| 110 | 127 | <EmptyState title={`Nothing in ${monthLabel(statement.month)}`}> | |
| 129 | 146 | kind={line.kind} | |
| 130 | 147 | count={line.count} | |
| 131 | 148 | chargedMicros={line.chargedMicros} | |
| 149 | + | coveredMicros={line.coveredMicros ?? 0} | |
| 132 | 150 | day={group === "day" ? g.key : null} | |
| 133 | 151 | project={group === "project" ? g.key : null} | |
| 134 | 152 | /> | |
| 149 | 167 | kind, | |
| 150 | 168 | count, | |
| 151 | 169 | chargedMicros, | |
| 170 | + | coveredMicros, | |
| 152 | 171 | day, | |
| 153 | 172 | project, | |
| 154 | 173 | }: { | |
| 157 | 176 | kind: string; | |
| 158 | 177 | count: number; | |
| 159 | 178 | chargedMicros: number; | |
| 179 | + | coveredMicros: number; | |
| 160 | 180 | day: string | null; | |
| 161 | 181 | project: string | null; | |
| 162 | 182 | }) { | |
| 199 | 219 | <span className="grow truncate font-medium">{kind}</span> | |
| 200 | 220 | <span className="shrink-0 text-xs text-faint tabular-nums"> | |
| 201 | 221 | {count.toLocaleString("en-US")} {count === 1 ? "entry" : "entries"} | |
| 222 | + | {coveredMicros > 0 && ` · ${charge(coveredMicros)} paid for`} | |
| 202 | 223 | </span> | |
| 203 | 224 | <span | |
| 204 | 225 | className={`w-24 shrink-0 text-right font-mono tabular-nums ${moneyIn ? "text-accent" : "text-fg"}`} |
| 2 | 2 | ||
| 3 | 3 | import { type AuditEntry, type AuditQuery, type Viewer, auditClient } from "@g1t/contracts"; | |
| 4 | 4 | ||
| 5 | − | import { visibilityFor } from "./audit"; | |
| 5 | + | import { retainedSince, visibilityFor } from "./audit"; | |
| 6 | + | import { billing } from "./services.server"; | |
| 6 | 7 | import { roleIn } from "./session.server"; | |
| 7 | 8 | ||
| 8 | 9 | /** The audit log, which the events service keeps. */ | |
| 9 | 10 | export const audit = auditClient(env.EVENTS); | |
| 10 | 11 | ||
| 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 | + | ||
| 11 | 21 | /** The most rows one export writes. */ | |
| 12 | 22 | export const EXPORT_LIMIT = 10_000; | |
| 13 | 23 | ||
| 18 | 28 | export async function auditPage(viewer: Viewer, query: Omit<AuditQuery, "visibility">) { | |
| 19 | 29 | const visibility = viewer ? visibilityFor(roleIn(viewer, query.workspace), viewer.username) : null; | |
| 20 | 30 | 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 }); | |
| 22 | 35 | } | |
| 23 | 36 | ||
| 24 | 37 | /** Every entry matching `query`, page by page, up to `EXPORT_LIMIT`. */ |
| 8 | 8 | exportName, | |
| 9 | 9 | filterHref, | |
| 10 | 10 | parseFilters, | |
| 11 | + | retainedSince, | |
| 11 | 12 | ruleLabel, | |
| 12 | 13 | targetLabel, | |
| 13 | 14 | toCsv, | |
| 89 | 90 | assert.ok(lines[2].includes("'=HYPERLINK(1)")); | |
| 90 | 91 | assert.equal(exportName("acme", "csv", new Date("2026-10-04T23:00:00Z")), "acme-audit-2026-10-04.csv"); | |
| 91 | 92 | }); | |
| 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 | + | }); |
| 24 | 24 | return null; | |
| 25 | 25 | } | |
| 26 | 26 | ||
| 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 | + | ||
| 27 | 37 | /** The filters a page's address can carry. */ | |
| 28 | 38 | export type AuditFilters = { | |
| 29 | 39 | actor: string; |
| 15 | 15 | export const CHANGELOG: ChangelogEntry[] = [ | |
| 16 | 16 | { | |
| 17 | 17 | 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", | |
| 18 | 32 | title: "Cost plus 20%, on everything", | |
| 19 | 33 | about: | |
| 20 | 34 | "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.", |
| 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 | + | }); |
| 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 | + | } |
| 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 | + | }); |
| 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 | + | } |
| 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 | + | } |
| 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 | + | }); |
| 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 | + | } |
| 8 | 8 | ||
| 9 | 9 | import { type Result, type Role, type User, type Viewer, httpStatus } from "@g1t/contracts"; | |
| 10 | 10 | ||
| 11 | + | import { safeNext } from "./next"; | |
| 11 | 12 | import { identity } from "./services.server"; | |
| 12 | 13 | ||
| 13 | 14 | const SESSION_COOKIE = "g1t_session"; | |
| 86 | 87 | * `next` cannot redirect off g1t. | |
| 87 | 88 | */ | |
| 88 | 89 | 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")); | |
| 91 | 91 | } | |
| 92 | 92 | ||
| 93 | 93 | /** `Set-Cookie` value that starts a session. */ |
| 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 | + | }); |
| 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 | + | } |
| 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 | + | }); |
| 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 | + | } |
| 22 | 22 | Scripts, | |
| 23 | 23 | ScrollRestoration, | |
| 24 | 24 | type ShouldRevalidateFunctionArgs, | |
| 25 | + | useLocation, | |
| 25 | 26 | useParams, | |
| 26 | 27 | useRouteLoaderData, | |
| 27 | 28 | useSubmit, | |
| 42 | 43 | DropdownMenuTrigger, | |
| 43 | 44 | } from "./components/ui/dropdown-menu"; | |
| 44 | 45 | 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"; | |
| 45 | 50 | import { CommandPalette, type PaletteCommand, usePaletteShortcut } from "./components/command-palette"; | |
| 46 | 51 | import { billing, projects, work } from "./lib/services.server"; | |
| 47 | 52 | import { getViewer, roleIn, viewerMiddleware } from "./lib/session.server"; | |
| 65 | 70 | ||
| 66 | 71 | export const middleware: Route.MiddlewareFunction[] = [viewerMiddleware]; | |
| 67 | 72 | ||
| 68 | − | export async function loader({ context, params }: Route.LoaderArgs) { | |
| 73 | + | export async function loader({ context, params, request }: Route.LoaderArgs) { | |
| 69 | 74 | 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 | + | }; | |
| 71 | 94 | } | |
| 72 | 95 | ||
| 73 | 96 | /** | |
| 85 | 108 | } | |
| 86 | 109 | ||
| 87 | 110 | /** | |
| 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. | |
| 90 | 114 | */ | |
| 91 | 115 | async function shellFor( | |
| 92 | 116 | user: User, | |
| 93 | 117 | params: { owner?: string; repo?: string }, | |
| 118 | + | chosen: string | null, | |
| 94 | 119 | ): Promise<ShellData> { | |
| 95 | 120 | 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); | |
| 98 | 122 | const path = params.owner && params.repo ? { namespace: params.owner, name: params.repo } : null; | |
| 99 | 123 | const now = new Date(); | |
| 100 | 124 | const monthStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)).toISOString(); | |
| 376 | 400 | // Undefined when the root loader itself failed. | |
| 377 | 401 | const root = useRouteLoaderData<typeof loader>("root"); | |
| 378 | 402 | const user = root?.user; | |
| 403 | + | const { pathname } = useLocation(); | |
| 379 | 404 | const banner = user && !user.verified && ( | |
| 380 | 405 | <Form | |
| 381 | 406 | method="post" | |
| 401 | 426 | <Links /> | |
| 402 | 427 | </head> | |
| 403 | 428 | <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}> | |
| 406 | 431 | {children} | |
| 407 | 432 | </AppShell> | |
| 408 | 433 | ) : ( | |
| 431 | 456 | let stack: string | undefined; | |
| 432 | 457 | ||
| 433 | 458 | 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) { | |
| 438 | 462 | details = error.data; | |
| 439 | 463 | } | |
| 440 | 464 | } else if (import.meta.env.DEV && error instanceof Error) { |
| 39 | 39 | import type { Route } from "./+types/home"; | |
| 40 | 40 | import { page } from "../lib/meta"; | |
| 41 | 41 | import { CHANGELOG, changelogHref } from "../lib/changelog"; | |
| 42 | + | import { WORKSPACE_COOKIE, chosenWorkspace } from "../lib/workspace-choice"; | |
| 43 | + | import { trialClosed } from "../lib/trial"; | |
| 42 | 44 | import { | |
| 43 | 45 | type ActivityItem, | |
| 44 | 46 | type Need, | |
| 201 | 203 | const seen = nextSeen(readCookie(cookies, SEEN_COOKIE), now); | |
| 202 | 204 | const tz = readCookie(cookies, TZ_COOKIE); | |
| 203 | 205 | 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; | |
| 205 | 208 | const mine = new Set(memberships.map((m) => m.slug.toLowerCase())); | |
| 206 | 209 | const username = viewer.username; | |
| 207 | 210 | ||
| 958 | 961 | ? { | |
| 959 | 962 | done: true, | |
| 960 | 963 | 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.`, | |
| 962 | 967 | to: workspace ? `/${workspace}/-/integrations` : null, | |
| 963 | 968 | action: "Connect", | |
| 964 | 969 | } | |
| 974 | 979 | done: false, | |
| 975 | 980 | title: "Connect a model", | |
| 976 | 981 | 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")} ` : "" | |
| 982 | 983 | }Agents need a model to think with. Connect your Anthropic or OpenAI key, or any compatible endpoint.`, | |
| 983 | 984 | to: workspace ? `/${workspace}/-/integrations` : null, | |
| 984 | 985 | action: "Connect", |
| 1 | 1 | import { redirect } from "react-router"; | |
| 2 | 2 | ||
| 3 | 3 | import type { Route } from "./+types/logout"; | |
| 4 | − | import { assertSameOrigin, endSession } from "../lib/session.server"; | |
| 4 | + | import { assertSameOrigin, endSession, nextPath } from "../lib/session.server"; | |
| 5 | 5 | ||
| 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 | + | */ | |
| 6 | 10 | export async function action({ request }: Route.ActionArgs) { | |
| 7 | 11 | assertSameOrigin(request); | |
| 8 | − | throw redirect("/", { | |
| 12 | + | throw redirect(nextPath(request), { | |
| 9 | 13 | headers: { "set-cookie": await endSession(request) }, | |
| 10 | 14 | }); | |
| 11 | 15 | } |
| 1 | 1 | import { ArrowUpRight } from "lucide-react"; | |
| 2 | 2 | ||
| 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"; | |
| 4 | 4 | ||
| 5 | 5 | import type { Route } from "./+types/pricing"; | |
| 6 | 6 | import { page } from "../lib/meta"; | |
| 10 | 10 | export function meta(args: Route.MetaArgs) { | |
| 11 | 11 | return page(args, { | |
| 12 | 12 | 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.", | |
| 14 | 15 | }); | |
| 15 | 16 | } | |
| 16 | 17 | ||
| 32 | 33 | return price.unit === "second" ? `${money(micros * 60)} a minute` : `${money(micros)} per ${price.unit}`; | |
| 33 | 34 | } | |
| 34 | 35 | ||
| 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 | + | ||
| 35 | 88 | const HOW = [ | |
| 36 | 89 | { | |
| 37 | 90 | title: "Our cost, passed through", | |
| 47 | 100 | }, | |
| 48 | 101 | { | |
| 49 | 102 | 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.", | |
| 51 | 108 | }, | |
| 52 | 109 | { | |
| 53 | 110 | title: "Limits that protect both of us", | |
| 54 | 111 | 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.", | |
| 59 | 112 | }, | |
| 60 | 113 | ]; | |
| 61 | 114 | ||
| 62 | 115 | export default function Pricing({ loaderData }: Route.ComponentProps) { | |
| 63 | 116 | const { book, free } = loaderData; | |
| 64 | 117 | 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; | |
| 65 | 120 | return ( | |
| 66 | 121 | <main className="mx-auto max-w-4xl px-4 py-12"> | |
| 67 | 122 | <p className="text-sm font-medium text-accent">Pricing</p> | |
| 135 | 190 | </table> | |
| 136 | 191 | </div> | |
| 137 | 192 | ||
| 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> | |
| 140 | 284 | <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> | |
| 153 | 312 | </section> | |
| 154 | 313 | ||
| 155 | 314 | <h2 className="mt-14 text-xl font-semibold tracking-tight">Price changes</h2> | |
| 161 | 320 | const before = change.oldCostMicros * (100 + (change.oldMarkupPercent ?? change.markupPercent)); | |
| 162 | 321 | const after = change.newCostMicros * (100 + change.markupPercent); | |
| 163 | 322 | const up = after > before; | |
| 323 | + | const fresh = after === before; | |
| 164 | 324 | return ( | |
| 165 | 325 | <li key={`${change.meter}-${change.createdAt}`} className="px-4 py-3 text-sm"> | |
| 166 | 326 | <p> | |
| 167 | 327 | <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 | + | )} | |
| 171 | 335 | </p> | |
| 172 | 336 | <p className="mt-0.5 text-xs text-faint"> | |
| 173 | 337 | {change.reason} · <TimeAgo at={change.createdAt} /> |
| 34 | 34 | PullIcon, | |
| 35 | 35 | plainText, | |
| 36 | 36 | } from "../../components/work"; | |
| 37 | + | import { notFound } from "../../lib/not-found.server"; | |
| 37 | 38 | import { identity, integrations, work } from "../../lib/services.server"; | |
| 38 | 39 | import { assertSameOrigin, getViewer, requireUser, roleIn } from "../../lib/session.server"; | |
| 39 | 40 | ||
| 73 | 74 | // Issues and pull requests share numbers; this one may be a pull request. | |
| 74 | 75 | const pull = await work.getPull(path, number, viewer); | |
| 75 | 76 | if (pull.ok) throw redirect(`/${params.owner}/${params.repo}/pull/${number}`); | |
| 76 | − | throw new Response("Issue not found.", { status: 404 }); | |
| 77 | + | throw notFound("issue"); | |
| 77 | 78 | } | |
| 78 | 79 | const { issue } = found.value; | |
| 79 | 80 | return { |
| 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"; | |
| 3 | 3 | ||
| 4 | 4 | import type { Project } from "@g1t/contracts"; | |
| 5 | 5 | ||
| 6 | 6 | import type { Route } from "./+types/layout"; | |
| 7 | 7 | import { page } from "../../lib/meta"; | |
| 8 | 8 | 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"; | |
| 10 | 11 | import { redirectIfRenamed } from "../../lib/renamed.server"; | |
| 11 | 12 | import { projects, repos, work } from "../../lib/services.server"; | |
| 12 | 13 | import { getViewer, roleIn, unwrap } from "../../lib/session.server"; | |
| 26 | 27 | if (!repo.ok && !found.ok) { | |
| 27 | 28 | // Under a workspace's old name, after a rename: the project is at the new one. | |
| 28 | 29 | await redirectIfRenamed(request, params.owner); | |
| 29 | − | throw data(null, { status: 404 }); | |
| 30 | + | throw notFound("project"); | |
| 30 | 31 | } | |
| 31 | 32 | let project: Project | null = found.ok ? found.value : null; | |
| 32 | 33 | // A repository made a moment ago, before its project: make it now. | |
| 107 | 108 | const current = (tab: PageTab) => | |
| 108 | 109 | [tab.path, ...(tab.also ?? [])].some((path) => rest === path || rest.startsWith(`${path}/`)); | |
| 109 | 110 | 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"> | |
| 111 | 112 | {tabs.map((tab) => ( | |
| 112 | 113 | <NavLink | |
| 113 | 114 | key={tab.path} | |
| 137 | 138 | } | |
| 138 | 139 | ||
| 139 | 140 | export default function ProjectLayout({ loaderData }: Route.ComponentProps) { | |
| 140 | − | const { repo, project, open, member } = loaderData; | |
| 141 | + | const { repo, project, member } = loaderData; | |
| 141 | 142 | const base = `/${repo.namespace}/${repo.name}`; | |
| 142 | − | const signedIn = useRouteLoaderData("root")?.user != null; | |
| 143 | 143 | const description = project?.description ?? repo.description; | |
| 144 | 144 | const { pathname } = useLocation(); | |
| 145 | 145 | 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. | |
| 172 | 148 | return ( | |
| 173 | 149 | <> | |
| 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"}`}> | |
| 177 | 152 | <Header | |
| 178 | 153 | project={project} | |
| 179 | 154 | isPrivate={repo.isPrivate} | |
| 180 | 155 | namespace={repo.namespace} | |
| 181 | 156 | name={repo.name} | |
| 182 | − | description={null} | |
| 183 | − | large | |
| 157 | + | description={description} | |
| 184 | 158 | /> | |
| 185 | − | {description && <p className="mt-2 max-w-2xl text-sm text-muted">{description}</p>} | |
| 186 | 159 | <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 | + | )} | |
| 219 | 165 | </div> | |
| 220 | 166 | </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"> | |
| 222 | 168 | <Outlet /> | |
| 223 | 169 | </div> | |
| 224 | 170 | </> |
| 67 | 67 | } from "../../components/work"; | |
| 68 | 68 | import { CatchUpProgress, ChecksSection, ConflictsSection, MergeabilityRow, runIdOf } from "../../components/merge-box"; | |
| 69 | 69 | import { CATCH_UP_TIMEOUT_MS } from "../../lib/catch-up"; | |
| 70 | + | import { notFound } from "../../lib/not-found.server"; | |
| 70 | 71 | import { actions, deployments, identity, projects, repos, work } from "../../lib/services.server"; | |
| 71 | 72 | import { assertSameOrigin, getViewer, requireUser } from "../../lib/session.server"; | |
| 72 | 73 | ||
| 118 | 119 | // Issues and pull requests share numbers; this one may be an issue. | |
| 119 | 120 | const issue = await work.getIssue(path, number, viewer); | |
| 120 | 121 | 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"); | |
| 122 | 123 | } | |
| 123 | 124 | const { pull } = found.value; | |
| 124 | 125 | const range = pullComparison(pull); |
| 20 | 20 | Pencil, | |
| 21 | 21 | } from "lucide-react"; | |
| 22 | 22 | 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"; | |
| 24 | 24 | ||
| 25 | 25 | import type { Authored, AuthoredItem, AuthoredSort, AuthoredState, Profile } from "@g1t/contracts"; | |
| 26 | 26 | ||
| 30 | 30 | import { RadioGroup, RadioOption } from "../components/ui/radio-group"; | |
| 31 | 31 | import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../components/ui/select"; | |
| 32 | 32 | import { IssueIcon, PullIcon } from "../components/work"; | |
| 33 | + | import { notFound } from "../lib/not-found.server"; | |
| 33 | 34 | import { identity, repos, work } from "../lib/services.server"; | |
| 34 | 35 | import { getViewer } from "../lib/session.server"; | |
| 35 | 36 | ||
| 69 | 70 | const url = new URL(request.url); | |
| 70 | 71 | const username = params.username.toLowerCase(); | |
| 71 | 72 | const profile = await identity.profile(username); | |
| 72 | − | if (!profile) throw data(null, { status: 404 }); | |
| 73 | + | if (!profile) throw notFound("person"); | |
| 73 | 74 | // One address per person: `/u/Ada` is `/u/ada`. | |
| 74 | 75 | if (params.username !== profile.username) { | |
| 75 | 76 | throw redirect(`/u/${profile.username}${url.search}`); |
| 6 | 6 | import { AuditTable } from "../../components/audit"; | |
| 7 | 7 | import { Button, ButtonLink, EmptyState, Field, Input } from "../../components/ui"; | |
| 8 | 8 | import { type AuditFilters, filterHref, parseFilters, toQuery } from "../../lib/audit"; | |
| 9 | − | import { auditPage } from "../../lib/audit.server"; | |
| 9 | + | import { auditPage, auditRetention } from "../../lib/audit.server"; | |
| 10 | 10 | import { repos } from "../../lib/services.server"; | |
| 11 | 11 | import { requireUser, roleIn } from "../../lib/session.server"; | |
| 12 | 12 | ||
| 37 | 37 | if (!role) throw data("Only members of this workspace can read its audit log.", { status: 404 }); | |
| 38 | 38 | const filters = parseFilters(new URL(request.url).searchParams); | |
| 39 | 39 | 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([ | |
| 41 | 41 | auditPage(viewer, query), | |
| 42 | 42 | repos.list(viewer, { namespace: workspace }).catch(() => []), | |
| 43 | + | auditRetention(workspace), | |
| 43 | 44 | ]); | |
| 44 | 45 | return { | |
| 45 | 46 | workspace, | |
| 48 | 49 | entries: found?.entries ?? [], | |
| 49 | 50 | next: found?.next ?? null, | |
| 50 | 51 | projects: projects.map((repo) => repo.name), | |
| 52 | + | retention, | |
| 51 | 53 | }; | |
| 52 | 54 | } | |
| 53 | 55 | ||
| 75 | 77 | "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"; | |
| 76 | 78 | ||
| 77 | 79 | 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; | |
| 79 | 81 | const base = `/${workspace}/-/audit`; | |
| 80 | 82 | const filtered = Object.entries(filters).some(([key, value]) => key !== "before" && value); | |
| 81 | 83 | const exportHref = (format: "csv" | "json") => filterHref(`${base}/export`, { ...filters, before: "" }) + `${filtered ? "&" : "?"}format=${format}`; | |
| 88 | 90 | {role === "owner" | |
| 89 | 91 | ? " As an owner you see the whole workspace." | |
| 90 | 92 | : " 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.`)} | |
| 91 | 97 | </p> | |
| 92 | 98 | ||
| 93 | 99 | <Form method="get" className="mt-6 rounded-xl border border-line bg-surface p-4"> |
| 1 | − | import { ArrowUpRight, CreditCard, FileText, Receipt, Rocket } from "lucide-react"; | |
| 1 | + | import { ArrowUpRight, CreditCard, FileText, Receipt, Rocket, Users } from "lucide-react"; | |
| 2 | 2 | import { Form, Link, data, redirect, useNavigation } from "react-router"; | |
| 3 | 3 | ||
| 4 | 4 | import { | |
| 5 | 5 | DEPLOYMENTS_ALLOWANCE, | |
| 6 | 6 | MICROS_PER_DOLLAR, | |
| 7 | 7 | type DeployUsage, | |
| 8 | + | type Entitlements, | |
| 8 | 9 | type Feature, | |
| 9 | 10 | type FeatureState, | |
| 10 | 11 | type Limit, | |
| 54 | 55 | const group: "day" | "project" = url.searchParams.get("group") === "project" ? "project" : "day"; | |
| 55 | 56 | const now = new Date(); | |
| 56 | 57 | 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([ | |
| 58 | 59 | billing.account(slug, viewer), | |
| 59 | 60 | billing.statement(slug, viewer, url.searchParams.get("month"), group), | |
| 60 | 61 | billing.features(slug, viewer), | |
| 63 | 64 | billing.invoices(slug, viewer).catch(() => null), | |
| 64 | 65 | billing.usage(slug, viewer, monthStart).catch(() => null), | |
| 65 | 66 | billing.usage(slug, viewer, "1970-01-01T00:00:00.000Z").catch(() => null), | |
| 67 | + | billing.entitlements(slug).catch(() => null), | |
| 66 | 68 | ]); | |
| 67 | 69 | return { | |
| 68 | 70 | slug, | |
| 79 | 81 | total: allTime?.ok ? allTime.value.spentMicros : null, | |
| 80 | 82 | added: allTime?.ok ? allTime.value.addedMicros : null, | |
| 81 | 83 | }, | |
| 84 | + | entitlements, | |
| 82 | 85 | added: url.searchParams.has("added"), | |
| 83 | 86 | subscribed: url.searchParams.has("subscribed"), | |
| 84 | 87 | }; | |
| 140 | 143 | } | |
| 141 | 144 | ||
| 142 | 145 | 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; | |
| 144 | 148 | const { status } = account; | |
| 145 | 149 | const paying = useNavigation().state === "submitting"; | |
| 146 | 150 | const empty = account.balanceMicros <= 0; | |
| 242 | 246 | ||
| 243 | 247 | <h2 className="font-medium">Plans</h2> | |
| 244 | 248 | <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. | |
| 247 | 251 | </p> | |
| 248 | 252 | {subscribed && <p className="mt-3 text-sm text-accent">Payment received. The plan is on.</p>} | |
| 249 | 253 | <div className="mt-5 space-y-4"> | |
| 256 | 260 | live={account.status.live} | |
| 257 | 261 | busy={paying} | |
| 258 | 262 | usage={state.plan.feature === "deployments" ? deployUsage : null} | |
| 263 | + | entitlements={entitlements} | |
| 259 | 264 | /> | |
| 260 | 265 | ))} | |
| 261 | 266 | </div> | |
| 371 | 376 | </Link> | |
| 372 | 377 | , the provider bills you for the model, and a run here is charged only its sandbox time. | |
| 373 | 378 | </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> | |
| 374 | 387 | <li>No seats: add as many people and agents as you like.</li> | |
| 375 | 388 | </ul> | |
| 376 | 389 | <div className="mt-4 space-y-1.5 border-t border-line pt-4 text-sm"> | |
| 401 | 414 | live, | |
| 402 | 415 | busy, | |
| 403 | 416 | usage, | |
| 417 | + | entitlements, | |
| 404 | 418 | }: { | |
| 405 | 419 | state: FeatureState; | |
| 406 | 420 | owner: boolean; | |
| 408 | 422 | live: boolean; | |
| 409 | 423 | busy: boolean; | |
| 410 | 424 | usage: DeployUsage | null; | |
| 425 | + | entitlements: Entitlements | null; | |
| 411 | 426 | }) { | |
| 412 | 427 | const { plan, subscription } = state; | |
| 413 | 428 | const ending = subscription?.status === "canceling"; | |
| 414 | 429 | const owed = subscription?.status === "past_due"; | |
| 430 | + | const team = plan.feature === "team"; | |
| 431 | + | const Icon = team ? Users : Rocket; | |
| 415 | 432 | return ( | |
| 416 | 433 | <section | |
| 417 | 434 | className={`rounded-xl border p-5 ${ | |
| 418 | − | state.on && subscription | |
| 435 | + | (state.on && subscription) || state.included | |
| 419 | 436 | ? "border-accent/40 bg-accent/5" | |
| 420 | 437 | : owed | |
| 421 | 438 | ? "border-warn/40 bg-warn/5" | |
| 425 | 442 | <div className="flex flex-wrap items-start justify-between gap-4"> | |
| 426 | 443 | <div className="min-w-0"> | |
| 427 | 444 | <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" /> | |
| 429 | 446 | {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 | + | )} | |
| 430 | 450 | {state.on && subscription && ( | |
| 431 | 451 | <span className="rounded-full bg-accent/15 px-2 py-0.5 text-xs text-accent"> | |
| 432 | 452 | {ending ? "Ends " : "On"} | |
| 441 | 461 | {owed && <span className="rounded-full bg-warn/15 px-2 py-0.5 text-xs text-warn">Payment failed</span>} | |
| 442 | 462 | </h3> | |
| 443 | 463 | <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."} | |
| 445 | 467 | </p> | |
| 446 | 468 | </div> | |
| 447 | 469 | <p className="shrink-0 text-right"> | |
| 449 | 471 | ${(plan.monthlyCents / 100).toFixed(0)} | |
| 450 | 472 | </span> | |
| 451 | 473 | <span className="text-sm text-muted"> / month</span> | |
| 474 | + | {team && <span className="block text-xs text-faint">per workspace</span>} | |
| 452 | 475 | </p> | |
| 453 | 476 | </div> | |
| 454 | 477 | <ul className="mt-4 grid gap-1.5 text-sm text-muted sm:grid-cols-2"> | |
| 460 | 483 | ))} | |
| 461 | 484 | </ul> | |
| 462 | 485 | <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 ? ( | |
| 465 | 493 | <p className="mt-4 text-sm text-muted">Payments are not set up on this g1t, so {plan.title} is already on.</p> | |
| 466 | 494 | ) : !owner ? ( | |
| 467 | 495 | !state.on && <p className="mt-4 text-sm text-muted">An owner can turn it on.</p> | |
| 491 | 519 | ); | |
| 492 | 520 | } | |
| 493 | 521 | ||
| 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 | + | ||
| 494 | 543 | /** 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 }) { | |
| 496 | 545 | const a = DEPLOYMENTS_ALLOWANCE; | |
| 546 | + | const buildMinutes = (entitlements?.buildSecondsIncluded ?? a.buildSeconds) / 60; | |
| 497 | 547 | const rows: [string, number, number, (n: number) => string][] = [ | |
| 498 | 548 | ["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")], | |
| 499 | 550 | ["Requests", usage.requests, a.requests, (n) => n.toLocaleString("en-US")], | |
| 500 | 551 | ["CPU milliseconds", usage.cpuMs, a.cpuMs, (n) => n.toLocaleString("en-US")], | |
| 501 | 552 | ]; | |
| 521 | 572 | ))} | |
| 522 | 573 | </ul> | |
| 523 | 574 | <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. | |
| 525 | 577 | {usage.countedAt ? " Requests and CPU time are counted every few minutes." : " Requests are counted once apps get visits."} | |
| 526 | 578 | </p> | |
| 527 | 579 | </div> |
| 24 | 24 | ||
| 25 | 25 | import type { Route } from "./+types/integrations"; | |
| 26 | 26 | import { page } from "../../lib/meta"; | |
| 27 | + | import { trialClosed } from "../../lib/trial"; | |
| 27 | 28 | import { MODEL_CATALOG, ModelCatalog, ModelProviderFields, ProviderMark, ProviderTiles, Routing } from "../../components/model-providers"; | |
| 28 | 29 | import { Avatar, Button, CopyLine, ErrorText, Field, Input, TimeAgo } from "../../components/ui"; | |
| 29 | 30 | import { CheckboxOption } from "../../components/ui/checkbox"; | |
| 203 | 204 | </span> | |
| 204 | 205 | <p className="text-sm text-muted"> | |
| 205 | 206 | <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}.`}{" "} | |
| 211 | 208 | Connect a provider of your own below and your agents start at once, billed by that provider. | |
| 212 | 209 | </p> | |
| 213 | 210 | </div> | |
| 217 | 214 | <p className="flex items-center gap-2 rounded-xl border border-line bg-surface px-4 py-3 text-sm text-muted"> | |
| 218 | 215 | <CheckCircle2 size={15} className="shrink-0 text-merged" /> | |
| 219 | 216 | {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.` | |
| 221 | 220 | : free | |
| 222 | 221 | ? "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." | |
| 223 | 222 | : `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.`} |
| 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"; | |
| 3 | 3 | ||
| 4 | 4 | import type { Route } from "./+types/layout"; | |
| 5 | 5 | 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"; | |
| 7 | 8 | import { redirectIfRenamed } from "../../lib/renamed.server"; | |
| 9 | + | import { rememberWorkspace } from "../../lib/workspace-choice"; | |
| 8 | 10 | import { identity } from "../../lib/services.server"; | |
| 9 | 11 | import { getViewer, roleIn } from "../../lib/session.server"; | |
| 10 | 12 | ||
| 17 | 19 | if (!workspace) { | |
| 18 | 20 | // A workspace's old name, after a rename: its pages are at the new one. | |
| 19 | 21 | await redirectIfRenamed(request, params.owner); | |
| 20 | − | throw data(null, { status: 404 }); | |
| 22 | + | throw notFound("workspace"); | |
| 21 | 23 | } | |
| 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) } }); | |
| 23 | 29 | } | |
| 24 | 30 | ||
| 25 | 31 | /** A workspace's own pages, each with its title and what it is for. */ | |
| 71 | 77 | ||
| 72 | 78 | export default function WorkspaceLayout({ loaderData }: Route.ComponentProps) { | |
| 73 | 79 | 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. | |
| 78 | 82 | const page = PAGES[useLocation().pathname.split("/-/")[1]?.split("/")[0] ?? ""]; | |
| 79 | − | if (signedIn && page) { | |
| 83 | + | if (page) { | |
| 80 | 84 | return ( | |
| 81 | 85 | <div className="mx-auto max-w-5xl px-4 py-10 sm:px-8"> | |
| 82 | 86 | <header className="mb-8 border-b border-line pb-6"> | |
| 89 | 93 | } | |
| 90 | 94 | return ( | |
| 91 | 95 | <> | |
| 92 | − | {/* The workspace's own header band, under the site header. */} | |
| 96 | + | {/* The workspace's own header band. */} | |
| 93 | 97 | <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"> | |
| 95 | 99 | <div className="flex flex-wrap items-center gap-4"> | |
| 96 | 100 | <Avatar name={workspace.slug} image={workspace.avatar} size={52} square /> | |
| 97 | 101 | <div className="min-w-0 grow"> | |
| 112 | 116 | </div> | |
| 113 | 117 | {workspace.description && ( | |
| 114 | 118 | <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> | |
| 149 | 119 | )} | |
| 150 | 120 | </div> | |
| 151 | 121 | </div> |
| 15 | 15 | const statement = await billing.statement(params.owner, viewer, month, "day"); | |
| 16 | 16 | if (!statement.ok) throw data(null, { status: 404 }); | |
| 17 | 17 | 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 | + | ]; | |
| 19 | 33 | for (const kind of kinds) { | |
| 20 | 34 | let before: string | null = null; | |
| 21 | 35 | for (let page = 0; page < MAX_PAGES; page++) { | |
| 51 | 65 | entry.createdBy ?? "", | |
| 52 | 66 | // Charges positive, as on the statement. | |
| 53 | 67 | (-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), | |
| 54 | 71 | ]; | |
| 55 | 72 | } | |
| 56 | 73 |
| 154 | 154 | ||
| 155 | 155 | Each workspace decides how its agents reach a model: its own provider | |
| 156 | 156 | (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. | |
| 160 | 161 | ||
| 161 | 162 | - **Hand off an outcome:** `POST {repo}/plans` with `brief`: what should be | |
| 162 | 163 | true when the work is done. A planner reads the repository and proposes |
| 34 | 34 | pub free: bool, | |
| 35 | 35 | } | |
| 36 | 36 | ||
| 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`. | |
| 41 | 44 | #[derive(Debug, Serialize, Deserialize)] | |
| 42 | 45 | #[serde(rename_all = "camelCase")] | |
| 43 | 46 | pub struct TrialArgs { | |
| 51 | 54 | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 52 | 55 | #[serde(rename_all = "camelCase")] | |
| 53 | 56 | 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. | |
| 55 | 59 | 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. | |
| 57 | 61 | pub used_micros: i64, | |
| 62 | + | /// Its grant, or what it would be granted. | |
| 58 | 63 | 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. | |
| 60 | 66 | 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. | |
| 63 | 70 | 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>, | |
| 64 | 78 | } | |
| 65 | 79 | ||
| 66 | 80 | /// A workspace's standing. | |
| 162 | 176 | /// lines apart. | |
| 163 | 177 | #[serde(default, skip_serializing_if = "Option::is_none")] | |
| 164 | 178 | 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, | |
| 165 | 189 | } | |
| 166 | 190 | ||
| 167 | 191 | fn g1t() -> String { | |
| 320 | 344 | pub enum Feature { | |
| 321 | 345 | /// Previews per pull request and production on g1t.page. | |
| 322 | 346 | 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, | |
| 323 | 350 | } | |
| 324 | 351 | ||
| 325 | 352 | impl Feature { | |
| 326 | − | pub const ALL: [Feature; 1] = [Feature::Deployments]; | |
| 353 | + | pub const ALL: [Feature; 2] = [Feature::Team, Feature::Deployments]; | |
| 327 | 354 | ||
| 328 | 355 | pub fn as_str(self) -> &'static str { | |
| 329 | 356 | match self { | |
| 330 | 357 | Feature::Deployments => "deployments", | |
| 358 | + | Feature::Team => "team", | |
| 331 | 359 | } | |
| 332 | 360 | } | |
| 333 | 361 | ||
| 338 | 366 | pub fn title(self) -> &'static str { | |
| 339 | 367 | match self { | |
| 340 | 368 | Feature::Deployments => "Deployments", | |
| 369 | + | Feature::Team => "Team", | |
| 341 | 370 | } | |
| 342 | 371 | } | |
| 343 | 372 | } | |
| 356 | 385 | pub const MICROS_PER_MILLION_CPU_MS: i64 = 20_000; | |
| 357 | 386 | /// What one second of a build's sandbox costs g1t (Cloudflare | |
| 358 | 387 | /// 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. | |
| 361 | 388 | 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; | |
| 362 | 394 | /// Custom domains across the workspace (Cloudflare for SaaS custom | |
| 363 | 395 | /// hostnames); each one past these is charged by the month. | |
| 364 | 396 | pub const CUSTOM_DOMAINS: u32 = 3; | |
| 481 | 513 | #[serde(rename_all = "camelCase")] | |
| 482 | 514 | pub struct NotePendingArgs { | |
| 483 | 515 | 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. | |
| 485 | 519 | pub source: String, | |
| 486 | 520 | /// What it cost g1t so far this month, before the margin. | |
| 487 | 521 | pub cost_micros: i64, | |
| 555 | 589 | /// The margin on model usage, which is charged at what AI Gateway | |
| 556 | 590 | /// priced each request at. | |
| 557 | 591 | 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>, | |
| 558 | 598 | } | |
| 559 | 599 | ||
| 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 | + | ||
| 560 | 620 | /// Who pays: a billing account. Every workspace has one; by default its | |
| 561 | 621 | /// own. An enterprise account pays for several workspaces at once, as | |
| 562 | 622 | /// GitHub Enterprise does: one bill, one limit, one set of terms. | |
| 577 | 637 | #[serde(default)] | |
| 578 | 638 | pub invoices: Vec<EnterpriseInvoice>, | |
| 579 | 639 | 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, | |
| 580 | 704 | } | |
| 581 | 705 | ||
| 582 | 706 | #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] | |
| 825 | 949 | /// Charges positive; money in (payments, credits) negative. | |
| 826 | 950 | pub charged_micros: i64, | |
| 827 | 951 | 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, | |
| 828 | 957 | } | |
| 829 | 958 | ||
| 830 | 959 | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 834 | 963 | pub paid_micros: i64, | |
| 835 | 964 | pub cost_micros: i64, | |
| 836 | 965 | 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, | |
| 837 | 986 | } | |
| 838 | 987 | ||
| 839 | 988 | /// `statement_entries`: one statement line's entries, newest first, 50 at | |
| 1020 | 1169 | pub open_invoices_micros: i64, | |
| 1021 | 1170 | /// Follow-ups due today or earlier. | |
| 1022 | 1171 | pub follow_ups_due: u32, | |
| 1172 | + | /// The capped budgets g1t pays from, this month. | |
| 1173 | + | #[serde(default)] | |
| 1174 | + | pub pools: Option<Pools>, | |
| 1023 | 1175 | } | |
| 1024 | 1176 | ||
| 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 | + | ||
| 1025 | 1194 | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 1026 | 1195 | #[serde(rename_all = "camelCase")] | |
| 1027 | 1196 | pub struct KindFigures { | |
| 1211 | 1380 | pub subscription: Option<Subscription>, | |
| 1212 | 1381 | /// Whether the feature works for the workspace now. | |
| 1213 | 1382 | 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, | |
| 1214 | 1387 | } | |
| 1215 | 1388 | ||
| 1216 | 1389 | /// `features`: every paid feature and the workspace's plan for each. | |
| 1282 | 1455 | pub repo: Option<String>, | |
| 1283 | 1456 | /// Unique to this charge, e.g. `deployments/acme/2026-10`. | |
| 1284 | 1457 | 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>, | |
| 1285 | 1463 | } | |
| 1286 | 1464 | ||
| 1287 | 1465 | #[cfg(test)] | |
| 1326 | 1504 | serde_json::json!("deployments") | |
| 1327 | 1505 | ); | |
| 1328 | 1506 | 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)); | |
| 1329 | 1509 | assert!(SubscriptionStatus::Canceling.on()); | |
| 1330 | 1510 | assert!(!SubscriptionStatus::PastDue.on()); | |
| 1331 | 1511 | } |
| 670 | 670 | pub next: Option<String>, | |
| 671 | 671 | } | |
| 672 | 672 | ||
| 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 | + | ||
| 673 | 708 | #[cfg(test)] | |
| 674 | 709 | mod topic_tests { | |
| 675 | 710 | use super::*; |
| 59 | 59 | createdAt: string; | |
| 60 | 60 | /** The workspace the line belongs to, which tells an enterprise's lines apart. */ | |
| 61 | 61 | 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; | |
| 62 | 68 | }; | |
| 63 | 69 | ||
| 64 | 70 | /** What lets a sandbox, and nothing else, report what its run cost. */ | |
| 100 | 106 | /** An enterprise's invoices, newest first. */ | |
| 101 | 107 | invoices?: EnterpriseInvoice[]; | |
| 102 | 108 | 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; | |
| 103 | 121 | }; | |
| 104 | 122 | ||
| 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 | + | ||
| 105 | 157 | export type AccountSummary = { | |
| 106 | 158 | account: PayingAccount; | |
| 107 | 159 | limit: Limit; | |
| 157 | 209 | groups: { | |
| 158 | 210 | key: string; | |
| 159 | 211 | 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 }[]; | |
| 161 | 214 | chargedMicros: number; | |
| 162 | 215 | }[]; | |
| 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 | + | }; | |
| 164 | 226 | }; | |
| 165 | 227 | ||
| 166 | 228 | export type MonthFigures = { month: string; chargedMicros: number; costMicros: number; paidMicros: number }; | |
| 217 | 279 | declined: number; | |
| 218 | 280 | openInvoicesMicros: number; | |
| 219 | 281 | followUpsDue: number; | |
| 282 | + | /** g1t's capped budgets for free usage, this month. */ | |
| 283 | + | pools?: Pools | null; | |
| 220 | 284 | }; | |
| 221 | 285 | ||
| 222 | 286 | /** A customer's Stripe billing page, for staff to send them. */ | |
| 243 | 307 | accounts(query?: string): Promise<AccountSummary[]>; | |
| 244 | 308 | account(id: string): Promise<Result<AccountDetail>>; | |
| 245 | 309 | 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>>; | |
| 246 | 312 | createEnterprise(name: string, workspaces: string[], by: string): Promise<Result<PayingAccount>>; | |
| 247 | 313 | attach(workspace: string, account: string | null, by: string): Promise<Result<PayingAccount>>; | |
| 248 | 314 | credit(workspace: string, amountMicros: number, note: string, by: string): Promise<Result<LedgerEntry>>; | |
| 306 | 372 | ||
| 307 | 373 | /** One metered unit: what it costs g1t and what it is sold at; the price follows the cost. */ | |
| 308 | 374 | 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; | |
| 310 | 387 | title: string; | |
| 311 | 388 | unit: string; | |
| 312 | 389 | costMicros: number; | |
| 329 | 406 | createdAt: string; | |
| 330 | 407 | }; | |
| 331 | 408 | ||
| 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 | + | }; | |
| 333 | 418 | ||
| 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 | + | */ | |
| 334 | 441 | export type Trial = { | |
| 335 | 442 | open: boolean; | |
| 336 | 443 | usedMicros: number; | |
| 337 | 444 | limitMicros: number; | |
| 445 | + | /** No longer used: trials do not end on a date. */ | |
| 338 | 446 | 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). */ | |
| 340 | 448 | 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; | |
| 341 | 453 | }; | |
| 342 | 454 | ||
| 343 | 455 | /** | |
| 346 | 458 | * `free` nor the model allowance covers it. Mirrors `Feature` in | |
| 347 | 459 | * `crates/contracts/src/billing.rs`. | |
| 348 | 460 | */ | |
| 349 | − | export type Feature = "deployments"; | |
| 461 | + | export type Feature = "deployments" | "team"; | |
| 350 | 462 | ||
| 351 | 463 | /** What the Deployments plan includes each month. Mirrors `deployments_allowance`. */ | |
| 352 | 464 | export const DEPLOYMENTS_ALLOWANCE = { | |
| 353 | 465 | apps: 10, | |
| 354 | 466 | requests: 1_000_000, | |
| 355 | 467 | cpuMs: 3_000_000, | |
| 468 | + | /** Build time included: 200 minutes. Billing's `DEPLOYMENTS_BUILD_SECONDS` decides. */ | |
| 469 | + | buildSeconds: 12_000, | |
| 356 | 470 | /** What Cloudflare charges g1t past that, in millionths of a dollar. */ | |
| 357 | 471 | microsPerAppMonth: 20_000, | |
| 358 | 472 | microsPerMillionRequests: 300_000, | |
| 359 | 473 | 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. */ | |
| 361 | 475 | microsPerBuildSecond: 21, | |
| 362 | 476 | /** Custom domains across the workspace, and what each one past that costs g1t a month. */ | |
| 363 | 477 | customDomains: 3, | |
| 392 | 506 | subscription: Subscription | null; | |
| 393 | 507 | /** Whether the feature works for the workspace now. */ | |
| 394 | 508 | on: boolean; | |
| 509 | + | /** On without a plan: comped terms, or given by g1t. Nothing to pay or turn off. */ | |
| 510 | + | included?: boolean; | |
| 395 | 511 | }; | |
| 396 | 512 | ||
| 397 | 513 | export interface BillingApi { | |
| 458 | 574 | description: string; | |
| 459 | 575 | repo?: string | null; | |
| 460 | 576 | 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; | |
| 461 | 579 | }): Promise<Result<boolean>>; | |
| 462 | 580 | /** | |
| 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. | |
| 465 | 585 | */ | |
| 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>; | |
| 467 | 589 | /** Every metered price and the recent changes. Public. */ | |
| 468 | 590 | prices(): Promise<PriceBook>; | |
| 469 | 591 | /** A workspace's limit, for its members. */ |
| 264 | 264 | setSpendLimit: (actor, workspace, spendLimitMicros, useFullLimit = false) => | |
| 265 | 265 | call("set_spend_limit", { actor, workspace, spendLimitMicros, use_full_limit: useFullLimit }), | |
| 266 | 266 | invoices: (workspace, viewer) => call("invoices", { workspace, viewer }), | |
| 267 | + | entitlements: (workspace) => call("entitlements", { workspace }), | |
| 267 | 268 | }; | |
| 268 | 269 | } | |
| 269 | 270 | ||
| 273 | 274 | accounts: (query) => call("admin_accounts", { query: query ?? null }), | |
| 274 | 275 | account: (id) => call("admin_account", { id }), | |
| 275 | 276 | 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 }), | |
| 276 | 278 | createEnterprise: (name, workspaces, by) => call("admin_create_enterprise", { name, workspaces, by }), | |
| 277 | 279 | attach: (workspace, account, by) => call("admin_attach", { workspace, account, by }), | |
| 278 | 280 | credit: (workspace, amountMicros, note, by) => call("admin_credit", { workspace, amount_micros: amountMicros, note, by }), |
| 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; |
| 16 | 16 | ||
| 17 | 17 | use g1t_contracts::billing::{ | |
| 18 | 18 | 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, | |
| 21 | 21 | }; | |
| 22 | 22 | use g1t_contracts::time::rfc3339; | |
| 23 | 23 | use g1t_contracts::{FailureCode, Outcome, new_id}; | |
| 43 | 43 | created_at: String, | |
| 44 | 44 | #[serde(default)] | |
| 45 | 45 | 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>, | |
| 46 | 52 | } | |
| 47 | 53 | ||
| 48 | 54 | 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 { | |
| 49 | 65 | fn terms(&self) -> Terms { | |
| 50 | 66 | let expired = self.terms_until.as_deref().is_some_and(|until| until < rfc3339(now_ms()).as_str()); | |
| 51 | 67 | if expired { | |
| 151 | 167 | created_at: row.created_at.clone(), | |
| 152 | 168 | billing_email: row.billing_email.clone(), | |
| 153 | 169 | invoices: vec![], | |
| 170 | + | allowances: row.allowances(), | |
| 154 | 171 | } | |
| 155 | 172 | } | |
| 156 | 173 | ||
| 185 | 202 | created_at: String::new(), | |
| 186 | 203 | billing_email: None, | |
| 187 | 204 | invoices: vec![], | |
| 205 | + | allowances: Allowances::default(), | |
| 188 | 206 | }, | |
| 189 | 207 | }) | |
| 190 | 208 | } | |
| 462 | 480 | Ok(Outcome::Ok(self.find_account(&account.id).await?.unwrap_or(account))) | |
| 463 | 481 | } | |
| 464 | 482 | ||
| 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 | + | ||
| 465 | 542 | pub(crate) async fn admin_create_enterprise(&self, a: AdminCreateEnterpriseArgs) -> Result<Outcome<BillingAccount>> { | |
| 466 | 543 | let name = a.name.trim(); | |
| 467 | 544 | if name.is_empty() || a.by.trim().is_empty() { | |
| 597 | 674 | } | |
| 598 | 675 | } | |
| 599 | 676 | ||
| 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 | + | ||
| 600 | 689 | /// A workspace's figures in `list`, added at the end the first time. | |
| 601 | 690 | fn figures_for<'a>(list: &'a mut Vec<WorkspaceFigures>, workspace: &str) -> &'a mut WorkspaceFigures { | |
| 602 | 691 | let i = match list.iter().position(|f| f.workspace == workspace) { | |
| 633 | 722 | } | |
| 634 | 723 | ||
| 635 | 724 | #[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] | |
| 636 | 732 | fn each_workspace_gets_one_share() { | |
| 637 | 733 | let mut list = vec![]; | |
| 638 | 734 | figures_for(&mut list, "acme").charged_micros += 5; |
| 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 | + | } |
| 6 | 6 | use g1t_contracts::billing::deployments_allowance as allowance; | |
| 7 | 7 | use g1t_contracts::billing::*; | |
| 8 | 8 | use g1t_contracts::time::rfc3339; | |
| 9 | − | use g1t_contracts::{FailureCode, Outcome, Role, new_id}; | |
| 9 | + | use g1t_contracts::{FailureCode, Outcome, Role}; | |
| 10 | 10 | use g1t_kit::now_ms; | |
| 11 | 11 | use serde::Deserialize; | |
| 12 | 12 | use worker::Result; | |
| 59 | 59 | } | |
| 60 | 60 | } | |
| 61 | 61 | ||
| 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 | + | ||
| 62 | 73 | /// Dollars to the cent, or finer for prices under a cent, so that a | |
| 63 | 74 | /// build minute's $0.0015 does not read as nothing. | |
| 64 | 75 | pub(crate) fn dollars(micros: i64) -> String { | |
| 83 | 94 | impl Billing { | |
| 84 | 95 | pub(crate) fn plan(&self, feature: Feature) -> Plan { | |
| 85 | 96 | 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 | + | }, | |
| 86 | 131 | Feature::Deployments => Plan { | |
| 87 | 132 | feature, | |
| 88 | 133 | title: feature.title().to_owned(), | |
| 92 | 137 | "{} apps deployed at once, production and previews together", | |
| 93 | 138 | allowance::APPS | |
| 94 | 139 | ), | |
| 140 | + | format!("{} build minutes", self.plans.build_seconds / 60), | |
| 95 | 141 | format!("{} million requests", allowance::REQUESTS / 1_000_000), | |
| 96 | 142 | format!("{} million CPU milliseconds", allowance::CPU_MS / 1_000_000), | |
| 97 | 143 | format!( | |
| 105 | 151 | "Previews that cost nothing while no one visits them".to_owned(), | |
| 106 | 152 | ], | |
| 107 | 153 | 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.", | |
| 109 | 155 | dollars(crate::charge_micros( | |
| 110 | 156 | allowance::MICROS_PER_APP_MONTH as f64 / MICROS_PER_DOLLAR as f64, | |
| 111 | 157 | self.margin_percent | |
| 204 | 250 | .current(workspace, feature) | |
| 205 | 251 | .await? | |
| 206 | 252 | .and_then(|row| row.subscription()); | |
| 253 | + | let included = self.included(workspace, feature).await?; | |
| 207 | 254 | Ok(FeatureState { | |
| 208 | 255 | 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()), | |
| 210 | 257 | subscription, | |
| 258 | + | included, | |
| 211 | 259 | }) | |
| 212 | 260 | } | |
| 213 | 261 | ||
| 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 | + | ||
| 214 | 279 | pub(crate) async fn features(&self, a: FeaturesArgs) -> Result<Outcome<Vec<FeatureState>>> { | |
| 215 | 280 | let workspace = a.workspace.to_lowercase(); | |
| 216 | 281 | if !a.viewer.is_some_and(|viewer| viewer.is_member(&workspace)) { | |
| 237 | 302 | "Payments are not set up on this g1t, so every feature is already on.", | |
| 238 | 303 | )); | |
| 239 | 304 | }; | |
| 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()) { | |
| 241 | 313 | return Ok(Outcome::fail( | |
| 242 | 314 | FailureCode::Conflict, | |
| 243 | 315 | format!("{} is already on for {workspace}.", a.feature.title()), | |
| 376 | 448 | ||
| 377 | 449 | pub(crate) async fn has_feature(&self, a: HasFeatureArgs) -> Result<Outcome<bool>> { | |
| 378 | 450 | 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? { | |
| 381 | 454 | return Ok(Outcome::Ok(true)); | |
| 382 | 455 | } | |
| 383 | 456 | if self.state(&workspace, a.feature).await?.on { | |
| 406 | 479 | if seen.is_some() { | |
| 407 | 480 | return Ok(Outcome::Ok(false)); | |
| 408 | 481 | } | |
| 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(×tamp); | |
| 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; | |
| 410 | 502 | // 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. | |
| 412 | 506 | 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: ×tamp, | |
| 518 | + | drawn, | |
| 519 | + | }) | |
| 520 | + | .await?; | |
| 448 | 521 | Ok(Outcome::Ok(true)) | |
| 449 | 522 | } | |
| 450 | 523 | } | |
| 451 | 524 | ||
| 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 | + | ||
| 452 | 535 | #[cfg(test)] | |
| 453 | 536 | mod tests { | |
| 454 | 537 | use super::*; | |
| 455 | 538 | ||
| 456 | 539 | #[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] | |
| 457 | 563 | fn prices_under_a_cent_keep_their_digits() { | |
| 458 | 564 | assert_eq!(dollars(1512), "$0.0015"); | |
| 459 | 565 | assert_eq!(dollars(24_000), "$0.024"); |
| 18 | 18 | ||
| 19 | 19 | use crate::Billing; | |
| 20 | 20 | ||
| 21 | − | /// Stripe will not charge a card less than this. | |
| 22 | − | const MIN_INVOICE_MICROS: i64 = 500_000; | |
| 23 | − | ||
| 24 | 21 | /// The invoice's lines: what was used since the last one, by kind, then | |
| 25 | 22 | /// whatever makes the total what is owed. | |
| 26 | 23 | pub(crate) fn invoice_lines(used: &[(String, i64)], owed: i64) -> Vec<InvoiceItem> { | |
| 86 | 83 | let Some(account) = self.row(workspace).await? else { return Ok(Err("Nothing billed yet.".into())) }; | |
| 87 | 84 | let Some(customer) = account.customer_id else { return Ok(Err("No card on file.".into())) }; | |
| 88 | 85 | 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 | + | ))); | |
| 91 | 96 | } | |
| 92 | 97 | // What was used since the last invoice, by kind. | |
| 93 | 98 | #[derive(Deserialize)] | |
| 114 | 119 | "SELECT CASE | |
| 115 | 120 | WHEN task = 'sandbox' THEN 'Sandbox time' | |
| 116 | 121 | 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' | |
| 117 | 125 | WHEN billed_to = 'workspace' THEN 'Runs on your own model provider' | |
| 118 | 126 | ELSE 'Agents on g1t''s models' END AS kind, | |
| 119 | 127 | -SUM(amount_micros) AS charged |
| 292 | 292 | struct Charged { | |
| 293 | 293 | cost_micros: Option<i64>, | |
| 294 | 294 | 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)) } | |
| 295 | 306 | } | |
| 296 | 307 | ||
| 297 | 308 | fn ms(timestamp: &str) -> u64 { | |
| 341 | 352 | }) | |
| 342 | 353 | .collect(), | |
| 343 | 354 | 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 | + | }), | |
| 344 | 365 | }) | |
| 345 | 366 | } | |
| 346 | 367 | ||
| 420 | 441 | let gateway_micros = charge_micros(cost_usd, 0); | |
| 421 | 442 | let charged = self | |
| 422 | 443 | .db | |
| 423 | − | .prepare("SELECT cost_micros, description FROM ledger WHERE reference = ?") | |
| 444 | + | .prepare("SELECT cost_micros, description, amount_micros FROM ledger WHERE reference = ?") | |
| 424 | 445 | .bind(&[run.id.as_str().into()])? | |
| 425 | 446 | .first::<Charged>(None) | |
| 426 | 447 | .await?; | |
| 452 | 473 | match charged { | |
| 453 | 474 | // Never reported: charged now, from the gateway's figure. | |
| 454 | 475 | 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?; | |
| 455 | 479 | 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() | |
| 458 | 484 | ); | |
| 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) | |
| 460 | 486 | .await?; | |
| 487 | + | self.record_drawn(&run.id, &drawn).await?; | |
| 461 | 488 | } | |
| 462 | 489 | Some(charged) => { | |
| 463 | 490 | let reported = charged.cost_micros.unwrap_or(0); | |
| 465 | 492 | if delta == 0 { | |
| 466 | 493 | return Ok(()); | |
| 467 | 494 | } | |
| 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 | + | }; | |
| 469 | 503 | 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 {}{}", | |
| 471 | 505 | charged.description, | |
| 472 | 506 | crate::features::dollars(gateway_micros), | |
| 473 | 507 | crate::features::dollars(reported), | |
| 508 | + | drawn.note(), | |
| 474 | 509 | ); | |
| 510 | + | let reference = format!("{}/settled", run.id); | |
| 475 | 511 | self.enter( | |
| 476 | 512 | &run.workspace, | |
| 477 | 513 | EntryKind::Usage, | |
| 478 | − | amount, | |
| 514 | + | -(change - drawn.total()), | |
| 479 | 515 | &description, | |
| 480 | − | &format!("{}/settled", run.id), | |
| 516 | + | &reference, | |
| 481 | 517 | Some(&row), | |
| 482 | 518 | Some(delta), | |
| 483 | 519 | None, | |
| 484 | 520 | None, | |
| 485 | 521 | ) | |
| 486 | 522 | .await?; | |
| 523 | + | self.record_drawn(&reference, &drawn).await?; | |
| 487 | 524 | } | |
| 488 | 525 | } | |
| 489 | 526 | Ok(()) | |
| 564 | 601 | } | |
| 565 | 602 | // Apps run as Workers: per million requests and CPU milliseconds, | |
| 566 | 603 | // 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] = [ | |
| 568 | 606 | ("app_requests", &["workers", "requests"], "requests"), | |
| 569 | 607 | ("app_cpu", &["workers cpu"], "CPU ms"), | |
| 608 | + | ("scan_cpu", &["workers cpu"], "CPU ms"), | |
| 570 | 609 | ]; | |
| 571 | 610 | for (meter, words, unit) in app_meters { | |
| 572 | 611 | if let Some(rate) = billed_rate(&named(words)) { | |
| 623 | 662 | use super::*; | |
| 624 | 663 | ||
| 625 | 664 | #[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] | |
| 626 | 677 | fn small_moves_are_noise_and_wild_ones_are_not_believed() { | |
| 627 | 678 | assert_eq!(adopt(21.0, 21.2), Ok(None)); | |
| 628 | 679 | assert_eq!(adopt(21.0, 25.0), Ok(Some(25.0))); |
| 17 | 17 | //! the methods and their arguments. | |
| 18 | 18 | ||
| 19 | 19 | mod accounts; | |
| 20 | + | mod credits; | |
| 21 | + | mod storage; | |
| 20 | 22 | mod invoices; | |
| 21 | 23 | mod sales; | |
| 22 | 24 | mod statement; | |
| 82 | 84 | billed_to: Option<String>, | |
| 83 | 85 | #[serde(default)] | |
| 84 | 86 | 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>, | |
| 85 | 93 | } | |
| 86 | 94 | ||
| 87 | 95 | impl From<LedgerRow> for LedgerEntry { | |
| 99 | 107 | created_by: row.created_by, | |
| 100 | 108 | created_at: row.created_at, | |
| 101 | 109 | 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), | |
| 102 | 113 | } | |
| 103 | 114 | } | |
| 104 | 115 | } | |
| 140 | 151 | margin_percent: u32, | |
| 141 | 152 | /// While g1t is being built out, nothing is charged (`FREE_WHILE_BUILDING`). | |
| 142 | 153 | 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>, | |
| 145 | 162 | /// The Deployments plan's monthly price (`DEPLOYMENTS_MONTHLY_CENTS`). | |
| 146 | 163 | deployments_monthly_cents: u32, | |
| 147 | 164 | /// How far unpaid usage may go; see `limits`. | |
| 150 | 167 | prepaid_only: bool, | |
| 151 | 168 | } | |
| 152 | 169 | ||
| 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 | − | ||
| 166 | 170 | impl Billing { | |
| 167 | 171 | fn status(&self) -> Status { | |
| 168 | 172 | Status { | |
| 576 | 580 | ), | |
| 577 | 581 | ) | |
| 578 | 582 | })) | |
| 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))) | |
| 591 | 583 | } | |
| 592 | 584 | ||
| 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 | − | ||
| 651 | 585 | async fn can_start(&self, a: CanStartArgs) -> Result<Outcome<bool>> { | |
| 652 | 586 | if self.stripe.is_none() { | |
| 653 | 587 | return Ok(Outcome::Ok(true)); | |
| 733 | 667 | if run.own_provider() { | |
| 734 | 668 | return Ok(Outcome::Ok(true)); | |
| 735 | 669 | } | |
| 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); | |
| 740 | 674 | 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?; | |
| 741 | 678 | let mut description = match run.task.as_str() { | |
| 742 | 679 | "plan" => format!("Planning for {}", run.repo), | |
| 743 | 680 | "review" => format!("Review of {}#{}", run.repo, run.number), | |
| 745 | 682 | _ => format!("Work on {}#{}", run.repo, run.number), | |
| 746 | 683 | }; | |
| 747 | 684 | description.push_str(&terms_note); | |
| 685 | + | description.push_str(&drawn.note()); | |
| 748 | 686 | self.enter( | |
| 749 | 687 | &run.workspace, | |
| 750 | 688 | EntryKind::Usage, | |
| 751 | − | -charge, | |
| 689 | + | -(charge - drawn.total()), | |
| 752 | 690 | &description, | |
| 753 | 691 | &a.run_id, | |
| 754 | 692 | Some(&run), | |
| 757 | 695 | None, | |
| 758 | 696 | ) | |
| 759 | 697 | .await?; | |
| 698 | + | self.record_drawn(&a.run_id, &drawn).await?; | |
| 760 | 699 | Ok(Outcome::Ok(true)) | |
| 761 | 700 | } | |
| 762 | 701 | } | |
| 789 | 728 | (cost, Price::price_for(cost, self.margin_percent)) | |
| 790 | 729 | }); | |
| 791 | 730 | 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(×tamp), &eligible).await?; | |
| 733 | + | let charge = charge - drawn.total(); | |
| 734 | + | let description = format!("{}: {} of sandbox time{terms_note}{}", a.description, duration(seconds), drawn.note()); | |
| 793 | 735 | self.db | |
| 794 | 736 | .batch(vec![ | |
| 795 | 737 | self.db | |
| 796 | 738 | .prepare( | |
| 797 | 739 | "INSERT INTO ledger | |
| 798 | 740 | (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', ?, ?, ?)", | |
| 801 | 743 | ) | |
| 802 | 744 | .bind(&[ | |
| 803 | 745 | new_id("led", now).into(), | |
| 808 | 750 | (seconds as f64 * cost_per_second).ceil().into(), | |
| 809 | 751 | a.reference.as_str().into(), | |
| 810 | 752 | timestamp.as_str().into(), | |
| 753 | + | (drawn.credit as f64).into(), | |
| 754 | + | (drawn.trial as f64).into(), | |
| 755 | + | (drawn.oss as f64).into(), | |
| 811 | 756 | ])?, | |
| 812 | 757 | self.db | |
| 813 | 758 | .prepare( | |
| 893 | 838 | .ok() | |
| 894 | 839 | .and_then(|cents| cents.to_string().parse().ok()) | |
| 895 | 840 | .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 | |
| 914 | 844 | }, | |
| 845 | + | plans: credits::Config::from_env(env), | |
| 846 | + | repos: env.service("REPOS").ok(), | |
| 915 | 847 | }) | |
| 916 | 848 | } | |
| 917 | 849 | } | |
| 928 | 860 | if let Err(error) = billing.autopay().await { | |
| 929 | 861 | worker::console_error!("paying at the limit failed: {error}"); | |
| 930 | 862 | } | |
| 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 | + | } | |
| 931 | 868 | if let Err(error) = billing.close_months().await { | |
| 932 | 869 | worker::console_error!("closing the month failed: {error}"); | |
| 933 | 870 | } | |
| 946 | 883 | worker::console_error!("checking costs against Cloudflare failed: {error}"); | |
| 947 | 884 | } | |
| 948 | 885 | } | |
| 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 | + | } | |
| 949 | 892 | } | |
| 950 | 893 | ||
| 951 | 894 | /// Events from the bus, on billing's own queue: only a workspace's rename | |
| 1017 | 960 | "admin_create_enterprise" => reply(&billing.admin_create_enterprise(args(body)?).await?), | |
| 1018 | 961 | "admin_attach" => reply(&billing.admin_attach(args(body)?).await?), | |
| 1019 | 962 | "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?), | |
| 1020 | 965 | _ => Response::error("Unknown method", 404), | |
| 1021 | 966 | } | |
| 1022 | 967 | } |
| 139 | 139 | with_month.push(month_start.as_str().into()); | |
| 140 | 140 | // Each usage entry at its cost to g1t or its charge, whichever is | |
| 141 | 141 | // 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. | |
| 142 | 144 | let month = self | |
| 143 | 145 | .db | |
| 144 | 146 | .prepare(format!( | |
| 145 | 147 | "SELECT | |
| 146 | 148 | SUM(CASE WHEN kind = 'usage' THEN | |
| 147 | 149 | 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) | |
| 149 | 153 | ELSE -amount_micros END | |
| 150 | 154 | END) AS used, | |
| 151 | 155 | SUM(CASE WHEN kind = 'top_up' THEN amount_micros END) AS paid | |
| 430 | 434 | Ok(Outcome::Ok(self.limit_of(&workspace).await?)) | |
| 431 | 435 | } | |
| 432 | 436 | ||
| 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. | |
| 433 | 440 | pub(crate) async fn note_pending(&self, a: NotePendingArgs) -> Result<bool> { | |
| 434 | 441 | 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?; | |
| 450 | 443 | Ok(true) | |
| 451 | 444 | } | |
| 452 | 445 | ||
| 553 | 546 | continue; | |
| 554 | 547 | } | |
| 555 | 548 | 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 { | |
| 558 | 550 | record("nothing", 0, None, None)?.run().await?; | |
| 559 | 551 | continue; | |
| 560 | 552 | } | |
| 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 | + | } | |
| 561 | 560 | match self.invoice_workspace(&account.workspace, "month", &closing).await? { | |
| 562 | 561 | Ok(invoice) if invoice.status == "paid" => { | |
| 563 | 562 | record("paid", invoice.amount_micros, Some(&invoice.invoice_id), None)?.run().await?; | |
| 701 | 700 | } | |
| 702 | 701 | } | |
| 703 | 702 | ||
| 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 | + | ||
| 704 | 709 | /// Which warning a workspace has reached: 100, 80, 50 or none (0). | |
| 705 | 710 | pub(crate) fn warning_level(exposure: i64, ceiling: i64) -> i64 { | |
| 706 | 711 | if exposure >= ceiling { | |
| 773 | 778 | } | |
| 774 | 779 | ||
| 775 | 780 | #[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] | |
| 776 | 794 | fn the_month_before_wraps_the_year() { | |
| 777 | 795 | assert_eq!(previous_month("2026-10"), "2026-09"); | |
| 778 | 796 | assert_eq!(previous_month("2026-01"), "2025-12"); |
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
This change is too large to show in full.