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.
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 1 | //! The g1t plan: one monthly price per workspace, never per person, that |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 2 | //! includes $10 of usage. Everything that costs g1t money is metered from |
| 3 | //! the first unit at cost plus the margin and drawn from that $10 first; | |
| 4 | //! past it, it is charged, up to the workspace's spend limit. There are no | |
| 5 | //! per-feature quotas: no count of apps, build minutes, requests or | |
| 6 | //! domains ever stops a workspace on the plan. Only its spend limit does | |
| 7 | //! (and g1t's protections against abuse). Projects, previews and | |
| 8 | //! repositories cost g1t next to nothing and are not metered. None of it is | |
| 9 | //! free, whatever `FREE_WHILE_BUILDING` says. | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 10 | //! |
| 11 | //! Deployments were once a plan of their own. They come with the g1t plan | |
| 12 | //! now: `has_feature(deployments)` answers whether the workspace has the | |
| 13 | //! plan, and a Deployments subscription from before keeps working until | |
| 14 | //! its period ends. Billing sets each one to end then, once | |
| 15 | //! (`retire_deployments_plans`), so no one pays for both. | |
| 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. | 16 | //! |
| 17 | //! Security and quality was once a $10 monthly activation of its own. It | |
| 18 | //! comes with the g1t plan now, at no price of its own (migration 0053 sets | |
| 19 | //! `security_activation` to $0): its scans are metered at cost plus the | |
| 20 | //! margin like everything else. `has_feature(security)` answers whether | |
| 21 | //! the workspace has the plan (or the activation's old inclusion), and | |
| 22 | //! billing ends every activation subscription at once, daily until none is | |
| 23 | //! left, then archives its product at Stripe | |
| 24 | //! (`retire_security_activations`). No refund is made for the part of a | |
| 25 | //! month already paid. | |
| Paid features: a workspace turns on Deployments with a monthly plan | 26 | |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 27 | use g1t_contracts::billing::deployment_costs as costs; |
| Paid features: a workspace turns on Deployments with a monthly plan | 28 | use g1t_contracts::billing::*; |
| 29 | use g1t_contracts::time::rfc3339; | |
| Merge main (membership, two-factor, GitHub repo roles) into tokens | 30 | use g1t_contracts::{FailureCode, Outcome}; |
| Paid features: a workspace turns on Deployments with a monthly plan | 31 | use g1t_kit::now_ms; |
| 32 | use serde::Deserialize; | |
| 33 | use worker::Result; | |
| 34 | ||
| Project dependencies: addresses, preview stacks, Affects, and agents who know | 35 | use crate::stripe::{StripeSubscription, is_missing}; |
| Paid features: a workspace turns on Deployments with a monthly plan | 36 | use crate::{Billing, Touched, members_only, optional}; |
| 37 | ||
| 38 | #[derive(Deserialize)] | |
| 39 | struct SubscriptionRow { | |
| 40 | feature: String, | |
| 41 | subscription_id: String, | |
| 42 | status: String, | |
| 43 | period_end: Option<String>, | |
| 44 | started_by: String, | |
| 45 | started_at: String, | |
| Billing answers every page in a few round trips, not forty: its reads run together, Stripe is asked at most hourly, and the ledger has a time index | 46 | updated_at: String, |
| 47 | } | |
| 48 | ||
| 49 | /// How long a plan's row is believed after it was last written, once its | |
| 50 | /// period is over, before the processor is asked again. | |
| 51 | const REFRESH_MS: u64 = 60 * 60 * 1000; | |
| 52 | ||
| 53 | /// Whether to ask the processor about a plan again: its period is over (or | |
| 54 | /// unknown) and it is not canceled, and it was not written in the last hour. | |
| 55 | /// Without the hour a plan the processor still shows as ended would be | |
| 56 | /// asked about on every page. | |
| 57 | fn needs_refresh(status: &str, period_end: Option<&str>, updated_at: &str, now_ms: u64) -> bool { | |
| 58 | let now = rfc3339(now_ms); | |
| 59 | let over = period_end.is_none_or(|end| end <= now.as_str()) && status != "canceled"; | |
| 60 | over && updated_at <= rfc3339(now_ms.saturating_sub(REFRESH_MS)).as_str() | |
| Paid features: a workspace turns on Deployments with a monthly plan | 61 | } |
| 62 | ||
| 63 | #[derive(Deserialize)] | |
| 64 | struct PlanCheckoutRow { | |
| 65 | workspace: String, | |
| 66 | created_by: String, | |
| 67 | feature: String, | |
| 68 | } | |
| 69 | ||
| 70 | fn status_from(text: &str) -> SubscriptionStatus { | |
| 71 | match text { | |
| 72 | "active" => SubscriptionStatus::Active, | |
| 73 | "canceling" => SubscriptionStatus::Canceling, | |
| 74 | "past_due" => SubscriptionStatus::PastDue, | |
| 75 | _ => SubscriptionStatus::Canceled, | |
| 76 | } | |
| 77 | } | |
| 78 | ||
| 79 | fn status_text(status: SubscriptionStatus) -> &'static str { | |
| 80 | match status { | |
| 81 | SubscriptionStatus::Active => "active", | |
| 82 | SubscriptionStatus::Canceling => "canceling", | |
| 83 | SubscriptionStatus::PastDue => "past_due", | |
| 84 | SubscriptionStatus::Canceled => "canceled", | |
| 85 | } | |
| 86 | } | |
| 87 | ||
| 88 | /// What the processor's state for a plan means here. | |
| 89 | fn status_of(subscription: &StripeSubscription) -> SubscriptionStatus { | |
| 90 | match subscription.status.as_str() { | |
| 91 | "active" | "trialing" if subscription.cancel_at_period_end => SubscriptionStatus::Canceling, | |
| 92 | "active" | "trialing" => SubscriptionStatus::Active, | |
| 93 | "past_due" | "unpaid" | "incomplete" | "paused" => SubscriptionStatus::PastDue, | |
| 94 | _ => SubscriptionStatus::Canceled, | |
| 95 | } | |
| 96 | } | |
| 97 | ||
| Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put | 98 | /// `1 GB`, `50 GB`, or `500 MB`, as storage is priced (powers of ten). |
| 99 | pub(crate) fn bytes(bytes: i64) -> String { | |
| 100 | if bytes >= 1_000_000_000 && bytes % 1_000_000_000 == 0 { | |
| 101 | format!("{} GB", bytes / 1_000_000_000) | |
| 102 | } else if bytes >= 1_000_000_000 { | |
| 103 | format!("{:.1} GB", bytes as f64 / 1e9) | |
| 104 | } else { | |
| 105 | format!("{} MB", bytes / 1_000_000) | |
| 106 | } | |
| 107 | } | |
| 108 | ||
| Deployments: a preview for every pull request, production on g1t.page | 109 | /// Dollars to the cent, or finer for prices under a cent, so that a |
| 110 | /// build minute's $0.0015 does not read as nothing. | |
| Usage limits: unpaid usage can only go so far | 111 | pub(crate) fn dollars(micros: i64) -> String { |
| Deployments: a preview for every pull request, production on g1t.page | 112 | let text = format!("{:.4}", micros as f64 / MICROS_PER_DOLLAR as f64); |
| 113 | let (whole, fraction) = text.split_once('.').unwrap_or((&text, "")); | |
| 114 | let fraction = fraction.trim_end_matches('0'); | |
| 115 | format!("${whole}.{fraction:0<2}") | |
| Paid features: a workspace turns on Deployments with a monthly plan | 116 | } |
| 117 | ||
| Money in billing's messages reads to the cent, and the deploy reads migrations again after a failure | 118 | /// An amount of money to the cent, as balances and amounts owed read: |
| 119 | /// `$3.99`, never `$3.987`. Prices use [`dollars`]. | |
| 120 | pub(crate) fn cents(micros: i64) -> String { | |
| 121 | format!("${:.2}", micros as f64 / MICROS_PER_DOLLAR as f64) | |
| 122 | } | |
| 123 | ||
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 124 | /// `units` at `each` micros a unit, as the pricing page writes it: a |
| 125 | /// build second's price times 60 is the build minute both quote. | |
| 126 | pub(crate) fn per_units(each: f64, units: f64) -> String { | |
| 127 | dollars((each * units).round() as i64) | |
| 128 | } | |
| 129 | ||
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 130 | /// The price book's meter for the Security and quality activation. |
| 131 | pub(crate) const SECURITY_METER: &str = "security_activation"; | |
| 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. | 132 | /// Its price when the price book cannot be read: none, as the book says. |
| 133 | const SECURITY_FALLBACK_MICROS: f64 = 0.0; | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 134 | |
| 135 | /// The Security and quality activation at the price book's price. | |
| 136 | pub(crate) fn security_plan_at(book: &std::collections::BTreeMap<&str, f64>) -> Plan { | |
| 137 | let micros = book.get(SECURITY_METER).copied().unwrap_or(SECURITY_FALLBACK_MICROS); | |
| 138 | Plan { | |
| 139 | feature: Feature::Security, | |
| 140 | title: Feature::Security.title().to_owned(), | |
| 141 | monthly_cents: (micros / 10_000.0).round().max(0.0) as u32, | |
| Merge Stripe Tax, the card fee on card payments, and one free workspace per person | 142 | card_fee_cents: 0, |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 143 | includes: vec![ |
| 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. | 144 | "With the g1t plan, for every private repository in the workspace; public repositories have it free".to_owned(), |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 145 | "Custom secret patterns, validity checks with issuers, and delegated push protection bypass".to_owned(), |
| 146 | "Code scanning from SARIF, with pull request checks that can block merges".to_owned(), | |
| 147 | "Dependency review on pull requests, and the workspace's security overview".to_owned(), | |
| 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. | 148 | "No price of its own: scans are charged at cost plus 20%, like everything g1t runs".to_owned(), |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 149 | ], |
| 150 | overage: "Fixes by g1t's agent are charged as agent usage, like any other agent run. Secret scanning, push protection, vulnerability alerts and security updates stay free.".to_owned(), | |
| 151 | } | |
| 152 | } | |
| 153 | ||
| Paid features: a workspace turns on Deployments with a monthly plan | 154 | impl SubscriptionRow { |
| 155 | fn subscription(&self) -> Option<Subscription> { | |
| 156 | Some(Subscription { | |
| 157 | feature: Feature::parse(&self.feature)?, | |
| 158 | status: status_from(&self.status), | |
| 159 | period_end: self.period_end.clone(), | |
| 160 | started_by: self.started_by.clone(), | |
| 161 | started_at: self.started_at.clone(), | |
| 162 | }) | |
| 163 | } | |
| 164 | } | |
| 165 | ||
| 166 | impl Billing { | |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 167 | /// What the g1t plan costs and includes, as it is sold now, at the |
| 168 | /// price book's prices (the same figures as the pricing page's table). | |
| Merge Stripe Tax, the card fee on card payments, and one free workspace per person | 169 | /// With the card fee on top of its monthly price, as it is charged. |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 170 | pub(crate) async fn plan(&self, feature: Feature) -> Result<Plan> { |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 171 | let mut book = std::collections::BTreeMap::new(); |
| Merge Stripe Tax, the card fee on card payments, and one free workspace per person | 172 | let mut plan = if feature == Feature::Security { |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 173 | if let Some((_, price)) = self.price(SECURITY_METER).await? { |
| 174 | book.insert(SECURITY_METER, price); | |
| 175 | } | |
| Merge Stripe Tax, the card fee on card payments, and one free workspace per person | 176 | security_plan_at(&book) |
| 177 | } else { | |
| 178 | for meter in ["build_second", "app_requests", "app_cpu", "custom_domain_month", "private_storage", "git_operations"] { | |
| 179 | if let Some((_, price)) = self.price(meter).await? { | |
| 180 | book.insert(meter, price); | |
| 181 | } | |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 182 | } |
| Merge Stripe Tax, the card fee on card payments, and one free workspace per person | 183 | self.plan_at(&book) |
| 184 | }; | |
| 185 | plan.card_fee_cents = self.card_fee_on(i64::from(plan.monthly_cents)).await? as u32; | |
| 186 | Ok(plan) | |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 187 | } |
| 188 | ||
| 189 | /// The plan at the given prices per unit (micros, after the markup); | |
| 190 | /// the published costs plus the margin for any not given. | |
| 191 | pub(crate) fn plan_at(&self, book: &std::collections::BTreeMap<&str, f64>) -> Plan { | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 192 | let p = &self.plans; |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 193 | let price_of = |meter: &str, cost: i64, units: f64| { |
| 194 | per_units(book.get(meter).copied().unwrap_or_else(|| Price::price_for(cost as f64, self.margin_percent)), units) | |
| 195 | }; | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 196 | Plan { |
| 197 | feature: Feature::Plan, | |
| 198 | title: Feature::Plan.title().to_owned(), | |
| 199 | monthly_cents: p.plan_monthly_cents, | |
| Merge Stripe Tax, the card fee on card payments, and one free workspace per person | 200 | card_fee_cents: 0, |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 201 | includes: vec![ |
| 202 | format!( | |
| 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. | 203 | "{} of usage each month, used first: models at the provider's price with the g1t agent rate, everything else g1t runs at cost plus {}%", |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 204 | dollars(p.plan_included_micros), |
| Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put | 205 | self.margin_percent |
| 206 | ), | |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 207 | "Everyone in the workspace at one price, never per person".to_owned(), |
| 208 | "Unlimited projects, previews and repositories".to_owned(), | |
| 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. | 209 | "Agents, checks, workflows, the merge queue, deployments, semantic search, and Security and quality on private repositories".to_owned(), |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 210 | format!( |
| 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. | 211 | "Usage past {} is charged the same way, up to your spend limit", |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 212 | dollars(p.plan_included_micros), |
| Paid features: a workspace turns on Deployments with a monthly plan | 213 | ), |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 214 | ], |
| 215 | overage: format!( | |
| 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. | 216 | "Everything g1t runs is metered from the first unit at what it costs g1t plus {}%: sandbox time and deploy builds by the second ({} a build minute), {} per million app requests, {} per million CPU milliseconds, {} a month per custom domain, private storage past the free {} at {} per GB-month, and git operations past the free {} a month at {} per 1,000. Unused included usage does not roll over.", |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 217 | self.margin_percent, |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 218 | price_of("build_second", costs::MICROS_PER_BUILD_SECOND, 60.0), |
| 219 | price_of("app_requests", costs::MICROS_PER_MILLION_REQUESTS, 1.0), | |
| 220 | price_of("app_cpu", costs::MICROS_PER_MILLION_CPU_MS, 1.0), | |
| 221 | price_of("custom_domain_month", costs::MICROS_PER_DOMAIN_MONTH, 1.0), | |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 222 | bytes(p.free_storage_bytes), |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 223 | price_of("private_storage", crate::storage::STORAGE_MICROS_PER_GB_MONTH, 1.0), |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 224 | thousands(p.git_included), |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 225 | price_of("git_operations", crate::storage::GIT_MICROS_PER_THOUSAND, 1.0), |
| 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. | 226 | ) + " Models are billed at the provider's price, with no markup, plus the g1t agent rate on their tokens, on your own model keys too. Your own runners cost nothing.", |
| Paid features: a workspace turns on Deployments with a monthly plan | 227 | } |
| 228 | } | |
| 229 | ||
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 230 | /// When the workspace's plan started, and when the period paid for |
| 231 | /// ends: its first billing cycle is the first month. | |
| 232 | pub(crate) async fn plan_cycle(&self, workspace: &str) -> Result<Option<(String, Option<String>)>> { | |
| 233 | let row = match self.current(workspace, Feature::Plan).await? { | |
| 234 | Some(row) => Some(row), | |
| 235 | None => self.current(workspace, Feature::Deployments).await?, | |
| 236 | }; | |
| 237 | Ok(row | |
| 238 | .filter(|row| status_from(&row.status).on()) | |
| 239 | .map(|row| (row.started_at, row.period_end))) | |
| 240 | } | |
| 241 | ||
| Paid features: a workspace turns on Deployments with a monthly plan | 242 | async fn subscription_row(&self, workspace: &str, feature: Feature) -> Result<Option<SubscriptionRow>> { |
| 243 | self.db | |
| 244 | .prepare( | |
| Billing answers every page in a few round trips, not forty: its reads run together, Stripe is asked at most hourly, and the ledger has a time index | 245 | "SELECT feature, subscription_id, status, period_end, started_by, started_at, updated_at |
| Paid features: a workspace turns on Deployments with a monthly plan | 246 | FROM subscriptions WHERE workspace = ? AND feature = ?", |
| 247 | ) | |
| 248 | .bind(&[workspace.into(), feature.as_str().into()])? | |
| 249 | .first::<SubscriptionRow>(None) | |
| 250 | .await | |
| 251 | } | |
| 252 | ||
| 253 | /// Writes down what the processor says about a plan. | |
| Stripe webhooks, enterprise invoices, and sudo for both | 254 | pub(crate) async fn record( |
| Paid features: a workspace turns on Deployments with a monthly plan | 255 | &self, |
| 256 | workspace: &str, | |
| 257 | feature: Feature, | |
| 258 | subscription: &StripeSubscription, | |
| 259 | started_by: &str, | |
| 260 | ) -> Result<()> { | |
| 261 | let now = rfc3339(now_ms()); | |
| 262 | let period_end = subscription.period_end().map(|seconds| rfc3339(seconds.max(0) as u64 * 1000)); | |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 263 | // Whether this plan was on already: the upgrade credit is for starting it. |
| 264 | let was_on = self | |
| 265 | .subscription_row(workspace, feature) | |
| 266 | .await? | |
| 267 | .is_some_and(|row| row.subscription_id == subscription.id && status_from(&row.status).on()); | |
| Paid features: a workspace turns on Deployments with a monthly plan | 268 | self.db |
| 269 | .prepare( | |
| 270 | "INSERT INTO subscriptions | |
| 271 | (workspace, feature, subscription_id, status, period_end, started_by, started_at, updated_at) | |
| 272 | VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?7) | |
| 273 | ON CONFLICT (workspace, feature) DO UPDATE SET | |
| 274 | subscription_id = ?3, status = ?4, period_end = ?5, updated_at = ?7, | |
| 275 | started_by = CASE WHEN subscription_id = ?3 THEN started_by ELSE ?6 END, | |
| 276 | started_at = CASE WHEN subscription_id = ?3 THEN started_at ELSE ?7 END", | |
| 277 | ) | |
| 278 | .bind(&[ | |
| 279 | workspace.into(), | |
| 280 | feature.as_str().into(), | |
| 281 | subscription.id.as_str().into(), | |
| 282 | status_text(status_of(subscription)).into(), | |
| 283 | optional(period_end.as_deref()), | |
| 284 | started_by.into(), | |
| 285 | now.as_str().into(), | |
| 286 | ])? | |
| 287 | .run() | |
| 288 | .await?; | |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 289 | // Starting the paid plan comes with $5 of AI credit, once (ai.rs). |
| 290 | if feature == Feature::Plan && !was_on && status_of(subscription) == SubscriptionStatus::Active { | |
| 291 | self.grant_upgrade_credit(workspace).await?; | |
| 292 | } | |
| Paid features: a workspace turns on Deployments with a monthly plan | 293 | Ok(()) |
| 294 | } | |
| 295 | ||
| 296 | /// A workspace's plan for a feature, asking the processor again once | |
| Billing answers every page in a few round trips, not forty: its reads run together, Stripe is asked at most hourly, and the ledger has a time index | 297 | /// the period it last knew of is over, at most once an hour. |
| Paid features: a workspace turns on Deployments with a monthly plan | 298 | async fn current(&self, workspace: &str, feature: Feature) -> Result<Option<SubscriptionRow>> { |
| 299 | let Some(row) = self.subscription_row(workspace, feature).await? else { | |
| 300 | return Ok(None); | |
| 301 | }; | |
| Billing answers every page in a few round trips, not forty: its reads run together, Stripe is asked at most hourly, and the ledger has a time index | 302 | let stale = needs_refresh(&row.status, row.period_end.as_deref(), &row.updated_at, now_ms()); |
| Paid features: a workspace turns on Deployments with a monthly plan | 303 | if let (true, Some(stripe)) = (stale, &self.stripe) { |
| Project dependencies: addresses, preview stacks, Affects, and agents who know | 304 | match stripe.subscription(&row.subscription_id).await { |
| 305 | Ok(subscription) => self.record(workspace, feature, &subscription, &row.started_by).await?, | |
| 306 | // A plan from another Stripe account: it has ended here. | |
| 307 | Err(error) if is_missing(&error) => { | |
| 308 | self.db | |
| 309 | .prepare("UPDATE subscriptions SET status = 'canceled', updated_at = ? WHERE workspace = ? AND feature = ?") | |
| 310 | .bind(&[rfc3339(now_ms()).into(), workspace.into(), feature.as_str().into()])? | |
| 311 | .run() | |
| 312 | .await?; | |
| 313 | } | |
| 314 | Err(error) => return Err(error), | |
| 315 | } | |
| Paid features: a workspace turns on Deployments with a monthly plan | 316 | return self.subscription_row(workspace, feature).await; |
| 317 | } | |
| 318 | Ok(Some(row)) | |
| 319 | } | |
| 320 | ||
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 321 | /// The plan as a workspace sees it. A Deployments subscription from |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 322 | /// before the plan shows as the plan until its period ends. The |
| 323 | /// Security and quality activation is its own subscription. | |
| 324 | async fn state(&self, workspace: &str, feature: Feature) -> Result<FeatureState> { | |
| 325 | if feature == Feature::Security { | |
| 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. | 326 | // It comes with the plan; an activation still running until |
| 327 | // billing ends it keeps it on meanwhile. | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 328 | let subscription = self.current(workspace, Feature::Security).await?.and_then(|row| row.subscription()); |
| 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. | 329 | let included = self.security_included(workspace).await? || self.has_plan(workspace).await?; |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 330 | return Ok(FeatureState { |
| 331 | plan: self.plan(Feature::Security).await?, | |
| 332 | on: included || self.stripe.is_none() || subscription.as_ref().is_some_and(|s| s.status.on()), | |
| 333 | subscription, | |
| 334 | included, | |
| 335 | }); | |
| 336 | } | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 337 | let subscription = match self.current(workspace, Feature::Plan).await?.and_then(|row| row.subscription()) { |
| 338 | Some(plan) if plan.status.on() => Some(plan), | |
| 339 | plan => self | |
| 340 | .current(workspace, Feature::Deployments) | |
| 341 | .await? | |
| 342 | .and_then(|row| row.subscription()) | |
| 343 | .filter(|legacy| legacy.status.on()) | |
| 344 | .or(plan), | |
| 345 | }; | |
| 346 | let included = self.included(workspace).await?; | |
| Paid features: a workspace turns on Deployments with a monthly plan | 347 | Ok(FeatureState { |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 348 | plan: self.plan(Feature::Plan).await?, |
| Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put | 349 | on: included || self.stripe.is_none() || subscription.as_ref().is_some_and(|s| s.status.on()), |
| Paid features: a workspace turns on Deployments with a monthly plan | 350 | subscription, |
| Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put | 351 | included, |
| Paid features: a workspace turns on Deployments with a monthly plan | 352 | }) |
| 353 | } | |
| 354 | ||
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 355 | /// Whether the plan is on without its price: comped terms, an |
| 356 | /// enterprise's workspaces, or given by g1t staff. | |
| 357 | async fn included(&self, workspace: &str) -> Result<bool> { | |
| Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put | 358 | let account = self.account_of(workspace).await?; |
| Billing: credits with a kind and expiry, discounts instead of comped, and safer charging | 359 | Ok(account.terms.full_discount() |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 360 | || account.kind == g1t_contracts::billing::AccountKind::Enterprise |
| 361 | || account.allowances.plan) | |
| Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put | 362 | } |
| 363 | ||
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 364 | /// Whether the Security and quality activation is on without its |
| 365 | /// price: comped terms, or an enterprise's workspace. Giving the plan | |
| 366 | /// as an allowance does not give the activation. | |
| 367 | async fn security_included(&self, workspace: &str) -> Result<bool> { | |
| 368 | let account = self.account_of(workspace).await?; | |
| 369 | Ok(account.terms.kind == g1t_contracts::billing::TermsKind::Comped | |
| 370 | || account.kind == g1t_contracts::billing::AccountKind::Enterprise) | |
| 371 | } | |
| 372 | ||
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 373 | /// Sets every Deployments subscription from before the plan to end |
| 374 | /// with its period, once, so no one pays for it and the plan both. | |
| 375 | /// Until then it counts as the plan. | |
| 376 | pub(crate) async fn retire_deployments_plans(&self) -> Result<()> { | |
| 377 | let Some(stripe) = &self.stripe else { return Ok(()) }; | |
| 378 | #[derive(Deserialize)] | |
| 379 | struct Legacy { | |
| 380 | workspace: String, | |
| 381 | subscription_id: String, | |
| 382 | started_by: String, | |
| 383 | period_end: Option<String>, | |
| 384 | } | |
| 385 | let legacy = self | |
| 386 | .db | |
| 387 | .prepare( | |
| 388 | "SELECT workspace, subscription_id, started_by, period_end FROM subscriptions | |
| 389 | WHERE feature = 'deployments' AND status = 'active' LIMIT 20", | |
| 390 | ) | |
| 391 | .all() | |
| 392 | .await? | |
| 393 | .results::<Legacy>()?; | |
| 394 | for plan in legacy { | |
| 395 | match stripe.cancel_at_period_end(&plan.subscription_id, true).await { | |
| 396 | Ok(subscription) => { | |
| 397 | self.record(&plan.workspace, Feature::Deployments, &subscription, &plan.started_by).await?; | |
| 398 | let account = self.account_of(&plan.workspace).await?; | |
| 399 | self.audit( | |
| 400 | &account.id, | |
| 401 | "migration", | |
| 402 | &format!( | |
| 403 | "{}: the Deployments plan ends {} and is not renewed; deployments come with the g1t plan now", | |
| 404 | plan.workspace, | |
| 405 | plan.period_end.as_deref().map_or("at the end of its period", |end| &end[..10]) | |
| 406 | ), | |
| 407 | "billing", | |
| 408 | ) | |
| 409 | .await?; | |
| 410 | } | |
| 411 | Err(error) if is_missing(&error) => { | |
| 412 | self.db | |
| 413 | .prepare("UPDATE subscriptions SET status = 'canceled', updated_at = ? WHERE workspace = ? AND feature = 'deployments'") | |
| 414 | .bind(&[rfc3339(now_ms()).into(), plan.workspace.as_str().into()])? | |
| 415 | .run() | |
| 416 | .await?; | |
| 417 | } | |
| 418 | Err(error) => worker::console_error!("could not end {}'s Deployments plan: {error}", plan.workspace), | |
| 419 | } | |
| 420 | } | |
| 421 | Ok(()) | |
| 422 | } | |
| 423 | ||
| 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. | 424 | /// Ends every Security and quality activation at once: it has no price |
| 425 | /// any more and comes with the plan. Stripe ends each subscription now, | |
| 426 | /// without proration or refund; its row is recorded as canceled and the | |
| 427 | /// account's audit log says why. Once none is left, the activation's | |
| 428 | /// product at Stripe is archived, so no price of it can be sold again. | |
| 429 | pub(crate) async fn retire_security_activations(&self) -> Result<()> { | |
| 430 | let Some(stripe) = &self.stripe else { return Ok(()) }; | |
| 431 | #[derive(Deserialize)] | |
| 432 | struct Live { | |
| 433 | workspace: String, | |
| 434 | subscription_id: String, | |
| 435 | started_by: String, | |
| 436 | } | |
| 437 | let live = self | |
| 438 | .db | |
| 439 | .prepare( | |
| 440 | "SELECT workspace, subscription_id, started_by FROM subscriptions | |
| 441 | WHERE feature = 'security' AND status <> 'canceled' LIMIT 20", | |
| 442 | ) | |
| 443 | .all() | |
| 444 | .await? | |
| 445 | .results::<Live>()?; | |
| 446 | let mut left = false; | |
| 447 | for activation in &live { | |
| 448 | match stripe.cancel_now(&activation.subscription_id).await { | |
| 449 | Ok(subscription) => { | |
| 450 | self.record(&activation.workspace, Feature::Security, &subscription, &activation.started_by).await?; | |
| 451 | let account = self.account_of(&activation.workspace).await?; | |
| 452 | self.audit( | |
| 453 | &account.id, | |
| 454 | "migration", | |
| 455 | &format!( | |
| 456 | "{}: the Security and quality activation ended; it comes with the g1t plan now, with no price of its own", | |
| 457 | activation.workspace | |
| 458 | ), | |
| 459 | "billing", | |
| 460 | ) | |
| 461 | .await?; | |
| 462 | } | |
| 463 | Err(error) if is_missing(&error) => { | |
| 464 | self.db | |
| 465 | .prepare("UPDATE subscriptions SET status = 'canceled', updated_at = ? WHERE workspace = ? AND feature = 'security'") | |
| 466 | .bind(&[rfc3339(now_ms()).into(), activation.workspace.as_str().into()])? | |
| 467 | .run() | |
| 468 | .await?; | |
| 469 | } | |
| 470 | Err(error) => { | |
| 471 | left = true; | |
| 472 | worker::console_error!("could not end {}'s Security and quality activation: {error}", activation.workspace); | |
| 473 | } | |
| 474 | } | |
| 475 | } | |
| 476 | if !left && live.len() < 20 { | |
| 477 | if let Err(error) = stripe.archive_product("security").await { | |
| 478 | worker::console_error!("could not archive the Security and quality product: {error}"); | |
| 479 | } | |
| 480 | } | |
| 481 | Ok(()) | |
| 482 | } | |
| 483 | ||
| Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put | 484 | /// Whether the workspace's plan for the feature is paid up. |
| 485 | pub(crate) async fn plan_on(&self, workspace: &str, feature: Feature) -> Result<bool> { | |
| 486 | Ok(self | |
| 487 | .current(workspace, feature) | |
| 488 | .await? | |
| 489 | .and_then(|row| row.subscription()) | |
| 490 | .is_some_and(|s| s.status.on())) | |
| 491 | } | |
| 492 | ||
| Paid features: a workspace turns on Deployments with a monthly plan | 493 | pub(crate) async fn features(&self, a: FeaturesArgs) -> Result<Outcome<Vec<FeatureState>>> { |
| 494 | let workspace = a.workspace.to_lowercase(); | |
| 495 | if !a.viewer.is_some_and(|viewer| viewer.is_member(&workspace)) { | |
| 496 | return Ok(members_only()); | |
| 497 | } | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 498 | let plan = self.state(&workspace, Feature::Plan).await?; |
| 499 | let security = self.state(&workspace, Feature::Security).await?; | |
| 500 | Ok(Outcome::Ok(vec![plan, security])) | |
| Paid features: a workspace turns on Deployments with a monthly plan | 501 | } |
| 502 | ||
| 503 | pub(crate) async fn subscribe(&self, a: SubscribeArgs) -> Result<Outcome<Checkout>> { | |
| 504 | let workspace = a.workspace.to_lowercase(); | |
| Merge main (membership, two-factor, GitHub repo roles) into tokens | 505 | if !a.actor.manages_billing(&workspace) { |
| Paid features: a workspace turns on Deployments with a monthly plan | 506 | return Ok(Outcome::fail( |
| 507 | FailureCode::Forbidden, | |
| Merge main (membership, two-factor, GitHub repo roles) into tokens | 508 | "Only an owner or a billing manager can turn on a paid feature.", |
| Paid features: a workspace turns on Deployments with a monthly plan | 509 | )); |
| 510 | } | |
| 511 | let Some(stripe) = &self.stripe else { | |
| 512 | return Ok(Outcome::fail( | |
| 513 | FailureCode::Conflict, | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 514 | "Payments are not set up on this g1t, so the plan is already on.", |
| Paid features: a workspace turns on Deployments with a monthly plan | 515 | )); |
| 516 | }; | |
| 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. | 517 | // Deployments and Security and quality come with the plan: there is |
| 518 | // nothing of their own to start. | |
| 519 | if a.feature == Feature::Security { | |
| 520 | return Ok(Outcome::fail( | |
| 521 | FailureCode::Conflict, | |
| 522 | format!("Security and quality comes with the g1t plan, with no price of its own. Start the plan at /{workspace}/-/billing."), | |
| 523 | )); | |
| 524 | } | |
| 525 | let feature = Feature::Plan; | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 526 | let name = if feature == Feature::Security { "The Security and quality activation" } else { "The g1t plan" }; |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 527 | let state = self.state(&workspace, feature).await?; |
| Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put | 528 | if state.included { |
| 529 | return Ok(Outcome::fail( | |
| 530 | FailureCode::Conflict, | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 531 | format!("{name} is included for {workspace} already, at no charge."), |
| Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put | 532 | )); |
| 533 | } | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 534 | if self.plan_on(&workspace, feature).await? { |
| 535 | return Ok(Outcome::fail(FailureCode::Conflict, format!("{name} is already on for {workspace}."))); | |
| Paid features: a workspace turns on Deployments with a monthly plan | 536 | } |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 537 | let plan = self.plan(feature).await?; |
| Paid features: a workspace turns on Deployments with a monthly plan | 538 | let customer = self.row(&workspace).await?.and_then(|row| row.customer_id); |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 539 | // The card from the card check, or else the customer's default |
| 540 | // payment method (one added on Stripe's billing page counts): the | |
| 541 | // plan starts on it at once, with no second page. A card that needs | |
| 542 | // the bank's approval again goes through Stripe's page instead. | |
| 543 | let saved = match (customer.as_deref(), self.checked_card(&workspace).await?) { | |
| 544 | (Some(_), Some(method)) => Some(method), | |
| 545 | (Some(customer), None) => match stripe.default_payment_method(customer).await { | |
| 546 | Ok(method) => method.map(|m| m.id), | |
| 547 | Err(error) => { | |
| 548 | worker::console_log!("{workspace}: the default payment method could not be read: {error}"); | |
| 549 | None | |
| 550 | } | |
| 551 | }, | |
| 552 | (None, _) => None, | |
| 553 | }; | |
| 554 | if let (Some(customer), Some(method)) = (customer.as_deref(), saved) { | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 555 | match stripe |
| Merge Stripe Tax, the card fee on card payments, and one free workspace per person | 556 | .subscribe_with_card(&workspace, feature.as_str(), &plan.title, plan.monthly_cents, plan.card_fee_cents, customer, &method) |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 557 | .await |
| 558 | { | |
| 559 | Ok(subscription) if matches!(subscription.status.as_str(), "active" | "trialing") => { | |
| 560 | self.record(&workspace, feature, &subscription, &a.actor.username).await?; | |
| 561 | let account = self.account_of(&workspace).await?; | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 562 | self.audit( |
| 563 | &account.id, | |
| 564 | "plan", | |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 565 | &format!("{workspace}: {} started on the saved card", name.to_lowercase()), |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 566 | &a.actor.username, |
| 567 | ) | |
| 568 | .await?; | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 569 | let separator = if a.return_url.contains('?') { '&' } else { '?' }; |
| 570 | return Ok(Outcome::Ok(Checkout { url: format!("{}{separator}plan=started", a.return_url) })); | |
| 571 | } | |
| 572 | Ok(subscription) => { | |
| 573 | // Incomplete: let it lapse, and use the page. | |
| 574 | let _ = stripe.cancel_now(&subscription.id).await; | |
| 575 | } | |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 576 | Err(error) => worker::console_log!("{workspace}: the plan could not start on the saved card: {error}"), |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 577 | } |
| 578 | } | |
| Project dependencies: addresses, preview stacks, Affects, and agents who know | 579 | let start = |customer: Option<String>| { |
| 580 | let plan = &plan; | |
| 581 | let workspace = &workspace; | |
| 582 | let return_url = &a.return_url; | |
| 583 | async move { | |
| 584 | stripe | |
| 585 | .start_subscription( | |
| 586 | workspace, | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 587 | feature.as_str(), |
| Project dependencies: addresses, preview stacks, Affects, and agents who know | 588 | &plan.title, |
| 589 | plan.monthly_cents, | |
| Merge Stripe Tax, the card fee on card payments, and one free workspace per person | 590 | plan.card_fee_cents, |
| Project dependencies: addresses, preview stacks, Affects, and agents who know | 591 | customer.as_deref(), |
| 592 | return_url, | |
| 593 | ) | |
| 594 | .await | |
| 595 | } | |
| 596 | }; | |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 597 | let started = match start(customer.clone()).await { |
| Project dependencies: addresses, preview stacks, Affects, and agents who know | 598 | // A customer saved under another Stripe account: start afresh. |
| 599 | Err(error) if customer.is_some() && is_missing(&error) => { | |
| 600 | self.forget_customer(&workspace).await?; | |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 601 | start(None).await |
| Project dependencies: addresses, preview stacks, Affects, and agents who know | 602 | } |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 603 | other => other, |
| Project dependencies: addresses, preview stacks, Affects, and agents who know | 604 | }; |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 605 | let session = match started { |
| 606 | Ok(session) => session, | |
| 607 | Err(error) => { | |
| 608 | worker::console_error!("{workspace}: Stripe refused the plan's page: {error}"); | |
| 609 | return Ok(Outcome::fail(FailureCode::Conflict, crate::stripe::friendly(&error))); | |
| 610 | } | |
| Paid features: a workspace turns on Deployments with a monthly plan | 611 | }; |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 612 | let Some(url) = session.url.clone() else { |
| 613 | return Ok(Outcome::fail(FailureCode::Conflict, "Stripe returned no payment page. Try again in a minute.")); | |
| 614 | }; | |
| 615 | if let Err(error) = self | |
| 616 | .record_checkout(&crate::NewCheckout { | |
| 617 | id: &session.id, | |
| 618 | workspace: &workspace, | |
| 619 | amount_cents: plan.monthly_cents, | |
| Merge Stripe Tax, the card fee on card payments, and one free workspace per person | 620 | fee_cents: plan.card_fee_cents, |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 621 | created_by: &a.actor.username, |
| 622 | feature: Some(feature.as_str()), | |
| 623 | }) | |
| 624 | .await | |
| 625 | { | |
| 626 | worker::console_error!("{workspace}: the plan's page could not be recorded: {error}"); | |
| 627 | return Ok(Outcome::fail(FailureCode::Conflict, "g1t could not keep track of the payment page. Nothing was charged; try again.")); | |
| 628 | } | |
| Paid features: a workspace turns on Deployments with a monthly plan | 629 | Ok(Outcome::Ok(Checkout { url })) |
| 630 | } | |
| 631 | ||
| 632 | pub(crate) async fn confirm_subscription( | |
| 633 | &self, | |
| 634 | a: ConfirmSubscriptionArgs, | |
| 635 | ) -> Result<Outcome<FeatureState>> { | |
| 636 | let workspace = a.workspace.to_lowercase(); | |
| 637 | if !a.viewer.is_some_and(|viewer| viewer.is_member(&workspace)) { | |
| 638 | return Ok(members_only()); | |
| 639 | } | |
| 640 | let checkout = self | |
| 641 | .db | |
| 642 | .prepare( | |
| 643 | "SELECT workspace, created_by, feature FROM checkouts | |
| 644 | WHERE id = ? AND workspace = ? AND status = 'open' AND feature IS NOT NULL", | |
| 645 | ) | |
| 646 | .bind(&[a.session.as_str().into(), workspace.as_str().into()])? | |
| 647 | .first::<PlanCheckoutRow>(None) | |
| 648 | .await?; | |
| 649 | let (Some(stripe), Some(checkout)) = (&self.stripe, checkout) else { | |
| 650 | // Unknown, someone else's, or already done: show where it stands. | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 651 | return Ok(Outcome::Ok(self.state(&workspace, Feature::Plan).await?)); |
| Paid features: a workspace turns on Deployments with a monthly plan | 652 | }; |
| 653 | let Some(feature) = Feature::parse(&checkout.feature) else { | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 654 | return Ok(Outcome::fail(FailureCode::NotFound, "No such plan.")); |
| Paid features: a workspace turns on Deployments with a monthly plan | 655 | }; |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 656 | let session = match stripe.session(&a.session).await { |
| 657 | Ok(session) => session, | |
| 658 | Err(error) => return Ok(Outcome::fail(FailureCode::Conflict, crate::stripe::friendly(&error))), | |
| 659 | }; | |
| Paid features: a workspace turns on Deployments with a monthly plan | 660 | if let (Some(subscription_id), true) = (&session.subscription, session.payment_status == "paid") { |
| 661 | let claimed = self | |
| 662 | .db | |
| 663 | .prepare("UPDATE checkouts SET status = 'paid' WHERE id = ? AND status = 'open' RETURNING id") | |
| 664 | .bind(&[a.session.as_str().into()])? | |
| 665 | .first::<Touched>(None) | |
| 666 | .await?; | |
| 667 | if claimed.is_some() { | |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 668 | let subscription = match stripe.subscription(subscription_id).await { |
| 669 | Ok(subscription) => subscription, | |
| 670 | Err(error) => { | |
| 671 | // Let the next look (or the webhook) settle it. | |
| 672 | self.db.prepare("UPDATE checkouts SET status = 'open' WHERE id = ?").bind(&[a.session.as_str().into()])?.run().await?; | |
| 673 | return Ok(Outcome::fail(FailureCode::Conflict, crate::stripe::friendly(&error))); | |
| 674 | } | |
| 675 | }; | |
| Paid features: a workspace turns on Deployments with a monthly plan | 676 | self.record(&checkout.workspace, feature, &subscription, &checkout.created_by) |
| 677 | .await?; | |
| 678 | // Keep the card's customer, so later payments need no retyping. | |
| 679 | self.db | |
| 680 | .prepare( | |
| 681 | "INSERT INTO accounts (workspace, balance_micros, customer_id, created_at) | |
| 682 | VALUES (?1, 0, ?2, ?3) | |
| 683 | ON CONFLICT (workspace) DO UPDATE SET customer_id = COALESCE(customer_id, ?2)", | |
| 684 | ) | |
| 685 | .bind(&[ | |
| 686 | checkout.workspace.as_str().into(), | |
| 687 | optional(session.customer.as_deref()), | |
| 688 | rfc3339(now_ms()).into(), | |
| 689 | ])? | |
| 690 | .run() | |
| 691 | .await?; | |
| 692 | } | |
| 693 | } | |
| 694 | Ok(Outcome::Ok(self.state(&workspace, feature).await?)) | |
| 695 | } | |
| 696 | ||
| 697 | pub(crate) async fn cancel_subscription( | |
| 698 | &self, | |
| 699 | a: CancelSubscriptionArgs, | |
| 700 | ) -> Result<Outcome<FeatureState>> { | |
| 701 | let workspace = a.workspace.to_lowercase(); | |
| Merge main (membership, two-factor, GitHub repo roles) into tokens | 702 | if !a.actor.manages_billing(&workspace) { |
| Paid features: a workspace turns on Deployments with a monthly plan | 703 | return Ok(Outcome::fail( |
| 704 | FailureCode::Forbidden, | |
| Merge main (membership, two-factor, GitHub repo roles) into tokens | 705 | "Only an owner or a billing manager can change the workspace's plan.", |
| Paid features: a workspace turns on Deployments with a monthly plan | 706 | )); |
| 707 | } | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 708 | // The activation; or the plan, or a Deployments subscription from |
| 709 | // before it. | |
| 710 | let row = if a.feature == Feature::Security { | |
| 711 | self.current(&workspace, Feature::Security).await?.map(|row| (Feature::Security, row)) | |
| 712 | } else { | |
| 713 | match self.current(&workspace, Feature::Plan).await? { | |
| 714 | Some(row) if status_from(&row.status) != SubscriptionStatus::Canceled => Some((Feature::Plan, row)), | |
| 715 | _ => self.current(&workspace, Feature::Deployments).await?.map(|row| (Feature::Deployments, row)), | |
| 716 | } | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 717 | }; |
| 718 | let (Some(stripe), Some((feature, row))) = (&self.stripe, row) else { | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 719 | let name = if a.feature == Feature::Security { "The Security and quality activation" } else { "The g1t plan" }; |
| 720 | return Ok(Outcome::fail(FailureCode::NotFound, format!("{name} is not on for {workspace}."))); | |
| Paid features: a workspace turns on Deployments with a monthly plan | 721 | }; |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 722 | let subscription = match stripe.cancel_at_period_end(&row.subscription_id, !a.resume).await { |
| 723 | Ok(subscription) => subscription, | |
| 724 | Err(error) => return Ok(Outcome::fail(FailureCode::Conflict, crate::stripe::friendly(&error))), | |
| 725 | }; | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 726 | self.record(&workspace, feature, &subscription, &row.started_by) |
| Paid features: a workspace turns on Deployments with a monthly plan | 727 | .await?; |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 728 | let shown = if feature == Feature::Security { Feature::Security } else { Feature::Plan }; |
| 729 | Ok(Outcome::Ok(self.state(&workspace, shown).await?)) | |
| Paid features: a workspace turns on Deployments with a monthly plan | 730 | } |
| 731 | ||
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 732 | /// Whether the workspace has the plan, which deployments come with, or |
| 733 | /// the Security and quality activation. | |
| Paid features: a workspace turns on Deployments with a monthly plan | 734 | pub(crate) async fn has_feature(&self, a: HasFeatureArgs) -> Result<Outcome<bool>> { |
| 735 | let workspace = a.workspace.to_lowercase(); | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 736 | if a.feature == Feature::Security { |
| 737 | let state = self.state(&workspace, Feature::Security).await?; | |
| 738 | if state.on { | |
| 739 | return Ok(Outcome::Ok(true)); | |
| 740 | } | |
| 741 | return Ok(Outcome::fail( | |
| 742 | FailureCode::PaymentRequired, | |
| 743 | format!( | |
| 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. | 744 | "Security and quality on private repositories comes with the g1t plan ($20 a month for the workspace, with $10 of usage included; scans at cost plus 20%), and {workspace} does not have it. An owner can start it at /{workspace}/-/billing." |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 745 | ), |
| 746 | )); | |
| 747 | } | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 748 | if self.has_plan(&workspace).await? { |
| Paid features: a workspace turns on Deployments with a monthly plan | 749 | return Ok(Outcome::Ok(true)); |
| 750 | } | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 751 | let what = match a.feature { |
| 752 | Feature::Deployments => "Deployments come with the g1t plan", | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 753 | Feature::Plan | Feature::Security => "This needs the g1t plan", |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 754 | }; |
| Paid features: a workspace turns on Deployments with a monthly plan | 755 | Ok(Outcome::fail( |
| 756 | FailureCode::PaymentRequired, | |
| 757 | format!( | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 758 | "{what} ($20 a month for the workspace, with $10 of usage included), and {workspace} does not have it. An owner can start it at /{workspace}/-/billing." |
| Paid features: a workspace turns on Deployments with a monthly plan | 759 | ), |
| 760 | )) | |
| 761 | } | |
| 762 | ||
| 763 | pub(crate) async fn charge_feature(&self, a: ChargeFeatureArgs) -> Result<Outcome<bool>> { | |
| 764 | if self.stripe.is_none() || a.cost_micros <= 0 { | |
| 765 | return Ok(Outcome::Ok(false)); | |
| 766 | } | |
| 767 | let workspace = a.workspace.to_lowercase(); | |
| 768 | let seen = self | |
| 769 | .db | |
| 770 | .prepare("SELECT id FROM ledger WHERE reference = ?") | |
| 771 | .bind(&[a.reference.as_str().into()])? | |
| 772 | .first::<Touched>(None) | |
| 773 | .await?; | |
| 774 | if seen.is_some() { | |
| 775 | return Ok(Outcome::Ok(false)); | |
| 776 | } | |
| Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put | 777 | let timestamp = rfc3339(now_ms()); |
| 778 | let month = crate::credits::month_of(×tamp); | |
| 779 | let mut description = a.description.clone(); | |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 780 | // A build: every second is metered, at the price book's build |
| 781 | // second, which the keeper keeps at what Cloudflare bills, rather | |
| 782 | // than at what the caller worked out. The month's build time is | |
| 783 | // tallied for the Billing page. | |
| Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put | 784 | let cost_micros = match a.build_seconds.filter(|s| *s > 0 && a.feature == Feature::Deployments) { |
| 785 | Some(seconds) => { | |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 786 | self.tally("build_seconds", &workspace, &month, seconds.into()).await?; |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 787 | let measured = self.price("build_second").await?.map(|(cost, _)| (f64::from(seconds) * cost).ceil() as i64); |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 788 | measured.unwrap_or(a.cost_micros) |
| Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put | 789 | } |
| 790 | None => a.cost_micros, | |
| 791 | }; | |
| Billing accounts, terms and enterprises; g1t is no longer free | 792 | // Never free: the margin applies whatever FREE_WHILE_BUILDING says, |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 793 | // and only the account's terms change it. The plan's included usage |
| 794 | // pays what it can; the trial and the open-source pool never pay for | |
| Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put | 795 | // deployments. |
| Billing: credits with a kind and expiry, discounts instead of comped, and safer charging | 796 | let (charge, discount) = self.terms_of(&workspace).await?.discounted(crate::margin_on(cost_micros, self.margin_percent)); |
| Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put | 797 | let drawn = self.draw(&workspace, charge, &month, &crate::credits::Eligible::default()).await?; |
| 798 | description.push_str(&drawn.note()); | |
| 799 | self.post_usage(crate::storage::UsageLine { | |
| 800 | workspace: &workspace, | |
| 801 | charged: charge - drawn.total(), | |
| 802 | description: &description, | |
| 803 | repo: a.repo.as_deref(), | |
| 804 | task: a.feature.as_str(), | |
| 805 | cost: cost_micros, | |
| 806 | reference: &a.reference, | |
| 807 | created_at: ×tamp, | |
| 808 | drawn, | |
| 809 | }) | |
| 810 | .await?; | |
| Merge branch 'worktree-agent-a633ac0f7f66d419d' | 811 | self.record_discount(&a.reference, discount).await?; |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 812 | self.count_spend(&workspace, cost_micros, charge - drawn.total(), &drawn).await; |
| Paid features: a workspace turns on Deployments with a monthly plan | 813 | Ok(Outcome::Ok(true)) |
| 814 | } | |
| 815 | } | |
| Deployments: a preview for every pull request, production on g1t.page | 816 | |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 817 | /// `50,000`: a count as the plan reads it. |
| 818 | pub(crate) fn thousands(n: u64) -> String { | |
| 819 | let digits = n.to_string(); | |
| 820 | let mut out = String::new(); | |
| 821 | for (i, c) in digits.chars().enumerate() { | |
| 822 | if i > 0 && (digits.len() - i).is_multiple_of(3) { | |
| 823 | out.push(','); | |
| 824 | } | |
| 825 | out.push(c); | |
| Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put | 826 | } |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 827 | out |
| Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put | 828 | } |
| 829 | ||
| Deployments: a preview for every pull request, production on g1t.page | 830 | #[cfg(test)] |
| 831 | mod tests { | |
| 832 | use super::*; | |
| 833 | ||
| 834 | #[test] | |
| Billing answers every page in a few round trips, not forty: its reads run together, Stripe is asked at most hourly, and the ledger has a time index | 835 | fn an_ended_plan_is_asked_about_at_most_once_an_hour() { |
| 836 | let now = 1_791_000_000_000; | |
| 837 | let at = |ago_ms: u64| rfc3339(now - ago_ms); | |
| 838 | let ended = at(24 * 60 * 60 * 1000); | |
| 839 | // Ended, and last written a day ago: ask. | |
| 840 | assert!(needs_refresh("active", Some(&ended), &ended, now)); | |
| 841 | // Ended, but written ten minutes ago: believe the row. | |
| 842 | assert!(!needs_refresh("active", Some(&ended), &at(10 * 60 * 1000), now)); | |
| 843 | // An hour on, ask again. | |
| 844 | assert!(needs_refresh("active", Some(&ended), &at(REFRESH_MS), now)); | |
| 845 | // No period known is the same as ended. | |
| 846 | assert!(needs_refresh("past_due", None, &ended, now)); | |
| 847 | // A period still running, or a canceled plan, is never asked about. | |
| 848 | assert!(!needs_refresh("active", Some(&rfc3339(now + 1000)), &ended, now)); | |
| 849 | assert!(!needs_refresh("canceled", Some(&ended), &ended, now)); | |
| 850 | } | |
| 851 | ||
| 852 | #[test] | |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 853 | fn the_plan_text_quotes_a_build_minute_as_the_table_does() { |
| 854 | // The price book's build second (16.44 millionths at cost, plus | |
| 855 | // 20%) is 19.73 millionths: a minute is 1,184 millionths, $0.0012, | |
| 856 | // as the pricing page's table says. The old fixed cost (15) gave | |
| 857 | // $0.0011. | |
| 858 | let each = Price::price_for(16.439_893_610_418_67, 20); | |
| 859 | assert_eq!(per_units(each, 60.0), "$0.0012"); | |
| 860 | assert_eq!(per_units(Price::price_for(15.0, 20), 60.0), "$0.0011"); | |
| 861 | assert_eq!(per_units(Price::price_for(150_000.0, 20), 1.0), "$0.18"); | |
| 862 | } | |
| 863 | ||
| 864 | #[test] | |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 865 | fn every_build_second_is_metered_at_cost_plus_the_margin() { |
| 866 | // A 5-minute build at 15 millionths a second costs g1t 4,500, and | |
| 867 | // is charged at cost plus 20%, from the first second: there are no | |
| 868 | // included build minutes, only the plan's included usage. | |
| 869 | let cost = 300 * costs::MICROS_PER_BUILD_SECOND; | |
| 870 | assert_eq!(cost, 4_500); | |
| 871 | assert_eq!(crate::credits::with_margin(cost, 20), 5_400); | |
| 872 | } | |
| 873 | ||
| 874 | #[test] | |
| 875 | fn counts_read_with_thousands_separators() { | |
| 876 | assert_eq!(thousands(0), "0"); | |
| 877 | assert_eq!(thousands(999), "999"); | |
| 878 | assert_eq!(thousands(50_000), "50,000"); | |
| 879 | assert_eq!(thousands(1_234_567), "1,234,567"); | |
| Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put | 880 | } |
| 881 | ||
| 882 | #[test] | |
| 883 | fn storage_reads_in_gigabytes() { | |
| 884 | assert_eq!(bytes(1_000_000_000), "1 GB"); | |
| 885 | assert_eq!(bytes(50_000_000_000), "50 GB"); | |
| 886 | assert_eq!(bytes(1_500_000_000), "1.5 GB"); | |
| 887 | assert_eq!(bytes(500_000_000), "500 MB"); | |
| 888 | } | |
| 889 | ||
| 890 | #[test] | |
| Money in billing's messages reads to the cent, and the deploy reads migrations again after a failure | 891 | fn amounts_of_money_read_to_the_cent() { |
| 892 | assert_eq!(cents(3_986_990), "$3.99"); | |
| 893 | assert_eq!(cents(5_000_000), "$5.00"); | |
| 894 | assert_eq!(cents(4_000), "$0.00"); | |
| 895 | } | |
| 896 | ||
| 897 | #[test] | |
| Deployments: a preview for every pull request, production on g1t.page | 898 | fn prices_under_a_cent_keep_their_digits() { |
| 899 | assert_eq!(dollars(1512), "$0.0015"); | |
| 900 | assert_eq!(dollars(24_000), "$0.024"); | |
| 901 | assert_eq!(dollars(360_000), "$0.36"); | |
| 902 | assert_eq!(dollars(5_000_000), "$5.00"); | |
| 903 | } | |
| 904 | } | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 905 | |
| 906 | #[cfg(test)] | |
| 907 | mod security_activation { | |
| 908 | use super::*; | |
| 909 | ||
| 910 | #[test] | |
| 911 | fn the_activation_is_priced_from_the_price_book() { | |
| 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. | 912 | let book = std::collections::BTreeMap::from([(SECURITY_METER, 0.0)]); |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 913 | let plan = security_plan_at(&book); |
| 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. | 914 | assert_eq!((plan.feature, plan.monthly_cents), (Feature::Security, 0)); |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 915 | assert_eq!(plan.title, "Security and quality"); |
| 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. | 916 | // The price book unreadable: no price, as the book says. |
| 917 | assert_eq!(security_plan_at(&std::collections::BTreeMap::new()).monthly_cents, 0); | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 918 | assert!(plan.includes.iter().any(|line| line.contains("public repositories have it free"))); |
| 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. | 919 | assert!(plan.includes.iter().any(|line| line.contains("cost plus 20%"))); |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 920 | assert!(plan.overage.contains("agent usage")); |
| 921 | } | |
| 922 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.