Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Project settings say who created the project, by name | 1 | //! The billing service: what g1t's compute costs, charged to the workspace |
| 2 | //! it ran for at what g1t pays plus 20%. | |
| 3 | //! | |
| 4 | //! One paid plan, "g1t" (see `features`): $20 a month per workspace, never | |
| 5 | //! per person, with $10 of usage included, deployments, and more private | |
| 6 | //! storage. The forge is free for everyone; compute needs the plan or a | |
| 7 | //! card check (see `compute` and `cards`). Before anything that costs money | |
| 8 | //! starts, the service that starts it reserves its estimate here; when it | |
| 9 | //! is done, what it cost goes on the ledger plus the margin, drawn first | |
| 10 | //! from what the plan includes, a trial or g1t's pools (see `credits`). | |
| 11 | //! Every change is a ledger entry, and a balance is always the sum of its | |
| 12 | //! ledger. | |
| 13 | //! | |
| 14 | //! Without a card processor configured the service says so and charges | |
| 15 | //! nothing, so that g1t still runs where billing has not been set up. | |
| 16 | //! | |
| 17 | //! Reached only through service bindings; see `g1t_contracts::billing` for | |
| 18 | //! the methods and their arguments. | |
| 19 | ||
| 20 | mod accounts; | |
| 21 | mod budget; | |
| Spend adds up on one ledger. Charged this month has one definition, price less discount, included usage and credit, shared by the plan card, the spend limit, Spend and the top bar; the limit had counted usage not yet closed at full price before the discount, so a comped workspace read as charged a cent. Every agent line on the ledger names the agent and who asked, repository runs by g1t included, so Spent is the sum of its products, Agents is the agent product, and by agent adds up to it; the billing API returns by_agent and by_person. The usage and billing guide says how spend is counted. | 22 | mod charged; |
| Project settings say who created the project, by name | 23 | mod cards; |
| 24 | mod closing; | |
| 25 | mod compute; | |
| 26 | mod costs; | |
| Merge Cloudflare's usage over its billing cycle: every page read, included amounts once a cycle, a projection, test-mode charges never money in (billing 0052) | 27 | mod cycle; |
| Project settings say who created the project, by name | 28 | mod margin; |
| Merge platform pause and the hourly usage watcher: staff can pause compute, schedules, indexing or renders for everyone, the watcher emails on a breach and is never blind quietly, and the models proxy holds each run to its cap (billing 0051, integrations 0006) | 29 | mod platform; |
| Project settings say who created the project, by name | 30 | mod pricing; |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 31 | mod report; |
| 32 | mod details; | |
| Project settings say who created the project, by name | 33 | mod credits; |
| Billing: credits with a kind and expiry, discounts instead of comped, and safer charging | 34 | mod grants; |
| Project settings say who created the project, by name | 35 | mod overages; |
| 36 | mod requests; | |
| 37 | mod storage; | |
| 38 | mod invoices; | |
| 39 | mod sales; | |
| 40 | mod statement; | |
| 41 | mod webhooks; | |
| 42 | mod features; | |
| 43 | mod keeper; | |
| 44 | mod limits; | |
| 45 | mod rename; | |
| sudo: reset a test workspace's billing so it starts again as a new customer; refused on a live Stripe key, for comped workspaces and for an enterprise's | 46 | mod reset; |
| Project settings say who created the project, by name | 47 | mod retention; |
| 48 | mod stripe; | |
| 49 | mod stripe_sync; | |
| Costs: Cloudflare's subscriptions read from Cloudflare each day, the estimate only until then; sudo's costs split into Costs & margin and Bill & pricing | 50 | mod subscriptions; |
| Merge Stripe Tax, the card fee on card payments, and one free workspace per person | 51 | mod tax; |
| Project settings say who created the project, by name | 52 | |
| 53 | use g1t_contracts::billing::*; | |
| 54 | use g1t_contracts::time::rfc3339; | |
| Merge main (membership, two-factor, GitHub repo roles) into tokens | 55 | use g1t_contracts::{FailureCode, Outcome, new_id}; |
| Project settings say who created the project, by name | 56 | use g1t_kit::{args, now_ms, reply, rpc_method}; |
| 57 | use serde::Deserialize; | |
| 58 | use worker::wasm_bindgen::JsValue; | |
| 59 | use worker::{Context, D1Database, Env, MessageBatch, MessageExt, Request, Response, Result, ScheduleContext, ScheduledEvent, event}; | |
| 60 | use futures_util::future::{try_join, try_join5}; | |
| 61 | ||
| 62 | use stripe::Stripe; | |
| 63 | ||
| 64 | /// Prepaying: $25 at the least; by card up to $10,000 at a time, and by | |
| 65 | /// bank transfer from $1,000 to $100,000. | |
| 66 | const MIN_TOP_UP_CENTS: u32 = 2_500; | |
| 67 | const MAX_TOP_UP_CENTS: u32 = 1_000_000; | |
| 68 | const MIN_BANK_TRANSFER_CENTS: u32 = 100_000; | |
| 69 | const MAX_BANK_TRANSFER_CENTS: u32 = 10_000_000; | |
| 70 | const LEDGER_PAGE: u32 = 100; | |
| Billing, integrations, security and identity no longer serve g1t's own agents. Billing stops metering agent runs, model tokens and AI Gateway requests, drops its model catalogue and price-keeper model prices, and stops selling AI credit, which only ever paid for those; credit bought earlier still pays toward what is owed. A usage report, statement or invoice that holds an old agent or model meter shows it as "Other usage" rather than failing, and the get_ai_credit and buy_ai_credit API operations and MCP billing actions go. Integrations no longer connects model providers or routes models, and no longer assigns imported issues or alerts to g1t; a model provider already connected can still be disconnected. Security drops "Fix with g1t". Identity no longer creates agent tokens or puts agents on teams, a team's lead is a person, and run credentials are issued only for checks, the merge queue, merge checks, deployments and bumps; tokens and credentials already issued keep working until they expire. The reserved names g1t and g1t-agent stay reserved. No tables are dropped. | 71 | /// What a cost g1t trusts (the price book's) is charged at: plus the |
| 72 | /// margin, rounded up to a whole millionth. | |
| Merge branch 'worktree-agent-a633ac0f7f66d419d' | 73 | pub fn margin_on(cost_micros: i64, margin_percent: u32) -> i64 { |
| 74 | let cost = i128::from(cost_micros.max(0)); | |
| 75 | let charge = (cost * i128::from(100 + margin_percent) + 99) / 100; | |
| 76 | i64::try_from(charge).unwrap_or(i64::MAX) | |
| Project settings say who created the project, by name | 77 | } |
| 78 | ||
| 79 | fn optional(value: Option<&str>) -> JsValue { | |
| 80 | value.map_or(JsValue::NULL, JsValue::from) | |
| 81 | } | |
| 82 | ||
| 83 | #[derive(Deserialize)] | |
| 84 | struct AccountRow { | |
| 85 | balance_micros: i64, | |
| 86 | customer_id: Option<String>, | |
| 87 | } | |
| 88 | ||
| 89 | #[derive(Deserialize)] | |
| 90 | struct LedgerRow { | |
| 91 | id: String, | |
| 92 | kind: EntryKind, | |
| 93 | amount_micros: i64, | |
| 94 | description: String, | |
| 95 | repo: Option<String>, | |
| 96 | number: Option<u32>, | |
| 97 | task: Option<String>, | |
| 98 | model: Option<String>, | |
| 99 | created_by: Option<String>, | |
| 100 | created_at: String, | |
| 101 | billed_to: Option<String>, | |
| 102 | #[serde(default)] | |
| 103 | workspace: Option<String>, | |
| 104 | #[serde(default)] | |
| 105 | credit_micros: Option<i64>, | |
| 106 | #[serde(default)] | |
| 107 | trial_micros: Option<i64>, | |
| 108 | #[serde(default)] | |
| 109 | oss_micros: Option<i64>, | |
| 110 | #[serde(default)] | |
| 111 | given_micros: Option<i64>, | |
| Billing: credits with a kind and expiry, discounts instead of comped, and safer charging | 112 | #[serde(default)] |
| 113 | credit_kind: Option<String>, | |
| 114 | #[serde(default)] | |
| 115 | discount_micros: Option<i64>, | |
| Project settings say who created the project, by name | 116 | } |
| 117 | ||
| 118 | impl From<LedgerRow> for LedgerEntry { | |
| 119 | fn from(row: LedgerRow) -> Self { | |
| 120 | LedgerEntry { | |
| 121 | id: row.id, | |
| 122 | kind: row.kind, | |
| 123 | amount_micros: row.amount_micros, | |
| 124 | description: row.description, | |
| 125 | repo: row.repo, | |
| 126 | number: row.number, | |
| 127 | task: row.task, | |
| 128 | model: row.model, | |
| 129 | billed_to: row.billed_to.unwrap_or_else(|| "g1t".to_owned()), | |
| 130 | created_by: row.created_by, | |
| 131 | created_at: row.created_at, | |
| 132 | workspace: row.workspace, | |
| 133 | credit_micros: row.credit_micros.unwrap_or(0), | |
| 134 | trial_micros: row.trial_micros.unwrap_or(0), | |
| 135 | oss_micros: row.oss_micros.unwrap_or(0), | |
| 136 | given_micros: row.given_micros.unwrap_or(0), | |
| Billing: credits with a kind and expiry, discounts instead of comped, and safer charging | 137 | credit_kind: row.credit_kind.as_deref().and_then(CreditKind::parse), |
| 138 | discount_micros: row.discount_micros.unwrap_or(0), | |
| Project settings say who created the project, by name | 139 | } |
| 140 | } | |
| 141 | } | |
| 142 | ||
| 143 | #[derive(Deserialize)] | |
| 144 | struct CheckoutRow { | |
| 145 | workspace: String, | |
| 146 | created_by: String, | |
| Merge Stripe Tax, the card fee on card payments, and one free workspace per person | 147 | #[serde(default)] |
| 148 | fee_cents: Option<u32>, | |
| Project settings say who created the project, by name | 149 | } |
| 150 | ||
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 151 | /// A payment page started, as `checkouts` keeps it. |
| 152 | pub(crate) struct NewCheckout<'a> { | |
| 153 | pub id: &'a str, | |
| 154 | pub workspace: &'a str, | |
| Billing, integrations, security and identity no longer serve g1t's own agents. Billing stops metering agent runs, model tokens and AI Gateway requests, drops its model catalogue and price-keeper model prices, and stops selling AI credit, which only ever paid for those; credit bought earlier still pays toward what is owed. A usage report, statement or invoice that holds an old agent or model meter shows it as "Other usage" rather than failing, and the get_ai_credit and buy_ai_credit API operations and MCP billing actions go. Integrations no longer connects model providers or routes models, and no longer assigns imported issues or alerts to g1t; a model provider already connected can still be disconnected. Security drops "Fix with g1t". Identity no longer creates agent tokens or puts agents on teams, a team's lead is a person, and run credentials are issued only for checks, the merge queue, merge checks, deployments and bumps; tokens and credentials already issued keep working until they expire. The reserved names g1t and g1t-agent stay reserved. No tables are dropped. | 155 | /// What it pays for, in cents: the prepayment or the plan's price; 0 |
| 156 | /// for a card check. | |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 157 | pub amount_cents: u32, |
| Billing, integrations, security and identity no longer serve g1t's own agents. Billing stops metering agent runs, model tokens and AI Gateway requests, drops its model catalogue and price-keeper model prices, and stops selling AI credit, which only ever paid for those; credit bought earlier still pays toward what is owed. A usage report, statement or invoice that holds an old agent or model meter shows it as "Other usage" rather than failing, and the get_ai_credit and buy_ai_credit API operations and MCP billing actions go. Integrations no longer connects model providers or routes models, and no longer assigns imported issues or alerts to g1t; a model provider already connected can still be disconnected. Security drops "Fix with g1t". Identity no longer creates agent tokens or puts agents on teams, a team's lead is a person, and run credentials are issued only for checks, the merge queue, merge checks, deployments and bumps; tokens and credentials already issued keep working until they expire. The reserved names g1t and g1t-agent stay reserved. No tables are dropped. | 158 | /// The card fee on top. |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 159 | pub fee_cents: u32, |
| 160 | pub created_by: &'a str, | |
| Billing, integrations, security and identity no longer serve g1t's own agents. Billing stops metering agent runs, model tokens and AI Gateway requests, drops its model catalogue and price-keeper model prices, and stops selling AI credit, which only ever paid for those; credit bought earlier still pays toward what is owed. A usage report, statement or invoice that holds an old agent or model meter shows it as "Other usage" rather than failing, and the get_ai_credit and buy_ai_credit API operations and MCP billing actions go. Integrations no longer connects model providers or routes models, and no longer assigns imported issues or alerts to g1t; a model provider already connected can still be disconnected. Security drops "Fix with g1t". Identity no longer creates agent tokens or puts agents on teams, a team's lead is a person, and run credentials are issued only for checks, the merge queue, merge checks, deployments and bumps; tokens and credentials already issued keep working until they expire. The reserved names g1t and g1t-agent stay reserved. No tables are dropped. | 161 | /// None for a prepayment; `plan`, `security` or `card_check` otherwise |
| 162 | /// (`ai_credit` on pages from before AI credit was retired). | |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 163 | pub feature: Option<&'a str>, |
| 164 | } | |
| 165 | ||
| 166 | /// The one insert every payment page goes through, every column named, so | |
| 167 | /// a column added to `checkouts` with no default is caught by | |
| 168 | /// `tests::every_checkout_insert_fills_the_table` rather than by a 500. | |
| 169 | pub(crate) const CHECKOUT_INSERT: &str = "INSERT INTO checkouts (id, workspace, amount_cents, fee_cents, created_by, created_at, feature, status) | |
| 170 | VALUES (?, ?, ?, ?, ?, ?, ?, 'open')"; | |
| 171 | ||
| 172 | impl Billing { | |
| 173 | pub(crate) async fn record_checkout(&self, c: &NewCheckout<'_>) -> Result<()> { | |
| 174 | self.db | |
| 175 | .prepare(CHECKOUT_INSERT) | |
| 176 | .bind(&[ | |
| 177 | c.id.into(), | |
| 178 | c.workspace.into(), | |
| 179 | c.amount_cents.into(), | |
| 180 | c.fee_cents.into(), | |
| 181 | c.created_by.into(), | |
| 182 | rfc3339(now_ms()).into(), | |
| 183 | optional(c.feature), | |
| 184 | ])? | |
| 185 | .run() | |
| 186 | .await?; | |
| 187 | Ok(()) | |
| 188 | } | |
| 189 | } | |
| 190 | ||
| Project settings say who created the project, by name | 191 | /// A row an `UPDATE … RETURNING` touched. |
| 192 | #[derive(Deserialize)] | |
| 193 | struct Touched { | |
| 194 | #[allow(dead_code)] | |
| 195 | id: String, | |
| 196 | } | |
| 197 | ||
| 198 | struct Billing { | |
| 199 | db: D1Database, | |
| 200 | /// Absent when no card processor is configured. | |
| 201 | stripe: Option<Stripe>, | |
| 202 | /// The destination's signing secret from Stripe (`STRIPE_WEBHOOK_SECRET`); | |
| 203 | /// without it no event is believed. | |
| 204 | webhook_secret: Option<String>, | |
| 205 | margin_percent: u32, | |
| 206 | /// While g1t is being built out, nothing is charged (`FREE_WHILE_BUILDING`). | |
| 207 | free: bool, | |
| 208 | /// Whether new workspaces get trial credit (`TRIAL_WORKSPACE_MICROS` | |
| 209 | /// and `TRIAL_MONTHLY_POOL_MICROS` both above zero). | |
| 210 | trials_on: bool, | |
| 211 | /// The plans' and pools' numbers; see `credits`. | |
| 212 | plans: credits::Config, | |
| 213 | /// The repos service: which repositories are public, what private ones | |
| 214 | /// hold, and their git operations. Absent where it is not bound. | |
| 215 | repos: Option<worker::Fetcher>, | |
| 216 | /// The packages service: what each workspace's packages hold. | |
| 217 | packages: Option<worker::Fetcher>, | |
| 218 | /// The identity service, which emails owners. Absent where it is not | |
| 219 | /// bound. | |
| 220 | identity: Option<worker::Fetcher>, | |
| 221 | /// How far unpaid usage may go; see `limits`. | |
| 222 | ceilings: limits::Ceilings, | |
| 223 | /// The caps on what g1t pays for itself; see `budget`. | |
| 224 | caps: budget::Caps, | |
| 225 | /// The worker's bindings, for emailing staff (`EMAIL`). | |
| 226 | env: Env, | |
| 227 | } | |
| 228 | ||
| 229 | impl Billing { | |
| 230 | fn status(&self) -> Status { | |
| 231 | Status { | |
| 232 | enabled: self.stripe.is_some(), | |
| 233 | live: self.stripe.as_ref().is_some_and(Stripe::live), | |
| 234 | free: self.free, | |
| 235 | } | |
| 236 | } | |
| 237 | ||
| 238 | async fn row(&self, workspace: &str) -> Result<Option<AccountRow>> { | |
| 239 | self.db | |
| 240 | .prepare("SELECT balance_micros, customer_id FROM accounts WHERE workspace = ?") | |
| 241 | .bind(&[workspace.into()])? | |
| 242 | .first::<AccountRow>(None) | |
| 243 | .await | |
| 244 | } | |
| 245 | ||
| 246 | async fn standing(&self, workspace: &str) -> Result<Account> { | |
| 247 | // The card as last synced (stripe_sync.rs), not asked of Stripe. | |
| 248 | let (row, card) = try_join(self.row(workspace), self.saved_card(workspace)).await?; | |
| 249 | Ok(Account { | |
| 250 | workspace: workspace.to_owned(), | |
| 251 | balance_micros: row.map_or(0, |row| row.balance_micros), | |
| 252 | status: self.status(), | |
| 253 | margin_percent: self.margin_percent, | |
| 254 | card, | |
| 255 | }) | |
| 256 | } | |
| 257 | ||
| 258 | /// The workspace's customer at Stripe, made the first time one is needed. | |
| 259 | pub(crate) async fn customer_for(&self, workspace: &str) -> Result<String> { | |
| 260 | let Some(stripe) = &self.stripe else { | |
| 261 | return Err(worker::Error::RustError("payments are not set up".into())); | |
| 262 | }; | |
| 263 | if let Some(customer) = self.row(workspace).await?.and_then(|row| row.customer_id) { | |
| 264 | return Ok(customer); | |
| 265 | } | |
| 266 | let customer = stripe.create_customer(workspace).await?; | |
| 267 | self.db | |
| 268 | .prepare( | |
| 269 | "INSERT INTO accounts (workspace, balance_micros, customer_id, created_at) VALUES (?1, 0, ?2, ?3) | |
| 270 | ON CONFLICT (workspace) DO UPDATE SET customer_id = ?2", | |
| 271 | ) | |
| 272 | .bind(&[workspace.into(), customer.as_str().into(), rfc3339(now_ms()).into()])? | |
| 273 | .run() | |
| 274 | .await?; | |
| 275 | Ok(customer) | |
| 276 | } | |
| 277 | ||
| 278 | /// Stripe's hosted billing page for the workspace. Owners only. | |
| 279 | async fn billing_portal(&self, a: BillingPortalArgs) -> Result<Outcome<Checkout>> { | |
| 280 | let workspace = a.workspace.to_lowercase(); | |
| Merge main (membership, two-factor, GitHub repo roles) into tokens | 281 | if !a.actor.manages_billing(&workspace) { |
| 282 | return Ok(Outcome::fail(FailureCode::Forbidden, "Only an owner or a billing manager can manage the workspace's billing.")); | |
| Project settings say who created the project, by name | 283 | } |
| 284 | let Some(stripe) = &self.stripe else { | |
| 285 | return Ok(Outcome::fail(FailureCode::Conflict, "Payments are not set up on this g1t.")); | |
| 286 | }; | |
| 287 | let customer = match self.customer_for(&workspace).await { | |
| 288 | Ok(customer) => customer, | |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 289 | Err(error) => return Ok(Outcome::fail(FailureCode::Conflict, crate::stripe::friendly(&error))), |
| Project settings say who created the project, by name | 290 | }; |
| 291 | match stripe.portal_session(&customer, &a.return_url).await { | |
| 292 | Ok(url) => Ok(Outcome::Ok(Checkout { url })), | |
| 293 | Err(error) if stripe::is_missing(&error) => { | |
| 294 | // The customer was removed at Stripe: a new one next time. | |
| 295 | self.forget_customer(&workspace).await?; | |
| 296 | Ok(Outcome::fail(FailureCode::Conflict, "Stripe no longer had this workspace's customer. Try again.")) | |
| 297 | } | |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 298 | Err(error) => Ok(Outcome::fail(FailureCode::Conflict, crate::stripe::friendly(&error))), |
| Project settings say who created the project, by name | 299 | } |
| 300 | } | |
| 301 | ||
| 302 | /// Adds a ledger entry and moves the balance by the same amount, as | |
| 303 | /// one write. | |
| 304 | #[allow(clippy::too_many_arguments)] | |
| 305 | async fn enter( | |
| 306 | &self, | |
| 307 | workspace: &str, | |
| 308 | kind: EntryKind, | |
| 309 | amount_micros: i64, | |
| 310 | description: &str, | |
| 311 | reference: &str, | |
| 312 | cost_micros: Option<i64>, | |
| 313 | created_by: Option<&str>, | |
| 314 | customer: Option<&str>, | |
| 315 | ) -> Result<()> { | |
| 316 | let now = now_ms(); | |
| 317 | let timestamp = rfc3339(now); | |
| 318 | let kind = match kind { | |
| 319 | EntryKind::TopUp => "top_up", | |
| 320 | EntryKind::Usage => "usage", | |
| 321 | }; | |
| 322 | self.db | |
| 323 | .batch(vec![ | |
| 324 | self.db | |
| 325 | .prepare( | |
| 326 | "INSERT INTO ledger | |
| Billing, integrations, security and identity no longer serve g1t's own agents. Billing stops metering agent runs, model tokens and AI Gateway requests, drops its model catalogue and price-keeper model prices, and stops selling AI credit, which only ever paid for those; credit bought earlier still pays toward what is owed. A usage report, statement or invoice that holds an old agent or model meter shows it as "Other usage" rather than failing, and the get_ai_credit and buy_ai_credit API operations and MCP billing actions go. Integrations no longer connects model providers or routes models, and no longer assigns imported issues or alerts to g1t; a model provider already connected can still be disconnected. Security drops "Fix with g1t". Identity no longer creates agent tokens or puts agents on teams, a team's lead is a person, and run credentials are issued only for checks, the merge queue, merge checks, deployments and bumps; tokens and credentials already issued keep working until they expire. The reserved names g1t and g1t-agent stay reserved. No tables are dropped. | 327 | (id, workspace, kind, amount_micros, description, cost_micros, reference, created_by, created_at, billed_to) |
| 328 | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'g1t')", | |
| Project settings say who created the project, by name | 329 | ) |
| 330 | .bind(&[ | |
| 331 | new_id("led", now).into(), | |
| 332 | workspace.into(), | |
| 333 | kind.into(), | |
| 334 | // D1 takes numbers as doubles, which hold every | |
| 335 | // amount this service will see exactly. | |
| 336 | (amount_micros as f64).into(), | |
| 337 | description.into(), | |
| 338 | cost_micros.map_or(JsValue::NULL, |cost| (cost as f64).into()), | |
| 339 | reference.into(), | |
| 340 | optional(created_by), | |
| 341 | timestamp.as_str().into(), | |
| 342 | ])?, | |
| 343 | self.db | |
| 344 | .prepare( | |
| 345 | "INSERT INTO accounts (workspace, balance_micros, customer_id, created_at) | |
| 346 | VALUES (?1, ?2, ?3, ?4) | |
| 347 | ON CONFLICT (workspace) DO UPDATE SET | |
| 348 | balance_micros = balance_micros + ?2, | |
| 349 | customer_id = COALESCE(?3, customer_id)", | |
| 350 | ) | |
| 351 | .bind(&[ | |
| 352 | workspace.into(), | |
| 353 | (amount_micros as f64).into(), | |
| 354 | optional(customer), | |
| 355 | timestamp.as_str().into(), | |
| 356 | ])?, | |
| 357 | ]) | |
| 358 | .await?; | |
| Billing: credits with a kind and expiry, discounts instead of comped, and safer charging | 359 | // Money in clears a card declined at the limit. A refund or a lost |
| 360 | // dispute is a top-up of less than nothing: money out, which never | |
| 361 | // clears it. | |
| 362 | if kind == "top_up" && amount_micros > 0 { | |
| Project settings say who created the project, by name | 363 | self.db |
| 364 | .prepare("UPDATE limits SET autopay_failed_at = NULL, autopay_error = NULL WHERE workspace = ?") | |
| 365 | .bind(&[workspace.into()])? | |
| 366 | .run() | |
| 367 | .await?; | |
| 368 | } | |
| 369 | Ok(()) | |
| 370 | } | |
| 371 | ||
| 372 | async fn account(&self, a: AccountArgs) -> Result<Outcome<Account>> { | |
| 373 | let workspace = a.workspace.to_lowercase(); | |
| 374 | if !a.viewer.is_some_and(|viewer| viewer.is_member(&workspace)) { | |
| 375 | return Ok(members_only()); | |
| 376 | } | |
| 377 | Ok(Outcome::Ok(self.standing(&workspace).await?)) | |
| 378 | } | |
| 379 | ||
| 380 | async fn ledger(&self, a: AccountArgs) -> Result<Outcome<Vec<LedgerEntry>>> { | |
| 381 | let workspace = a.workspace.to_lowercase(); | |
| 382 | if !a.viewer.is_some_and(|viewer| viewer.is_member(&workspace)) { | |
| 383 | return Ok(members_only()); | |
| 384 | } | |
| 385 | let rows = self | |
| 386 | .db | |
| 387 | .prepare("SELECT * FROM ledger WHERE workspace = ? ORDER BY id DESC LIMIT ?") | |
| 388 | .bind(&[workspace.into(), LEDGER_PAGE.into()])? | |
| 389 | .all() | |
| 390 | .await? | |
| 391 | .results::<LedgerRow>()?; | |
| 392 | Ok(Outcome::Ok( | |
| 393 | rows.into_iter().map(LedgerEntry::from).collect(), | |
| 394 | )) | |
| 395 | } | |
| 396 | ||
| 397 | async fn usage(&self, a: UsageArgs) -> Result<Outcome<Usage>> { | |
| 398 | let workspace = a.workspace.to_lowercase(); | |
| 399 | if !a.viewer.is_some_and(|viewer| viewer.is_member(&workspace)) { | |
| 400 | return Ok(members_only()); | |
| 401 | } | |
| 402 | #[derive(serde::Deserialize)] | |
| 403 | struct SliceRow { | |
| 404 | key: Option<String>, | |
| 405 | micros: Option<i64>, | |
| 406 | runs: Option<u32>, | |
| 407 | } | |
| Billing: credits with a kind and expiry, discounts instead of comped, and safer charging | 408 | // While g1t is free, what was used at cost is what there is to show. |
| 409 | // With a discount, usage at its price, so a 100% discount still | |
| 410 | // shows what the workspace would pay. | |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 411 | // Every slice measures usage at price (statement::PRICE_SQL), the one |
| 412 | // figure every page shows as usage, whatever paid for it. | |
| Billing: credits with a kind and expiry, discounts instead of comped, and safer charging | 413 | let discount_percent = self.terms_of(&workspace).await?.percent_off(); |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 414 | let measure = if self.free { "COALESCE(cost_micros, 0)" } else { crate::statement::PRICE_SQL }; |
| Project settings say who created the project, by name | 415 | let slices = |key: &str, limit: u32| { |
| 416 | format!( | |
| 417 | "SELECT {key} AS key, SUM({measure}) AS micros, COUNT(*) AS runs FROM ledger | |
| 418 | WHERE workspace = ?1 AND kind = 'usage' AND created_at >= ?2 | |
| 419 | GROUP BY 1 ORDER BY micros DESC LIMIT {limit}" | |
| 420 | ) | |
| 421 | }; | |
| 422 | let query = |sql: String| { | |
| 423 | let db = &self.db; | |
| 424 | let workspace = workspace.clone(); | |
| 425 | let since = a.since.clone(); | |
| 426 | async move { | |
| 427 | let rows = db | |
| 428 | .prepare(sql) | |
| 429 | .bind(&[workspace.into(), since.into()])? | |
| 430 | .all() | |
| 431 | .await? | |
| 432 | .results::<SliceRow>()?; | |
| 433 | Ok::<Vec<UsageSlice>, worker::Error>( | |
| 434 | rows.into_iter() | |
| 435 | .map(|row| UsageSlice { | |
| 436 | key: row.key.unwrap_or_else(|| "other".to_owned()), | |
| 437 | micros: row.micros.unwrap_or_default(), | |
| 438 | runs: row.runs.unwrap_or_default(), | |
| 439 | }) | |
| 440 | .collect(), | |
| 441 | ) | |
| 442 | } | |
| 443 | }; | |
| 444 | #[derive(serde::Deserialize)] | |
| 445 | struct Totals { | |
| 446 | spent: Option<i64>, | |
| 447 | cost: Option<i64>, | |
| 448 | provider: Option<i64>, | |
| 449 | runs: Option<u32>, | |
| 450 | added: Option<i64>, | |
| Billing: credits with a kind and expiry, discounts instead of comped, and safer charging | 451 | discount: Option<i64>, |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 452 | covered: Option<i64>, |
| 453 | price: Option<i64>, | |
| Project settings say who created the project, by name | 454 | } |
| 455 | let totals = async { | |
| 456 | self.db | |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 457 | .prepare(format!( |
| Project settings say who created the project, by name | 458 | "SELECT |
| 459 | -SUM(CASE WHEN kind = 'usage' THEN amount_micros END) AS spent, | |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 460 | SUM(CASE WHEN kind = 'usage' THEN COALESCE(credit_micros, 0) + COALESCE(trial_micros, 0) |
| 461 | + COALESCE(oss_micros, 0) + COALESCE(given_micros, 0) END) AS covered, | |
| 462 | SUM(CASE WHEN kind = 'usage' THEN {price} END) AS price,", | |
| 463 | price = crate::statement::PRICE_SQL | |
| 464 | ) + " | |
| Project settings say who created the project, by name | 465 | SUM(CASE WHEN kind = 'usage' AND COALESCE(billed_to, 'g1t') = 'g1t' THEN cost_micros END) AS cost, |
| 466 | SUM(CASE WHEN kind = 'usage' AND billed_to = 'workspace' THEN cost_micros END) AS provider, | |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 467 | SUM(CASE WHEN kind = 'usage' AND " + crate::statement::RUN_SQL + " THEN 1 ELSE 0 END) AS runs, |
| Billing: credits with a kind and expiry, discounts instead of comped, and safer charging | 468 | SUM(CASE WHEN kind = 'top_up' THEN amount_micros END) AS added, |
| 469 | SUM(CASE WHEN kind = 'usage' THEN discount_micros END) AS discount | |
| Project settings say who created the project, by name | 470 | FROM ledger WHERE workspace = ?1 AND created_at >= ?2", |
| 471 | ) | |
| 472 | .bind(&[workspace.as_str().into(), a.since.as_str().into()])? | |
| 473 | .first::<Totals>(None) | |
| 474 | .await | |
| 475 | }; | |
| 476 | // The totals and the five slices read the same rows independently, | |
| 477 | // so they go to D1 at once: one round trip of waiting, not six. | |
| 478 | let (totals, (by_day, by_task, by_repo, by_pull, by_model)) = try_join( | |
| 479 | totals, | |
| 480 | try_join5( | |
| 481 | query(slices("substr(created_at, 1, 10) || '/' || COALESCE(task, 'other')", 400)), | |
| 482 | query(slices("task", 20)), | |
| 483 | query(slices("repo", 20)), | |
| 484 | query(slices("repo || '#' || number", 10)), | |
| 485 | query(slices("model", 10)), | |
| 486 | ), | |
| 487 | ) | |
| 488 | .await?; | |
| 489 | let totals = totals.unwrap_or(Totals { | |
| 490 | spent: None, | |
| 491 | cost: None, | |
| 492 | provider: None, | |
| 493 | runs: None, | |
| 494 | added: None, | |
| Billing: credits with a kind and expiry, discounts instead of comped, and safer charging | 495 | discount: None, |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 496 | covered: None, |
| 497 | price: None, | |
| Project settings say who created the project, by name | 498 | }); |
| 499 | Ok(Outcome::Ok(Usage { | |
| 500 | spent_micros: totals.spent.unwrap_or_default(), | |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 501 | // What the included usage, the trial, a pool or g1t paid, as the |
| 502 | // entries record it. | |
| 503 | covered_micros: totals.covered.unwrap_or_default(), | |
| 504 | price_micros: if self.free { totals.cost.unwrap_or_default() } else { totals.price.unwrap_or_default() }, | |
| Project settings say who created the project, by name | 505 | cost_micros: totals.cost.unwrap_or_default(), |
| 506 | provider_micros: totals.provider.unwrap_or_default(), | |
| 507 | used_micros: totals.cost.unwrap_or_default() + totals.provider.unwrap_or_default(), | |
| 508 | free: self.free, | |
| Billing: credits with a kind and expiry, discounts instead of comped, and safer charging | 509 | discount_micros: totals.discount.unwrap_or_default(), |
| 510 | discount_percent: (discount_percent > 0).then_some(discount_percent), | |
| Project settings say who created the project, by name | 511 | runs: totals.runs.unwrap_or_default(), |
| 512 | added_micros: totals.added.unwrap_or_default(), | |
| 513 | by_day, | |
| 514 | by_task, | |
| 515 | by_repo, | |
| 516 | by_pull, | |
| 517 | by_model, | |
| 518 | since: a.since, | |
| 519 | })) | |
| 520 | } | |
| 521 | ||
| 522 | /// `checkout`: prepays usage, by card or (from $1,000) bank transfer. | |
| 523 | async fn checkout(&self, a: CheckoutArgs) -> Result<Outcome<Checkout>> { | |
| 524 | let workspace = a.workspace.to_lowercase(); | |
| Merge main (membership, two-factor, GitHub repo roles) into tokens | 525 | if !a.actor.manages_billing(&workspace) { |
| Project settings say who created the project, by name | 526 | return Ok(Outcome::fail( |
| 527 | FailureCode::Forbidden, | |
| Merge main (membership, two-factor, GitHub repo roles) into tokens | 528 | "Only an owner or a billing manager can prepay for a workspace.", |
| Project settings say who created the project, by name | 529 | )); |
| 530 | } | |
| 531 | let Some(stripe) = &self.stripe else { | |
| 532 | return Ok(Outcome::fail( | |
| 533 | FailureCode::Conflict, | |
| 534 | "Payments are not set up on this g1t yet.", | |
| 535 | )); | |
| 536 | }; | |
| 537 | let bank_transfer = a.method.as_deref() == Some("bank_transfer"); | |
| 538 | if let Err(why) = prepay_amount(a.amount_cents, bank_transfer) { | |
| 539 | return Ok(Outcome::fail(FailureCode::Invalid, why)); | |
| 540 | } | |
| 541 | // A bank transfer needs a customer for its account details. | |
| 542 | let customer = if bank_transfer { | |
| 543 | match self.customer_for(&workspace).await { | |
| 544 | Ok(customer) => Some(customer), | |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 545 | Err(error) => return Ok(Outcome::fail(FailureCode::Conflict, stripe::friendly(&error))), |
| Project settings say who created the project, by name | 546 | } |
| 547 | } else { | |
| 548 | self.row(&workspace).await?.and_then(|row| row.customer_id) | |
| 549 | }; | |
| Merge Stripe Tax, the card fee on card payments, and one free workspace per person | 550 | // By card, Stripe's fee is its own line; a bank transfer has none. |
| 551 | let fee = if bank_transfer { 0 } else { self.card_fee_on(i64::from(a.amount_cents)).await? as u32 }; | |
| 552 | let started = match stripe.start_checkout(&workspace, a.amount_cents, fee, customer.as_deref(), &a.return_url, bank_transfer).await { | |
| Project settings say who created the project, by name | 553 | // A customer saved under another Stripe account: start afresh. |
| 554 | Err(error) if customer.is_some() && stripe::is_missing(&error) => { | |
| 555 | self.forget_customer(&workspace).await?; | |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 556 | let customer = if bank_transfer { self.customer_for(&workspace).await.ok() } else { None }; |
| Merge Stripe Tax, the card fee on card payments, and one free workspace per person | 557 | stripe.start_checkout(&workspace, a.amount_cents, fee, customer.as_deref(), &a.return_url, bank_transfer).await |
| Project settings say who created the project, by name | 558 | } |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 559 | other => other, |
| Project settings say who created the project, by name | 560 | }; |
| Merge Stripe Tax, the card fee on card payments, and one free workspace per person | 561 | self.page_opened(started, &workspace, a.amount_cents, fee, &a.actor.username, None).await |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 562 | } |
| 563 | ||
| 564 | /// A payment page Stripe started (or refused), recorded in `checkouts` | |
| 565 | /// and handed back; any failure as a sentence for the page, never a | |
| 566 | /// raw error. | |
| 567 | pub(crate) async fn page_opened( | |
| 568 | &self, | |
| 569 | started: Result<stripe::Session>, | |
| 570 | workspace: &str, | |
| 571 | amount_cents: u32, | |
| 572 | fee_cents: u32, | |
| 573 | created_by: &str, | |
| 574 | feature: Option<&str>, | |
| 575 | ) -> Result<Outcome<Checkout>> { | |
| 576 | let session = match started { | |
| 577 | Ok(session) => session, | |
| 578 | Err(error) => { | |
| 579 | worker::console_error!("{workspace}: Stripe refused a payment page ({}): {error}", feature.unwrap_or("prepay")); | |
| 580 | return Ok(Outcome::fail(FailureCode::Conflict, stripe::friendly(&error))); | |
| 581 | } | |
| Project settings say who created the project, by name | 582 | }; |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 583 | let Some(url) = session.url.clone() else { |
| 584 | return Ok(Outcome::fail(FailureCode::Conflict, "Stripe returned no payment page. Try again in a minute.")); | |
| 585 | }; | |
| 586 | let recorded = self | |
| 587 | .record_checkout(&NewCheckout { id: &session.id, workspace, amount_cents, fee_cents, created_by, feature }) | |
| 588 | .await; | |
| 589 | if let Err(error) = recorded { | |
| 590 | worker::console_error!("{workspace}: a payment page could not be recorded: {error}"); | |
| 591 | return Ok(Outcome::fail(FailureCode::Conflict, "g1t could not keep track of the payment page. Nothing was charged; try again.")); | |
| 592 | } | |
| Project settings say who created the project, by name | 593 | Ok(Outcome::Ok(Checkout { url })) |
| 594 | } | |
| 595 | ||
| 596 | /// Credits a payment if the processor says it was made and it has not | |
| 597 | /// been credited before. The amount credited is what the processor | |
| 598 | /// says was paid, not what anyone here remembers asking for. | |
| 599 | async fn confirm(&self, a: ConfirmArgs) -> Result<Outcome<Account>> { | |
| 600 | let workspace = a.workspace.to_lowercase(); | |
| 601 | if !a.viewer.is_some_and(|viewer| viewer.is_member(&workspace)) { | |
| 602 | return Ok(members_only()); | |
| 603 | } | |
| 604 | let (Some(stripe), Some(checkout)) = ( | |
| 605 | &self.stripe, | |
| 606 | self.db | |
| 607 | .prepare( | |
| Billing: credits with a kind and expiry, discounts instead of comped, and safer charging | 608 | // A prepayment only: a plan's or a card check's page is |
| 609 | // settled where it was started, never credited as money | |
| 610 | // paid in advance. | |
| Merge Stripe Tax, the card fee on card payments, and one free workspace per person | 611 | "SELECT workspace, created_by, fee_cents FROM checkouts |
| Billing: credits with a kind and expiry, discounts instead of comped, and safer charging | 612 | WHERE id = ? AND workspace = ? AND status = 'open' AND feature IS NULL", |
| Project settings say who created the project, by name | 613 | ) |
| 614 | .bind(&[a.session.as_str().into(), workspace.as_str().into()])? | |
| 615 | .first::<CheckoutRow>(None) | |
| 616 | .await?, | |
| 617 | ) else { | |
| 618 | // Unknown, someone else's, or already credited: nothing to do. | |
| 619 | return Ok(Outcome::Ok(self.standing(&workspace).await?)); | |
| 620 | }; | |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 621 | let session = match stripe.session(&a.session).await { |
| 622 | Ok(session) => session, | |
| 623 | Err(error) => return Ok(Outcome::fail(FailureCode::Conflict, crate::stripe::friendly(&error))), | |
| 624 | }; | |
| Merge Stripe Tax, the card fee on card payments, and one free workspace per person | 625 | if session.payment_status == "paid" && session.amount_total.is_some() { |
| Project settings say who created the project, by name | 626 | // Only whoever flips it from open to paid enters the credit. |
| 627 | let claimed = self | |
| 628 | .db | |
| 629 | .prepare( | |
| 630 | "UPDATE checkouts SET status = 'paid' WHERE id = ? AND status = 'open' | |
| 631 | RETURNING id", | |
| 632 | ) | |
| 633 | .bind(&[a.session.as_str().into()])? | |
| 634 | .first::<Touched>(None) | |
| 635 | .await?; | |
| 636 | if claimed.is_some() { | |
| Merge Stripe Tax, the card fee on card payments, and one free workspace per person | 637 | self.credit_prepayment(&checkout.workspace, &session, checkout.fee_cents.unwrap_or(0), &checkout.created_by).await?; |
| Project settings say who created the project, by name | 638 | } |
| 639 | } | |
| 640 | Ok(Outcome::Ok(self.standing(&workspace).await?)) | |
| 641 | } | |
| 642 | ||
| Merge Stripe Tax, the card fee on card payments, and one free workspace per person | 643 | /// Enters a paid prepayment page: what its lines came to before tax, |
| 644 | /// less the card fee, as credit; its tax and fee kept apart. Once the | |
| 645 | /// page is claimed `open → paid`. The credit in cents. | |
| 646 | pub(crate) async fn credit_prepayment(&self, workspace: &str, session: &stripe::Session, fee_cents: u32, by: &str) -> Result<i64> { | |
| 647 | let fee = i64::from(fee_cents); | |
| 648 | let cents = tax::prepay_credit_cents(session.before_tax_cents(), fee); | |
| 649 | let tax_cents = session.tax_cents(); | |
| 650 | let description = if tax_cents > 0 || fee > 0 { | |
| 651 | format!( | |
| 652 | "Paid in advance (tax {} and card fee {} paid with it)", | |
| 653 | features::cents(tax_cents * 10_000), | |
| 654 | features::cents(fee * 10_000) | |
| 655 | ) | |
| 656 | } else { | |
| 657 | "Paid in advance".to_owned() | |
| 658 | }; | |
| 659 | self.enter( | |
| 660 | workspace, | |
| 661 | EntryKind::TopUp, | |
| 662 | cents * MICROS_PER_DOLLAR / 100, | |
| 663 | &description, | |
| 664 | &session.id, | |
| 665 | None, | |
| 666 | Some(by), | |
| 667 | session.customer.as_deref(), | |
| 668 | ) | |
| 669 | .await?; | |
| 670 | self.record_extras(workspace, &session.id, session.payment_intent.as_deref(), tax::Extras { tax_cents, fee_cents: fee }, None) | |
| 671 | .await?; | |
| 672 | Ok(cents) | |
| 673 | } | |
| 674 | ||
| Project settings say who created the project, by name | 675 | /// Drops a saved customer the card processor no longer knows. |
| 676 | pub(crate) async fn forget_customer(&self, workspace: &str) -> Result<()> { | |
| 677 | self.db | |
| 678 | .prepare( | |
| 679 | "UPDATE accounts SET customer_id = NULL, card_brand = NULL, card_last4 = NULL, card_exp_month = NULL, | |
| 680 | card_exp_year = NULL, card_synced_at = NULL WHERE workspace = ?", | |
| 681 | ) | |
| 682 | .bind(&[workspace.into()])? | |
| 683 | .run() | |
| 684 | .await?; | |
| 685 | Ok(()) | |
| 686 | } | |
| 687 | } | |
| 688 | ||
| 689 | impl Billing { | |
| 690 | /// Records how long a sandbox ran, with its cost and its charge: every | |
| 691 | /// second, from the first, at the price book's price; on its own CPU | |
| 692 | /// when it reports it. Settles its reservation, if it names one. | |
| 693 | async fn record_sandbox(&self, a: RecordSandboxArgs) -> Result<Outcome<bool>> { | |
| 694 | // Self-hosted time is recorded wherever g1t runs, for its minutes; | |
| 695 | // anything else only where there is a bill to put it on. | |
| 696 | if (self.stripe.is_none() && !a.self_hosted) || a.seconds == 0 { | |
| 697 | return Ok(Outcome::Ok(false)); | |
| 698 | } | |
| 699 | let workspace = a.workspace.to_lowercase(); | |
| 700 | let seen = self | |
| 701 | .db | |
| 702 | .prepare("SELECT id FROM ledger WHERE reference = ?") | |
| 703 | .bind(&[a.reference.as_str().into()])? | |
| 704 | .first::<Touched>(None) | |
| 705 | .await?; | |
| 706 | if seen.is_some() { | |
| 707 | return Ok(Outcome::Ok(false)); | |
| 708 | } | |
| 709 | let now = now_ms(); | |
| 710 | let timestamp = rfc3339(now); | |
| 711 | let seconds = i64::from(a.seconds); | |
| 712 | // On the workspace's own machine: its minutes go on usage, at $0, | |
| 713 | // and whatever was reserved for it is given back. | |
| 714 | if a.self_hosted { | |
| 715 | let description = format!("{}: {} of self-hosted runner time, $0", a.description, duration(seconds)); | |
| 716 | self.db | |
| 717 | .prepare( | |
| 718 | "INSERT INTO ledger | |
| 719 | (id, workspace, kind, amount_micros, description, repo, task, | |
| Billing, integrations, security and identity no longer serve g1t's own agents. Billing stops metering agent runs, model tokens and AI Gateway requests, drops its model catalogue and price-keeper model prices, and stops selling AI credit, which only ever paid for those; credit bought earlier still pays toward what is owed. A usage report, statement or invoice that holds an old agent or model meter shows it as "Other usage" rather than failing, and the get_ai_credit and buy_ai_credit API operations and MCP billing actions go. Integrations no longer connects model providers or routes models, and no longer assigns imported issues or alerts to g1t; a model provider already connected can still be disconnected. Security drops "Fix with g1t". Identity no longer creates agent tokens or puts agents on teams, a team's lead is a person, and run credentials are issued only for checks, the merge queue, merge checks, deployments and bumps; tokens and credentials already issued keep working until they expire. The reserved names g1t and g1t-agent stay reserved. No tables are dropped. | 720 | cost_micros, reference, created_at, billed_to, credit_micros, trial_micros, oss_micros, given_micros, quantity) |
| 721 | VALUES (?, ?, 'usage', 0, ?, ?, 'self_hosted', 0, ?, ?, 'workspace', 0, 0, 0, 0, ?)", | |
| Project settings say who created the project, by name | 722 | ) |
| 723 | .bind(&[ | |
| 724 | new_id("led", now).into(), | |
| 725 | workspace.as_str().into(), | |
| 726 | description.as_str().into(), | |
| 727 | optional(a.repo.as_deref()), | |
| 728 | a.reference.as_str().into(), | |
| 729 | timestamp.as_str().into(), | |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 730 | (seconds as f64).into(), |
| Project settings say who created the project, by name | 731 | ])? |
| 732 | .run() | |
| 733 | .await?; | |
| 734 | if let Some(reservation) = &a.reservation_id { | |
| 735 | self.settle_reservation(SettleArgs { reservation_id: reservation.clone(), actual_micros: 0 }).await?; | |
| 736 | } | |
| 737 | return Ok(Outcome::Ok(true)); | |
| 738 | } | |
| 739 | // From the price book, which follows what Cloudflare bills g1t. A | |
| 740 | // sandbox is the same container as a build, so without a row it is | |
| 741 | // a build second's cost plus the margin. | |
| 742 | let (cost_per_second, _) = self.price("sandbox_second").await?.unwrap_or_else(|| { | |
| 743 | let cost = deployment_costs::MICROS_PER_BUILD_SECOND as f64; | |
| 744 | (cost, Price::price_for(cost, self.margin_percent)) | |
| 745 | }); | |
| 746 | // A larger machine (`runs-on: g1t-4core`): its memory and disk | |
| 747 | // cost more each second, and without its own CPU it is priced at | |
| 748 | // its vCPUs as busy as the standard machine's. | |
| 749 | let instance = a.instance.as_deref().and_then(g1t_contracts::actions::instance_named); | |
| 750 | let base_scale = instance.map_or(1.0, |i| keeper::base_scale(i.memory_gib, i.disk_gb)); | |
| 751 | let price_scale = instance.map_or(1.0, |i| i.price_scale); | |
| 752 | let vcpus = instance.map_or(4.0, |i| i.vcpu.max(4.0)); | |
| 753 | // Its own CPU when the sandbox reports it; otherwise the average. | |
| 754 | let parts = match a.cpu_seconds.filter(|cpu| cpu.is_finite() && *cpu >= 0.0) { | |
| 755 | Some(cpu) => match (self.price("sandbox_base_second").await?, self.price("sandbox_cpu_second").await?) { | |
| 756 | (Some((base, _)), Some((vcpu, _))) => { | |
| 757 | Some((keeper::run_cost(seconds, cpu.min(seconds as f64 * vcpus), base * base_scale, vcpu), cpu)) | |
| 758 | } | |
| 759 | _ => None, | |
| 760 | }, | |
| 761 | None => None, | |
| 762 | }; | |
| 763 | let cost = parts.map_or(seconds as f64 * cost_per_second * price_scale, |(cost, _)| cost).ceil() as i64; | |
| Merge branch 'worktree-agent-a633ac0f7f66d419d' | 764 | let (charge, terms_note, discount) = self.charged(&workspace, credits::with_margin(cost, self.margin_percent)).await?; |
| Project settings say who created the project, by name | 765 | let eligible = credits::eligible_for(a.kind, a.repo.as_deref()); |
| 766 | let drawn = self.draw(&workspace, charge, &credits::month_of(×tamp), &eligible).await?; | |
| 767 | let charge = charge - drawn.total(); | |
| 768 | let cpu_note = parts.map_or(String::new(), |(_, cpu)| format!(", {cpu:.0} vCPU-seconds")); | |
| 769 | let description = format!("{}: {} of sandbox time{cpu_note}{terms_note}{}", a.description, duration(seconds), drawn.note()); | |
| 770 | // The price versions it was charged at (pricing.rs). | |
| 771 | let meters: &[&str] = if parts.is_some() { &["sandbox_base_second", "sandbox_cpu_second"] } else { &["sandbox_second"] }; | |
| 772 | let mut versions = Vec::new(); | |
| 773 | for meter in meters { | |
| 774 | versions.extend(self.version_now(meter).await?); | |
| 775 | } | |
| 776 | let price_version = versions.join(","); | |
| 777 | self.db | |
| 778 | .batch(vec![ | |
| 779 | self.db | |
| 780 | .prepare( | |
| 781 | "INSERT INTO ledger | |
| 782 | (id, workspace, kind, amount_micros, description, repo, task, | |
| Billing, integrations, security and identity no longer serve g1t's own agents. Billing stops metering agent runs, model tokens and AI Gateway requests, drops its model catalogue and price-keeper model prices, and stops selling AI credit, which only ever paid for those; credit bought earlier still pays toward what is owed. A usage report, statement or invoice that holds an old agent or model meter shows it as "Other usage" rather than failing, and the get_ai_credit and buy_ai_credit API operations and MCP billing actions go. Integrations no longer connects model providers or routes models, and no longer assigns imported issues or alerts to g1t; a model provider already connected can still be disconnected. Security drops "Fix with g1t". Identity no longer creates agent tokens or puts agents on teams, a team's lead is a person, and run credentials are issued only for checks, the merge queue, merge checks, deployments and bumps; tokens and credentials already issued keep working until they expire. The reserved names g1t and g1t-agent stay reserved. No tables are dropped. | 783 | cost_micros, reference, created_at, billed_to, credit_micros, trial_micros, oss_micros, given_micros, price_version, quantity, compute) |
| 784 | VALUES (?, ?, 'usage', ?, ?, ?, 'sandbox', ?, ?, ?, 'g1t', ?, ?, ?, ?, ?, ?, ?)", | |
| Project settings say who created the project, by name | 785 | ) |
| 786 | .bind(&[ | |
| 787 | new_id("led", now).into(), | |
| 788 | workspace.as_str().into(), | |
| 789 | (-(charge as f64)).into(), | |
| 790 | description.as_str().into(), | |
| 791 | optional(a.repo.as_deref()), | |
| 792 | (cost as f64).into(), | |
| 793 | a.reference.as_str().into(), | |
| 794 | timestamp.as_str().into(), | |
| 795 | (drawn.credit as f64).into(), | |
| 796 | (drawn.trial as f64).into(), | |
| 797 | (drawn.oss as f64).into(), | |
| 798 | (drawn.given as f64).into(), | |
| 799 | optional(Some(price_version.as_str()).filter(|v| !v.is_empty())), | |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 800 | (seconds as f64).into(), |
| 801 | optional(a.kind.map(ComputeKind::as_str)), | |
| Project settings say who created the project, by name | 802 | ])?, |
| 803 | self.db | |
| 804 | .prepare( | |
| 805 | "INSERT INTO accounts (workspace, balance_micros, created_at) | |
| 806 | VALUES (?1, ?2, ?3) | |
| 807 | ON CONFLICT (workspace) DO UPDATE SET balance_micros = balance_micros + ?2", | |
| 808 | ) | |
| 809 | .bind(&[ | |
| 810 | workspace.as_str().into(), | |
| 811 | (-(charge as f64)).into(), | |
| 812 | timestamp.as_str().into(), | |
| 813 | ])?, | |
| 814 | ]) | |
| 815 | .await?; | |
| Merge branch 'worktree-agent-a633ac0f7f66d419d' | 816 | self.record_discount(&a.reference, discount).await?; |
| Project settings say who created the project, by name | 817 | self.count_spend(&workspace, cost, charge, &drawn).await; |
| 818 | if let Some(reservation) = &a.reservation_id { | |
| 819 | self.settle_reservation(SettleArgs { reservation_id: reservation.clone(), actual_micros: cost }).await?; | |
| 820 | } | |
| 821 | Ok(Outcome::Ok(true)) | |
| 822 | } | |
| 823 | } | |
| 824 | ||
| 825 | /// Whether an amount may be prepaid: $25 at the least by card, and from | |
| 826 | /// $1,000 by bank transfer. | |
| 827 | pub(crate) fn prepay_amount(cents: u32, bank_transfer: bool) -> std::result::Result<(), String> { | |
| 828 | let (min, max) = if bank_transfer { (MIN_BANK_TRANSFER_CENTS, MAX_BANK_TRANSFER_CENTS) } else { (MIN_TOP_UP_CENTS, MAX_TOP_UP_CENTS) }; | |
| 829 | if (min..=max).contains(¢s) { | |
| 830 | return Ok(()); | |
| 831 | } | |
| 832 | Err(if bank_transfer { | |
| 833 | format!("Prepay between ${} and ${} by bank transfer.", min / 100, group(max / 100)) | |
| 834 | } else { | |
| 835 | format!("Prepay between ${} and ${} by card; from $1,000, a bank transfer works too.", min / 100, group(max / 100)) | |
| 836 | }) | |
| 837 | } | |
| 838 | ||
| 839 | /// `10,000` for 10000. | |
| 840 | fn group(n: u32) -> String { | |
| 841 | let digits = n.to_string(); | |
| 842 | let mut out = String::new(); | |
| 843 | for (i, c) in digits.chars().enumerate() { | |
| 844 | if i > 0 && (digits.len() - i).is_multiple_of(3) { | |
| 845 | out.push(','); | |
| 846 | } | |
| 847 | out.push(c); | |
| 848 | } | |
| 849 | out | |
| 850 | } | |
| 851 | ||
| 852 | #[cfg(test)] | |
| 853 | /// What `seconds` of sandbox time are charged at `price_per_second`, in | |
| 854 | /// millionths of a dollar: every second, rounded up to the next millionth. | |
| 855 | fn sandbox_charge(seconds: i64, price_per_second: f64) -> i64 { | |
| 856 | (seconds as f64 * price_per_second).ceil() as i64 | |
| 857 | } | |
| 858 | ||
| 859 | /// `1h 2m`, `3m 12s` or `40s`. | |
| 860 | fn duration(seconds: i64) -> String { | |
| 861 | let (h, m, s) = (seconds / 3600, seconds % 3600 / 60, seconds % 60); | |
| 862 | if h > 0 { | |
| 863 | format!("{h}h {m}m") | |
| 864 | } else if m > 0 { | |
| 865 | format!("{m}m {s}s") | |
| 866 | } else { | |
| 867 | format!("{s}s") | |
| 868 | } | |
| 869 | } | |
| 870 | ||
| 871 | impl Billing { | |
| 872 | /// What a workspace is charged for something that would be `base`: | |
| 873 | /// nothing while g1t is free, or as its account's terms say. With a | |
| 874 | /// note for the statement when it differs. | |
| Merge branch 'worktree-agent-a633ac0f7f66d419d' | 875 | /// A charge at cost plus the margin (`base`) on the account's terms: |
| 876 | /// what is charged, the note for the statement, and what a discount | |
| 877 | /// gave away below `base`. That last is written on the entry | |
| 878 | /// (`record_discount`) so the reconciliation counts it as given, never | |
| 879 | /// as margin lost: a sold charge is worth at least its cost plus the | |
| 880 | /// margin. | |
| 881 | pub(crate) async fn charged(&self, workspace: &str, base: i64) -> Result<(i64, String, i64)> { | |
| Project settings say who created the project, by name | 882 | if self.free { |
| Merge branch 'worktree-agent-a633ac0f7f66d419d' | 883 | return Ok((0, " (free while g1t is being built out)".to_owned(), 0)); |
| Project settings say who created the project, by name | 884 | } |
| 885 | let terms = self.terms_of(workspace).await?; | |
| Merge branch 'worktree-agent-a633ac0f7f66d419d' | 886 | let (charge, discount) = terms.discounted(base); |
| Billing: credits with a kind and expiry, discounts instead of comped, and safer charging | 887 | let note = match terms.discount_label() { |
| 888 | Some(label) if base > 0 => format!(" ({label})"), | |
| Project settings say who created the project, by name | 889 | _ => String::new(), |
| 890 | }; | |
| Merge branch 'worktree-agent-a633ac0f7f66d419d' | 891 | Ok((charge, note, discount)) |
| 892 | } | |
| 893 | ||
| 894 | /// What a discount gave away on an entry, below cost plus the margin | |
| 895 | /// (less than nothing on a correction down). | |
| 896 | pub(crate) async fn record_discount(&self, reference: &str, micros: i64) -> Result<()> { | |
| 897 | if micros == 0 { | |
| 898 | return Ok(()); | |
| 899 | } | |
| 900 | self.db | |
| 901 | .prepare("UPDATE ledger SET discount_micros = ? WHERE reference = ?") | |
| 902 | .bind(&[(micros as f64).into(), reference.into()])? | |
| 903 | .run() | |
| 904 | .await?; | |
| 905 | Ok(()) | |
| Project settings say who created the project, by name | 906 | } |
| 907 | } | |
| 908 | ||
| 909 | fn members_only<T>() -> Outcome<T> { | |
| 910 | Outcome::fail( | |
| 911 | FailureCode::Forbidden, | |
| 912 | "Only members can see a workspace's billing.", | |
| 913 | ) | |
| 914 | } | |
| 915 | ||
| 916 | impl Billing { | |
| 917 | fn from_env(env: &Env) -> Result<Self> { | |
| 918 | Ok(Billing { | |
| 919 | db: env.d1("DB")?, | |
| 920 | stripe: env | |
| 921 | .secret("STRIPE_SECRET_KEY") | |
| 922 | .ok() | |
| 923 | .map(|key| key.to_string()) | |
| 924 | .filter(|key| !key.is_empty()) | |
| 925 | .map(Stripe::new), | |
| 926 | webhook_secret: env | |
| 927 | .secret("STRIPE_WEBHOOK_SECRET") | |
| 928 | .ok() | |
| 929 | .map(|secret| secret.to_string().trim().to_owned()) | |
| 930 | .filter(|secret| !secret.is_empty()), | |
| 931 | margin_percent: env | |
| 932 | .var("MARGIN_PERCENT") | |
| 933 | .ok() | |
| 934 | .and_then(|percent| percent.to_string().parse().ok()) | |
| 935 | .unwrap_or(20), | |
| 936 | free: env.var("FREE_WHILE_BUILDING").is_ok_and(|v| v.to_string() == "true"), | |
| 937 | ceilings: limits::Ceilings::from_env(env), | |
| 938 | trials_on: { | |
| 939 | let plans = credits::Config::from_env(env); | |
| 940 | plans.trial_workspace_micros > 0 && plans.trial_monthly_pool_micros > 0 | |
| 941 | }, | |
| 942 | plans: credits::Config::from_env(env), | |
| 943 | repos: env.service("REPOS").ok(), | |
| 944 | packages: env.service("PACKAGES").ok(), | |
| 945 | identity: env.service("IDENTITY").ok(), | |
| 946 | caps: budget::Caps::from_env(env), | |
| 947 | env: env.clone(), | |
| 948 | }) | |
| 949 | } | |
| 950 | } | |
| 951 | ||
| Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails | 952 | /// The fifteen-minute steps, each on its own: one failing never skips the |
| 953 | /// rest. | |
| Billing, integrations, security and identity no longer serve g1t's own agents. Billing stops metering agent runs, model tokens and AI Gateway requests, drops its model catalogue and price-keeper model prices, and stops selling AI credit, which only ever paid for those; credit bought earlier still pays toward what is owed. A usage report, statement or invoice that holds an old agent or model meter shows it as "Other usage" rather than failing, and the get_ai_credit and buy_ai_credit API operations and MCP billing actions go. Integrations no longer connects model providers or routes models, and no longer assigns imported issues or alerts to g1t; a model provider already connected can still be disconnected. Security drops "Fix with g1t". Identity no longer creates agent tokens or puts agents on teams, a team's lead is a person, and run credentials are issued only for checks, the merge queue, merge checks, deployments and bumps; tokens and credentials already issued keep working until they expire. The reserved names g1t and g1t-agent stay reserved. No tables are dropped. | 954 | async fn tick(billing: &Billing, env: &Env) { |
| Project settings say who created the project, by name | 955 | // Stripe events billing never received, handled now. |
| 956 | match billing.replay_events().await { | |
| 957 | Ok(done) => worker::console_log!("stripe replay: {done}"), | |
| 958 | Err(error) => worker::console_error!("replaying Stripe events failed: {error}"), | |
| 959 | } | |
| Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails | 960 | // This month's users, read once for autopay and the warnings. |
| 961 | let users = if billing.stripe.is_some() { | |
| 962 | match billing.month_users().await { | |
| 963 | Ok(users) => Some(users), | |
| 964 | Err(error) => { | |
| 965 | worker::console_error!("reading this month's users failed: {error}"); | |
| 966 | None | |
| 967 | } | |
| 968 | } | |
| 969 | } else { | |
| 970 | None | |
| 971 | }; | |
| 972 | if let Some(users) = &users | |
| 973 | && let Err(error) = billing.autopay(users).await { | |
| 974 | worker::console_error!("paying at the limit failed: {error}"); | |
| 975 | } | |
| Project settings say who created the project, by name | 976 | // Last month's metered usage (scans, embeddings, storage) goes on the |
| 977 | // ledger before the month is closed and invoiced. | |
| 978 | if let Err(error) = billing.charge_pending().await { | |
| 979 | worker::console_error!("charging last month's metered usage failed: {error}"); | |
| 980 | } | |
| 981 | if let Err(error) = billing.close_months().await { | |
| 982 | worker::console_error!("closing the month failed: {error}"); | |
| 983 | } | |
| 984 | if let Err(error) = billing.invoice_enterprises().await { | |
| 985 | worker::console_error!("invoicing enterprises failed: {error}"); | |
| 986 | } | |
| Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails | 987 | if let Some(users) = &users |
| 988 | && let Ok(identity) = env.service("IDENTITY") | |
| 989 | && let Err(error) = billing.warn_limits(&identity, users).await { | |
| 990 | worker::console_error!("warning owners failed: {error}"); | |
| 991 | } | |
| 992 | } | |
| 993 | ||
| 994 | /// Every fifteen minutes: runs settled, Stripe replayed, cards charged at | |
| 995 | /// the limit, months closed, owners warned. Once a day, at | |
| 996 | /// [`keeper::DAILY`], in an invocation of its own: the slower checks | |
| 997 | /// against Stripe and Cloudflare, and measuring. | |
| 998 | /// | |
| 999 | /// Each step logs its own failure and the next still runs. The spend | |
| 1000 | /// breaker and budget alerts go first: a step that runs out of CPU ends | |
| 1001 | /// the invocation, and must never take the alerts with it. | |
| 1002 | #[event(scheduled)] | |
| 1003 | async fn scheduled(event: ScheduledEvent, env: Env, _ctx: ScheduleContext) { | |
| 1004 | let Ok(billing) = Billing::from_env(&env) else { | |
| 1005 | return; | |
| 1006 | }; | |
| Project settings say who created the project, by name | 1007 | // Comped budgets' alerts, and a tripped breaker staff were not told of. |
| 1008 | if let Err(error) = billing.watch_spend().await { | |
| 1009 | worker::console_error!("watching g1t's own spend failed: {error}"); | |
| 1010 | } | |
| Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails | 1011 | let keeper = keeper::Keeper::from_env(&env); |
| Merge platform pause and the hourly usage watcher: staff can pause compute, schedules, indexing or renders for everyone, the watcher emails on a breach and is never blind quietly, and the models proxy holds each run to its cap (billing 0051, integrations 0006) | 1012 | // Once an hour, at the quarter past: what Cloudflare counted for the |
| 1013 | // whole platform in the hour before, against its thresholds | |
| 1014 | // (platform.rs). | |
| 1015 | if event.cron() == keeper::QUARTER_HOURLY && platform::hourly_due(now_ms()) { | |
| 1016 | match billing.watch_platform(&keeper).await { | |
| 1017 | Ok((read, breached)) => worker::console_log!("platform watch: {read} metrics, {breached} breaches"), | |
| 1018 | Err(error) => worker::console_error!("watching platform usage failed: {error}"), | |
| 1019 | } | |
| 1020 | } | |
| Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails | 1021 | let daily = event.cron() == keeper::DAILY; |
| 1022 | if !daily { | |
| Billing, integrations, security and identity no longer serve g1t's own agents. Billing stops metering agent runs, model tokens and AI Gateway requests, drops its model catalogue and price-keeper model prices, and stops selling AI credit, which only ever paid for those; credit bought earlier still pays toward what is owed. A usage report, statement or invoice that holds an old agent or model meter shows it as "Other usage" rather than failing, and the get_ai_credit and buy_ai_credit API operations and MCP billing actions go. Integrations no longer connects model providers or routes models, and no longer assigns imported issues or alerts to g1t; a model provider already connected can still be disconnected. Security drops "Fix with g1t". Identity no longer creates agent tokens or puts agents on teams, a team's lead is a person, and run credentials are issued only for checks, the merge queue, merge checks, deployments and bumps; tokens and credentials already issued keep working until they expire. The reserved names g1t and g1t-agent stay reserved. No tables are dropped. | 1023 | tick(&billing, &env).await; |
| Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails | 1024 | } |
| Project settings say who created the project, by name | 1025 | // Once a day: Stripe's endpoint kept listening to billing's events and |
| 1026 | // enabled, and saved cards and plans not read in a while read again. | |
| Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails | 1027 | if daily { |
| Project settings say who created the project, by name | 1028 | match billing.keep_endpoint("billing").await { |
| 1029 | Ok(done) => worker::console_log!("stripe endpoint: {done}"), | |
| 1030 | Err(error) => worker::console_error!("keeping Stripe's endpoint failed: {error}"), | |
| 1031 | } | |
| 1032 | match billing.refresh_from_stripe().await { | |
| 1033 | Ok(done) => worker::console_log!("stripe refresh: {done}"), | |
| 1034 | Err(error) => worker::console_error!("refreshing from Stripe failed: {error}"), | |
| 1035 | } | |
| 1036 | } | |
| 1037 | // Once a day, and at once if the costs were never checked: check every | |
| 1038 | // cost against what Cloudflare billed. | |
| Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails | 1039 | if (daily || billing.never_checked().await.unwrap_or(false)) |
| Project settings say who created the project, by name | 1040 | && let Err(error) = billing.reconcile(&keeper).await { |
| 1041 | worker::console_error!("checking costs against Cloudflare failed: {error}"); | |
| 1042 | } | |
| Billing: credits with a kind and expiry, discounts instead of comped, and safer charging | 1043 | // Once a day: credit from g1t past its expiry stops counting |
| 1044 | // (grants.rs), before the day is reconciled. | |
| Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails | 1045 | if daily { |
| Billing: credits with a kind and expiry, discounts instead of comped, and safer charging | 1046 | match billing.expire_credits().await { |
| 1047 | Ok(closed) => worker::console_log!("credits expired: {closed}"), | |
| 1048 | Err(error) => worker::console_error!("expiring credits failed: {error}"), | |
| 1049 | } | |
| 1050 | } | |
| Project settings say who created the project, by name | 1051 | // Once a day: what Cloudflare charged, reconciled against what g1t |
| 1052 | // counted and charged; prices whose day has come; margin alerts | |
| 1053 | // (margin.rs). After the keeper, so its proposals are in. | |
| Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails | 1054 | if daily { |
| Project settings say who created the project, by name | 1055 | match billing.costs_daily(&env, &keeper).await { |
| 1056 | Ok(run) => worker::console_log!("costs: {} lines, {} days, {} proposals, {} alerts", run.lines, run.days, run.proposals, run.alerts), | |
| 1057 | Err(error) => worker::console_error!("reconciling costs failed: {error}"), | |
| 1058 | } | |
| 1059 | } | |
| 1060 | // Once a day: what each workspace's private repositories hold, its git | |
| 1061 | // operations, Deployments plans from before the g1t plan set to end, | |
| 1062 | // and old reservations cleared. | |
| Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails | 1063 | if daily { |
| Project settings say who created the project, by name | 1064 | if let Err(error) = billing.measure_packages().await { |
| 1065 | worker::console_error!("measuring package storage failed: {error}"); | |
| 1066 | } | |
| 1067 | if let Err(error) = billing.measure_storage().await { | |
| 1068 | worker::console_error!("measuring storage failed: {error}"); | |
| 1069 | } | |
| 1070 | if let Err(error) = billing.measure_git().await { | |
| 1071 | worker::console_error!("measuring git operations failed: {error}"); | |
| 1072 | } | |
| 1073 | if let Err(error) = billing.retire_deployments_plans().await { | |
| 1074 | worker::console_error!("ending Deployments plans failed: {error}"); | |
| 1075 | } | |
| Pricing says it plainly: models at the provider's price, the agent rate for what g1t runs around every model call (the gateway, secrets, routing, context and pass-through to your own provider, so your own keys too), everything else at cost plus 20%, your own runners free, no seats; Security and quality comes with the plan with no separate fee, and live activations end. Each agent has an effort setting, Auto to Max, with what a typical task has cost at each level, and Spend's Spend less, keep quality suggests a lower level only when the agent's own past work shows quality held, to apply or dismiss. The pricing, spend and agents guides say how. | 1076 | if let Err(error) = billing.retire_security_activations().await { |
| 1077 | worker::console_error!("ending Security and quality activations failed: {error}"); | |
| 1078 | } | |
| Project settings say who created the project, by name | 1079 | if let Err(error) = billing.sweep_reservations().await { |
| 1080 | worker::console_error!("clearing reservations failed: {error}"); | |
| 1081 | } | |
| Billing, integrations, security and identity no longer serve g1t's own agents. Billing stops metering agent runs, model tokens and AI Gateway requests, drops its model catalogue and price-keeper model prices, and stops selling AI credit, which only ever paid for those; credit bought earlier still pays toward what is owed. A usage report, statement or invoice that holds an old agent or model meter shows it as "Other usage" rather than failing, and the get_ai_credit and buy_ai_credit API operations and MCP billing actions go. Integrations no longer connects model providers or routes models, and no longer assigns imported issues or alerts to g1t; a model provider already connected can still be disconnected. Security drops "Fix with g1t". Identity no longer creates agent tokens or puts agents on teams, a team's lead is a person, and run credentials are issued only for checks, the merge queue, merge checks, deployments and bumps; tokens and credentials already issued keep working until they expire. The reserved names g1t and g1t-agent stay reserved. No tables are dropped. | 1082 | // The AI Gateway's request log, from when there was one, still |
| 1083 | // keeps only its last 30 days until it is empty. | |
| 1084 | let cutoff = rfc3339(now_ms().saturating_sub(30 * 24 * 60 * 60 * 1000)); | |
| 1085 | let cleared = match billing.db.prepare("DELETE FROM gateway_requests WHERE created_at < ?").bind(&[cutoff.into()]) { | |
| 1086 | Ok(statement) => statement.run().await.map(|_| ()), | |
| 1087 | Err(error) => Err(error), | |
| 1088 | }; | |
| 1089 | if let Err(error) = cleared { | |
| Merge the AI Gateway: Anthropic's Messages API on a workspace's tokens | 1090 | worker::console_error!("clearing old AI Gateway requests failed: {error}"); |
| 1091 | } | |
| Project settings say who created the project, by name | 1092 | } |
| 1093 | } | |
| 1094 | ||
| 1095 | /// Events from the bus, on billing's own queue: only a workspace's rename | |
| 1096 | /// matters here (see `rename`). | |
| 1097 | #[event(queue)] | |
| 1098 | async fn queue(batch: MessageBatch<g1t_contracts::events::Event>, env: Env, _ctx: Context) -> Result<()> { | |
| 1099 | let billing = Billing::from_env(&env)?; | |
| 1100 | let identity = env.service("IDENTITY").ok(); | |
| 1101 | for message in batch.messages()? { | |
| 1102 | // A repository transferred: its share of the open-source pool this | |
| 1103 | // month follows it. What it was charged stays with the workspace | |
| 1104 | // it was charged to; usage from now on is charged to the new one. | |
| 1105 | if g1t_kit::transfer::on_event(&env, &billing.db, message.body(), closing::TRANSFERRED).await? { | |
| 1106 | message.ack(); | |
| 1107 | continue; | |
| 1108 | } | |
| 1109 | billing.on_event(identity.as_ref(), message.body()).await?; | |
| 1110 | message.ack(); | |
| 1111 | } | |
| 1112 | Ok(()) | |
| 1113 | } | |
| 1114 | ||
| 1115 | #[event(fetch)] | |
| 1116 | async fn fetch(mut request: Request, env: Env, _ctx: Context) -> Result<Response> { | |
| 1117 | let Some(method) = rpc_method(&request) else { | |
| 1118 | return Response::error("Not found", 404); | |
| 1119 | }; | |
| 1120 | // A replica near the caller when it asks for one (crates/kit/src/d1.rs); | |
| 1121 | // what other services call (can_start, start_run) asks for none. | |
| 1122 | let (db, served) = g1t_kit::d1::open(&env, "DB", &request)?; | |
| 1123 | let body: serde_json::Value = request.json().await?; | |
| 1124 | let mut billing = Billing::from_env(&env)?; | |
| 1125 | billing.db = db; | |
| 1126 | let answered = match method.as_str() { | |
| 1127 | "status" => reply(&billing.status()), | |
| 1128 | "account" => reply(&billing.account(args(body)?).await?), | |
| 1129 | "ledger" => reply(&billing.ledger(args(body)?).await?), | |
| Billing: credits with a kind and expiry, discounts instead of comped, and safer charging | 1130 | "credits" => reply(&billing.credits(args(body)?).await?), |
| Project settings say who created the project, by name | 1131 | "usage" => reply(&billing.usage(args(body)?).await?), |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 1132 | "usage_report" => reply(&billing.usage_report(args(body)?).await?), |
| 1133 | "set_budget" => reply(&billing.set_budget(args(body)?).await?), | |
| 1134 | "billing_details" => reply(&billing.billing_details(args(body)?).await?), | |
| 1135 | "set_billing_details" => reply(&billing.set_billing_details(args(body)?).await?), | |
| Project settings say who created the project, by name | 1136 | "checkout" => reply(&billing.checkout(args(body)?).await?), |
| 1137 | "confirm" => reply(&billing.confirm(args(body)?).await?), | |
| 1138 | "trial" => reply(&billing.trial(args(body)?).await?), | |
| 1139 | "features" => reply(&billing.features(args(body)?).await?), | |
| 1140 | "subscribe" => reply(&billing.subscribe(args(body)?).await?), | |
| 1141 | "confirm_subscription" => reply(&billing.confirm_subscription(args(body)?).await?), | |
| 1142 | "cancel_subscription" => reply(&billing.cancel_subscription(args(body)?).await?), | |
| 1143 | "close_workspace" => reply(&billing.close_workspace(args(body)?).await?), | |
| 1144 | "has_feature" => reply(&billing.has_feature(args(body)?).await?), | |
| Merge Stripe Tax, the card fee on card payments, and one free workspace per person | 1145 | "free_workspaces" => reply(&billing.free_workspaces(args(body)?).await?), |
| Project settings say who created the project, by name | 1146 | "charge_feature" => reply(&billing.charge_feature(args(body)?).await?), |
| 1147 | "record_sandbox" => reply(&billing.record_sandbox(args(body)?).await?), | |
| 1148 | "limit" => reply(&billing.limit(args(body)?).await?), | |
| 1149 | "check_limit" => reply(&billing.check_limit(args(body)?).await?), | |
| 1150 | "set_spend_limit" => reply(&billing.set_spend_limit(args(body)?).await?), | |
| 1151 | "prices" => reply(&billing.prices().await?), | |
| 1152 | "billing_portal" => reply(&billing.billing_portal(args(body)?).await?), | |
| 1153 | "admin_billing_link" => reply(&billing.admin_billing_link(args(body)?).await?), | |
| 1154 | "admin_stripe" => reply(&billing.admin_stripe(args(body)?).await?), | |
| 1155 | "admin_enterprise_billing" => reply(&billing.admin_enterprise_billing(args(body)?).await?), | |
| Merge Stripe Tax, the card fee on card payments, and one free workspace per person | 1156 | "admin_enterprise_address" => reply(&billing.admin_enterprise_address(args(body)?).await?), |
| Project settings say who created the project, by name | 1157 | "admin_invoice_enterprise" => reply(&billing.admin_invoice_enterprise(args(body)?).await?), |
| 1158 | "stripe_webhook" => reply(&billing.stripe_webhook(args(body)?).await?), | |
| 1159 | "invoices" => reply(&billing.invoices(args(body)?).await?), | |
| 1160 | "statement" => reply(&billing.statement(args(body)?).await?), | |
| 1161 | "statement_entries" => reply(&billing.statement_entries(args(body)?).await?), | |
| 1162 | "usage_meters" => reply(&billing.usage_meters(args(body)?).await?), | |
| 1163 | "admin_workspace_invoices" => { | |
| 1164 | let a: AdminWorkspaceInvoicesArgs = args(body)?; | |
| 1165 | reply(&billing.workspace_invoices(&a.workspace.to_lowercase()).await?) | |
| 1166 | } | |
| 1167 | "admin_signals" => reply(&billing.admin_signals(args(body)?).await?), | |
| 1168 | "admin_overview" => reply(&billing.admin_overview(args(body)?).await?), | |
| 1169 | "admin_sales" => reply(&billing.admin_sales(args(body)?).await?), | |
| 1170 | "admin_set_sales" => reply(&billing.admin_set_sales(args(body)?).await?), | |
| 1171 | "admin_add_note" => reply(&billing.admin_add_note(args(body)?).await?), | |
| 1172 | "admin_invoices" => reply(&billing.admin_invoices(args(body)?).await?), | |
| 1173 | "admin_audit" => reply(&billing.admin_audit(args(body)?).await?), | |
| 1174 | // A staff change made in another service, for sudo's audit log: | |
| 1175 | // identity's restores and purges of deleted workspaces. | |
| 1176 | "admin_log" => { | |
| 1177 | let a: AdminLogArgs = args(body)?; | |
| 1178 | billing | |
| 1179 | .audit(&accounts::own_account(&a.workspace), &a.action, &a.detail, &a.by) | |
| 1180 | .await?; | |
| 1181 | reply(&true) | |
| 1182 | } | |
| 1183 | "note_pending" => reply(&billing.note_pending(args(body)?).await?), | |
| 1184 | "admin_accounts" => reply(&billing.admin_accounts(args(body)?).await?), | |
| 1185 | "admin_account" => reply(&billing.admin_account(args(body)?).await?), | |
| 1186 | "admin_set_terms" => reply(&billing.admin_set_terms(args(body)?).await?), | |
| 1187 | "admin_create_enterprise" => reply(&billing.admin_create_enterprise(args(body)?).await?), | |
| 1188 | "admin_attach" => reply(&billing.admin_attach(args(body)?).await?), | |
| 1189 | "admin_credit" => reply(&billing.admin_credit(args(body)?).await?), | |
| Billing: credits with a kind and expiry, discounts instead of comped, and safer charging | 1190 | "admin_credits" => reply(&billing.admin_credits(args(body)?).await?), |
| 1191 | "admin_revoke_credit" => reply(&billing.admin_revoke_credit(args(body)?).await?), | |
| sudo: a billing reset runs the costs analysis again so every figure is fresh; every submit button shows it is working (CSS only); no margin percentage on less than a cent sold | 1192 | "admin_reset_billing" => reply(&billing.admin_reset_billing(&env, args(body)?).await?), |
| Project settings say who created the project, by name | 1193 | "admin_set_allowances" => reply(&billing.admin_set_allowances(args(body)?).await?), |
| 1194 | "entitlements" => reply(&billing.entitlements(args(body)?).await?), | |
| 1195 | "audit_retention" => reply(&billing.audit_retention(args(body)?).await?), | |
| 1196 | "reserve" => reply(&billing.reserve(args(body)?).await?), | |
| 1197 | "settle" => reply(&billing.settle_reservation(args(body)?).await?), | |
| 1198 | "card_check" => reply(&billing.card_check(args(body)?).await?), | |
| 1199 | "confirm_card_check" => reply(&billing.confirm_card_check(args(body)?).await?), | |
| 1200 | "request_limit" => reply(&billing.request_limit(args(body)?).await?), | |
| 1201 | "limit_requests" => reply(&billing.limit_requests(args(body)?).await?), | |
| 1202 | "confirm_spike" => reply(&billing.confirm_spike(args(body)?).await?), | |
| 1203 | "set_caps" => reply(&billing.set_caps(args(body)?).await?), | |
| 1204 | "admin_limit_requests" => reply(&billing.admin_limit_requests(args(body)?).await?), | |
| 1205 | "admin_decide_limit_request" => reply(&billing.admin_decide_limit_request(args(body)?).await?), | |
| 1206 | "admin_overages" => reply(&billing.admin_overages(args(body)?).await?), | |
| 1207 | "admin_goodwill" => reply(&billing.admin_goodwill(args(body)?).await?), | |
| 1208 | "admin_velocity" => reply(&billing.admin_velocity(args(body)?).await?), | |
| 1209 | "admin_record_payment" => reply(&billing.admin_record_payment(args(body)?).await?), | |
| 1210 | "admin_costs" => reply(&billing.admin_costs(args(body)?, keeper::Keeper::from_env(&env).can_read_bill()).await?), | |
| 1211 | "admin_cost_alerts" => reply(&billing.admin_cost_alerts(args(body)?).await?), | |
| 1212 | "admin_spend_caps" => reply(&billing.spend_caps().await?), | |
| 1213 | "admin_lift_breaker" => reply(&billing.admin_lift_breaker(args(body)?).await?), | |
| Merge platform pause and the hourly usage watcher: staff can pause compute, schedules, indexing or renders for everyone, the watcher emails on a breach and is never blind quietly, and the models proxy holds each run to its cap (billing 0051, integrations 0006) | 1214 | // Platform pauses (src/platform.rs): read by every service that |
| 1215 | // honours one, kept 30 seconds in each isolate. | |
| 1216 | "platform_pause" => reply(&billing.pause_now().await), | |
| 1217 | "admin_platform_guard" => reply(&billing.platform_guard(&keeper::Keeper::from_env(&env)).await?), | |
| 1218 | "admin_set_pause" => reply(&billing.admin_set_pause(args(body)?, &keeper::Keeper::from_env(&env)).await?), | |
| Project settings say who created the project, by name | 1219 | "admin_decide_proposal" => reply(&billing.admin_decide_proposal(args(body)?).await?), |
| 1220 | "admin_set_cost_settings" => reply(&billing.admin_set_cost_settings(args(body)?).await?), | |
| 1221 | "admin_set_cost_mapping" => reply(&billing.admin_set_cost_mapping(args(body)?).await?), | |
| 1222 | "admin_run_costs" => reply(&billing.admin_run_costs(&env, args(body)?).await?), | |
| 1223 | _ => Response::error("Unknown method", 404), | |
| 1224 | }; | |
| 1225 | served.finish(answered) | |
| 1226 | } | |
| 1227 | ||
| 1228 | #[cfg(test)] | |
| 1229 | mod tests { | |
| 1230 | use super::*; | |
| 1231 | ||
| 1232 | #[test] | |
| 1233 | fn sandbox_time_is_charged_from_the_first_second() { | |
| 1234 | // 21 millionths a second at cost, plus 20%. | |
| 1235 | let price = Price::price_for(21.0, 20); | |
| 1236 | assert_eq!(sandbox_charge(1, price), 26); | |
| 1237 | assert_eq!(sandbox_charge(60, price), 1_512); | |
| 1238 | assert_eq!(sandbox_charge(0, price), 0); | |
| 1239 | } | |
| 1240 | ||
| 1241 | #[test] | |
| 1242 | fn prepaying_starts_at_twenty_five_dollars_and_bank_transfers_at_a_thousand() { | |
| 1243 | assert!(prepay_amount(2_500, false).is_ok()); | |
| 1244 | assert!(prepay_amount(2_499, false).is_err()); | |
| 1245 | assert!(prepay_amount(10_000, false).is_ok()); | |
| 1246 | assert!(prepay_amount(1_000_000, false).is_ok()); | |
| 1247 | assert_eq!(prepay_amount(1_000_001, false).unwrap_err(), "Prepay between $25 and $10,000 by card; from $1,000, a bank transfer works too."); | |
| 1248 | assert!(prepay_amount(99_999, true).is_err()); | |
| 1249 | assert!(prepay_amount(100_000, true).is_ok()); | |
| 1250 | } | |
| 1251 | ||
| 1252 | #[test] | |
| 1253 | fn durations_read_plainly() { | |
| 1254 | assert_eq!(duration(40), "40s"); | |
| 1255 | assert_eq!(duration(192), "3m 12s"); | |
| 1256 | assert_eq!(duration(3720), "1h 2m"); | |
| 1257 | } | |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 1258 | |
| 1259 | /// Every migration, in order, as D1 applies them. | |
| 1260 | const MIGRATIONS: &[&str] = &[ | |
| 1261 | include_str!("../migrations/0001_init.sql"), | |
| 1262 | include_str!("../migrations/0002_own_provider.sql"), | |
| 1263 | include_str!("../migrations/0003_subscriptions.sql"), | |
| 1264 | include_str!("../migrations/0004_sandbox_time.sql"), | |
| 1265 | include_str!("../migrations/0005_limits.sql"), | |
| 1266 | include_str!("../migrations/0006_prices.sql"), | |
| 1267 | include_str!("../migrations/0007_pending_usage.sql"), | |
| 1268 | include_str!("../migrations/0008_accounts.sql"), | |
| 1269 | include_str!("../migrations/0009_autopay.sql"), | |
| 1270 | include_str!("../migrations/0010_month_close.sql"), | |
| 1271 | include_str!("../migrations/0011_limit_warnings.sql"), | |
| 1272 | include_str!("../migrations/0012_stripe_webhooks.sql"), | |
| 1273 | include_str!("../migrations/0013_invoices_trust_sales.sql"), | |
| 1274 | include_str!("../migrations/0014_custom_domain_price.sql"), | |
| 1275 | include_str!("../migrations/0015_sandbox_at_cost.sql"), | |
| 1276 | include_str!("../migrations/0016_plans_and_pools.sql"), | |
| 1277 | include_str!("../migrations/0017_flagon_comped.sql"), | |
| 1278 | include_str!("../migrations/0018_one_plan.sql"), | |
| 1279 | include_str!("../migrations/0019_closed_workspaces.sql"), | |
| 1280 | include_str!("../migrations/0020_syntaqx_standard.sql"), | |
| 1281 | include_str!("../migrations/0021_no_quotas.sql"), | |
| 1282 | include_str!("../migrations/0022_costs_and_margin.sql"), | |
| 1283 | include_str!("../migrations/0023_one_operation_mapping.sql"), | |
| 1284 | include_str!("../migrations/0024_spend_caps.sql"), | |
| 1285 | include_str!("../migrations/0025_run_tier.sql"), | |
| 1286 | include_str!("../migrations/0026_ledger_by_workspace_time.sql"), | |
| 1287 | include_str!("../migrations/0027_stripe_cache.sql"), | |
| 1288 | include_str!("../migrations/0028_drop_stripe_webhooks.sql"), | |
| 1289 | include_str!("../migrations/0029_audit_retention.sql"), | |
| 1290 | include_str!("../migrations/0030_token_usage.sql"), | |
| 1291 | include_str!("../migrations/0031_package_storage.sql"), | |
| 1292 | include_str!("../migrations/0032_workspace_value.sql"), | |
| 1293 | include_str!("../migrations/0033_given_away.sql"), | |
| 1294 | include_str!("../migrations/0034_given_by_why.sql"), | |
| 1295 | include_str!("../migrations/0035_cf_subscriptions.sql"), | |
| 1296 | include_str!("../migrations/0036_model_costs_in_full.sql"), | |
| 1297 | include_str!("../migrations/0037_security_activation.sql"), | |
| 1298 | include_str!("../migrations/0038_staff_credits.sql"), | |
| 1299 | include_str!("../migrations/0039_discounts_not_comped.sql"), | |
| 1300 | include_str!("../migrations/0040_ai_credit.sql"), | |
| Merge branch 'model-routing' | 1301 | include_str!("../migrations/0041_agent_rate_own_key.sql"), |
| 1302 | include_str!("../migrations/0042_agent_rate_weights.sql"), | |
| Merge the AI Gateway: Anthropic's Messages API on a workspace's tokens | 1303 | include_str!("../migrations/0043_tax_and_card_fees.sql"), |
| 1304 | include_str!("../migrations/0044_cache_reads_count_a_tenth.sql"), | |
| 1305 | include_str!("../migrations/0045_gateway.sql"), | |
| Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97 | 1306 | include_str!("../migrations/0046_reset_costs.sql"), |
| 1307 | include_str!("../migrations/0047_gateway_formats.sql"), | |
| Merge branch 'main' into actions-toolkit-oidc-artifacts | 1308 | include_str!("../migrations/0048_model_catalogue.sql"), |
| Merge platform pause and the hourly usage watcher: staff can pause compute, schedules, indexing or renders for everyone, the watcher emails on a breach and is never blind quietly, and the models proxy holds each run to its cap (billing 0051, integrations 0006) | 1309 | include_str!("../migrations/0049_superseded_proposals.sql"), |
| 1310 | include_str!("../migrations/0050_ledger_usage_by_time.sql"), | |
| 1311 | include_str!("../migrations/0051_platform_guardrails.sql"), | |
| Spend adds up on one ledger. Charged this month has one definition, price less discount, included usage and credit, shared by the plan card, the spend limit, Spend and the top bar; the limit had counted usage not yet closed at full price before the discount, so a comped workspace read as charged a cent. Every agent line on the ledger names the agent and who asked, repository runs by g1t included, so Spent is the sum of its products, Agents is the agent product, and by agent adds up to it; the billing API returns by_agent and by_person. The usage and billing guide says how spend is counted. | 1312 | include_str!("../migrations/0052_cloudflare_cycle.sql"), |
| 1313 | include_str!("../migrations/0053_models_at_cost.sql"), | |
| 1314 | include_str!("../migrations/0054_agent_attribution.sql"), | |
| Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's. | 1315 | include_str!("../migrations/0055_app_containers.sql"), |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 1316 | ]; |
| 1317 | ||
| 1318 | /// The columns of `table` after the migrations: each with whether an | |
| 1319 | /// insert must give it (NOT NULL, no default). | |
| 1320 | fn columns(table: &str) -> Vec<(String, bool)> { | |
| 1321 | let mut out = vec![]; | |
| 1322 | for sql in MIGRATIONS { | |
| 1323 | let sql: String = sql.lines().map(|l| l.split("--").next().unwrap_or("")).collect::<Vec<_>>().join("\n"); | |
| 1324 | if let Some(start) = sql.find(&format!("CREATE TABLE {table} (")) { | |
| 1325 | let body = &sql[start + format!("CREATE TABLE {table} (").len()..]; | |
| 1326 | let body = &body[..body.find(");").unwrap()]; | |
| 1327 | for column in body.split(",\n") { | |
| 1328 | let column = column.trim(); | |
| 1329 | let name = column.split_whitespace().next().unwrap_or("").to_owned(); | |
| 1330 | if name.is_empty() || name == "PRIMARY" { | |
| 1331 | continue; | |
| 1332 | } | |
| 1333 | let required = column.contains("NOT NULL") && !column.contains("DEFAULT") && !column.contains("PRIMARY KEY"); | |
| 1334 | out.push((name, required || column.contains("PRIMARY KEY"))); | |
| 1335 | } | |
| 1336 | } | |
| 1337 | for statement in sql.split(';') { | |
| 1338 | let s = statement.trim(); | |
| 1339 | if let Some(rest) = s.strip_prefix(&format!("ALTER TABLE {table} ADD COLUMN ")) { | |
| 1340 | let name = rest.split_whitespace().next().unwrap().to_owned(); | |
| 1341 | out.push((name, rest.contains("NOT NULL") && !rest.contains("DEFAULT"))); | |
| 1342 | } | |
| 1343 | } | |
| 1344 | } | |
| 1345 | out | |
| 1346 | } | |
| 1347 | ||
| 1348 | /// The columns an `INSERT INTO table (…)` names. | |
| 1349 | fn inserted(sql: &str, table: &str) -> Vec<String> { | |
| 1350 | let start = sql.find(&format!("INSERT INTO {table} (")).unwrap() + format!("INSERT INTO {table} (").len(); | |
| 1351 | sql[start..start + sql[start..].find(')').unwrap()].split(',').map(|c| c.trim().to_owned()).collect() | |
| 1352 | } | |
| Merge branch 'model-routing' | 1353 | |
| Chat and workspace agents: channels, DMs and named agents you talk to | 1354 | |
| 1355 | #[test] | |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 1356 | fn every_checkout_insert_fills_the_table() { |
| Billing, integrations, security and identity no longer serve g1t's own agents. Billing stops metering agent runs, model tokens and AI Gateway requests, drops its model catalogue and price-keeper model prices, and stops selling AI credit, which only ever paid for those; credit bought earlier still pays toward what is owed. A usage report, statement or invoice that holds an old agent or model meter shows it as "Other usage" rather than failing, and the get_ai_credit and buy_ai_credit API operations and MCP billing actions go. Integrations no longer connects model providers or routes models, and no longer assigns imported issues or alerts to g1t; a model provider already connected can still be disconnected. Security drops "Fix with g1t". Identity no longer creates agent tokens or puts agents on teams, a team's lead is a person, and run credentials are issued only for checks, the merge queue, merge checks, deployments and bumps; tokens and credentials already issued keep working until they expire. The reserved names g1t and g1t-agent stay reserved. No tables are dropped. | 1357 | // The plan's, the activation's, a card check's and a prepayment's |
| 1358 | // pages all go through one insert, and it names every | |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 1359 | // column the table needs and none it lacks: a missing NOT NULL |
| 1360 | // column, or one from a migration not yet applied, was a 500. | |
| 1361 | let table = columns("checkouts"); | |
| 1362 | let names: Vec<&str> = table.iter().map(|(n, _)| n.as_str()).collect(); | |
| 1363 | assert_eq!(names, ["id", "workspace", "amount_cents", "created_by", "status", "created_at", "feature", "fee_cents"]); | |
| 1364 | let given = inserted(CHECKOUT_INSERT, "checkouts"); | |
| 1365 | for column in &given { | |
| 1366 | assert!(names.contains(&column.as_str()), "the insert names {column}, which checkouts does not have"); | |
| 1367 | } | |
| 1368 | for (column, required) in &table { | |
| 1369 | assert!(!required || given.contains(column), "checkouts needs {column}, which the insert leaves out"); | |
| 1370 | } | |
| 1371 | // As many values as columns. | |
| 1372 | let values = CHECKOUT_INSERT.split("VALUES").nth(1).unwrap(); | |
| 1373 | assert_eq!(values.matches('?').count() + values.matches("'open'").count(), given.len()); | |
| 1374 | // And no page writes a row of its own any more. | |
| 1375 | for (file, source) in [ | |
| 1376 | ("lib.rs", include_str!("lib.rs")), | |
| 1377 | ("features.rs", include_str!("features.rs")), | |
| 1378 | ("cards.rs", include_str!("cards.rs")), | |
| 1379 | ] { | |
| 1380 | let inserts = source.matches(concat!("INSERT INTO ", "checkouts")).count(); | |
| 1381 | assert!(inserts <= usize::from(file == "lib.rs"), "{file} inserts into checkouts on its own"); | |
| 1382 | } | |
| 1383 | } | |
| 1384 | ||
| 1385 | #[test] | |
| 1386 | fn a_new_ledger_column_has_a_default() { | |
| 1387 | // The ledger is written from a dozen places; a column without a | |
| 1388 | // default would break every one that does not name it. | |
| 1389 | for (column, required) in columns("ledger") { | |
| 1390 | assert!(!required || ["id", "workspace", "kind", "amount_micros", "description", "reference", "created_at"].contains(&column.as_str()), "{column}"); | |
| 1391 | } | |
| 1392 | } | |
| Project settings say who created the project, by name | 1393 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.