Skip to content

g1t/services/billing/src/features.rs

845 lines39,008 bytesCodeBlame

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 look1//! 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 quotas2//! 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 look10//!
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.
Paid features: a workspace turns on Deployments with a monthly plan16
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas17use g1t_contracts::billing::deployment_costs as costs;
Paid features: a workspace turns on Deployments with a monthly plan18use g1t_contracts::billing::*;
19use g1t_contracts::time::rfc3339;
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put20use g1t_contracts::{FailureCode, Outcome, Role};
Paid features: a workspace turns on Deployments with a monthly plan21use g1t_kit::now_ms;
22use serde::Deserialize;
23use worker::Result;
24
Project dependencies: addresses, preview stacks, Affects, and agents who know25use crate::stripe::{StripeSubscription, is_missing};
Paid features: a workspace turns on Deployments with a monthly plan26use crate::{Billing, Touched, members_only, optional};
27
28#[derive(Deserialize)]
29struct SubscriptionRow {
30 feature: String,
31 subscription_id: String,
32 status: String,
33 period_end: Option<String>,
34 started_by: String,
35 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 index36 updated_at: String,
37}
38
39/// How long a plan's row is believed after it was last written, once its
40/// period is over, before the processor is asked again.
41const REFRESH_MS: u64 = 60 * 60 * 1000;
42
43/// Whether to ask the processor about a plan again: its period is over (or
44/// unknown) and it is not canceled, and it was not written in the last hour.
45/// Without the hour a plan the processor still shows as ended would be
46/// asked about on every page.
47fn needs_refresh(status: &str, period_end: Option<&str>, updated_at: &str, now_ms: u64) -> bool {
48 let now = rfc3339(now_ms);
49 let over = period_end.is_none_or(|end| end <= now.as_str()) && status != "canceled";
50 over && updated_at <= rfc3339(now_ms.saturating_sub(REFRESH_MS)).as_str()
Paid features: a workspace turns on Deployments with a monthly plan51}
52
53#[derive(Deserialize)]
54struct PlanCheckoutRow {
55 workspace: String,
56 created_by: String,
57 feature: String,
58}
59
60fn status_from(text: &str) -> SubscriptionStatus {
61 match text {
62 "active" => SubscriptionStatus::Active,
63 "canceling" => SubscriptionStatus::Canceling,
64 "past_due" => SubscriptionStatus::PastDue,
65 _ => SubscriptionStatus::Canceled,
66 }
67}
68
69fn status_text(status: SubscriptionStatus) -> &'static str {
70 match status {
71 SubscriptionStatus::Active => "active",
72 SubscriptionStatus::Canceling => "canceling",
73 SubscriptionStatus::PastDue => "past_due",
74 SubscriptionStatus::Canceled => "canceled",
75 }
76}
77
78/// What the processor's state for a plan means here.
79fn status_of(subscription: &StripeSubscription) -> SubscriptionStatus {
80 match subscription.status.as_str() {
81 "active" | "trialing" if subscription.cancel_at_period_end => SubscriptionStatus::Canceling,
82 "active" | "trialing" => SubscriptionStatus::Active,
83 "past_due" | "unpaid" | "incomplete" | "paused" => SubscriptionStatus::PastDue,
84 _ => SubscriptionStatus::Canceled,
85 }
86}
87
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put88/// `1 GB`, `50 GB`, or `500 MB`, as storage is priced (powers of ten).
89pub(crate) fn bytes(bytes: i64) -> String {
90 if bytes >= 1_000_000_000 && bytes % 1_000_000_000 == 0 {
91 format!("{} GB", bytes / 1_000_000_000)
92 } else if bytes >= 1_000_000_000 {
93 format!("{:.1} GB", bytes as f64 / 1e9)
94 } else {
95 format!("{} MB", bytes / 1_000_000)
96 }
97}
98
Deployments: a preview for every pull request, production on g1t.page99/// Dollars to the cent, or finer for prices under a cent, so that a
100/// build minute's $0.0015 does not read as nothing.
Usage limits: unpaid usage can only go so far101pub(crate) fn dollars(micros: i64) -> String {
Deployments: a preview for every pull request, production on g1t.page102 let text = format!("{:.4}", micros as f64 / MICROS_PER_DOLLAR as f64);
103 let (whole, fraction) = text.split_once('.').unwrap_or((&text, ""));
104 let fraction = fraction.trim_end_matches('0');
105 format!("${whole}.{fraction:0<2}")
Paid features: a workspace turns on Deployments with a monthly plan106}
107
Money in billing's messages reads to the cent, and the deploy reads migrations again after a failure108/// An amount of money to the cent, as balances and amounts owed read:
109/// `$3.99`, never `$3.987`. Prices use [`dollars`].
110pub(crate) fn cents(micros: i64) -> String {
111 format!("${:.2}", micros as f64 / MICROS_PER_DOLLAR as f64)
112}
113
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily114/// `units` at `each` micros a unit, as the pricing page writes it: a
115/// build second's price times 60 is the build minute both quote.
116pub(crate) fn per_units(each: f64, units: f64) -> String {
117 dollars((each * units).round() as i64)
118}
119
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar120/// The price book's meter for the Security and quality activation.
121pub(crate) const SECURITY_METER: &str = "security_activation";
122/// Its price when the price book cannot be read: $10 a month.
123const SECURITY_FALLBACK_MICROS: f64 = 10_000_000.0;
124
125/// The Security and quality activation at the price book's price.
126pub(crate) fn security_plan_at(book: &std::collections::BTreeMap<&str, f64>) -> Plan {
127 let micros = book.get(SECURITY_METER).copied().unwrap_or(SECURITY_FALLBACK_MICROS);
128 Plan {
129 feature: Feature::Security,
130 title: Feature::Security.title().to_owned(),
131 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 person132 card_fee_cents: 0,
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar133 includes: vec![
134 "For every private repository in the workspace; public repositories have it free".to_owned(),
135 "Custom secret patterns, validity checks with issuers, and delegated push protection bypass".to_owned(),
136 "Code scanning from SARIF, with pull request checks that can block merges".to_owned(),
137 "Dependency review on pull requests, and the workspace's security overview".to_owned(),
138 "Everyone in the workspace at one price, never per person".to_owned(),
139 ],
140 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(),
141 }
142}
143
Paid features: a workspace turns on Deployments with a monthly plan144impl SubscriptionRow {
145 fn subscription(&self) -> Option<Subscription> {
146 Some(Subscription {
147 feature: Feature::parse(&self.feature)?,
148 status: status_from(&self.status),
149 period_end: self.period_end.clone(),
150 started_by: self.started_by.clone(),
151 started_at: self.started_at.clone(),
152 })
153 }
154}
155
156impl Billing {
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily157 /// What the g1t plan costs and includes, as it is sold now, at the
158 /// 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 person159 /// 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 bar160 pub(crate) async fn plan(&self, feature: Feature) -> Result<Plan> {
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily161 let mut book = std::collections::BTreeMap::new();
Merge Stripe Tax, the card fee on card payments, and one free workspace per person162 let mut plan = if feature == Feature::Security {
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar163 if let Some((_, price)) = self.price(SECURITY_METER).await? {
164 book.insert(SECURITY_METER, price);
165 }
Merge Stripe Tax, the card fee on card payments, and one free workspace per person166 security_plan_at(&book)
167 } else {
168 for meter in ["build_second", "app_requests", "app_cpu", "custom_domain_month", "private_storage", "git_operations"] {
169 if let Some((_, price)) = self.price(meter).await? {
170 book.insert(meter, price);
171 }
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily172 }
Merge Stripe Tax, the card fee on card payments, and one free workspace per person173 self.plan_at(&book)
174 };
175 plan.card_fee_cents = self.card_fee_on(i64::from(plan.monthly_cents)).await? as u32;
176 Ok(plan)
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily177 }
178
179 /// The plan at the given prices per unit (micros, after the markup);
180 /// the published costs plus the margin for any not given.
181 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 look182 let p = &self.plans;
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily183 let price_of = |meter: &str, cost: i64, units: f64| {
184 per_units(book.get(meter).copied().unwrap_or_else(|| Price::price_for(cost as f64, self.margin_percent)), units)
185 };
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look186 Plan {
187 feature: Feature::Plan,
188 title: Feature::Plan.title().to_owned(),
189 monthly_cents: p.plan_monthly_cents,
Merge Stripe Tax, the card fee on card payments, and one free workspace per person190 card_fee_cents: 0,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look191 includes: vec![
192 format!(
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas193 "{} of usage each month at cost plus {}%, used first",
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look194 dollars(p.plan_included_micros),
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put195 self.margin_percent
196 ),
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas197 "Everyone in the workspace at one price, never per person".to_owned(),
198 "Unlimited projects, previews and repositories".to_owned(),
199 "Agents, checks, workflows, the merge queue, deployments and semantic search".to_owned(),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look200 format!(
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas201 "Usage past {} is charged at cost plus {}%, up to your spend limit",
202 dollars(p.plan_included_micros),
203 self.margin_percent
Paid features: a workspace turns on Deployments with a monthly plan204 ),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look205 ],
206 overage: format!(
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas207 "Everything is metered from the first unit at what it costs g1t plus {}%: sandbox time and deploy builds by the second ({} a build minute), models at what the provider charged, {} 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 look208 self.margin_percent,
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily209 price_of("build_second", costs::MICROS_PER_BUILD_SECOND, 60.0),
210 price_of("app_requests", costs::MICROS_PER_MILLION_REQUESTS, 1.0),
211 price_of("app_cpu", costs::MICROS_PER_MILLION_CPU_MS, 1.0),
212 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 quotas213 bytes(p.free_storage_bytes),
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily214 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 quotas215 thousands(p.git_included),
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily216 price_of("git_operations", crate::storage::GIT_MICROS_PER_THOUSAND, 1.0),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look217 ),
Paid features: a workspace turns on Deployments with a monthly plan218 }
219 }
220
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look221 /// When the workspace's plan started, and when the period paid for
222 /// ends: its first billing cycle is the first month.
223 pub(crate) async fn plan_cycle(&self, workspace: &str) -> Result<Option<(String, Option<String>)>> {
224 let row = match self.current(workspace, Feature::Plan).await? {
225 Some(row) => Some(row),
226 None => self.current(workspace, Feature::Deployments).await?,
227 };
228 Ok(row
229 .filter(|row| status_from(&row.status).on())
230 .map(|row| (row.started_at, row.period_end)))
231 }
232
Paid features: a workspace turns on Deployments with a monthly plan233 async fn subscription_row(&self, workspace: &str, feature: Feature) -> Result<Option<SubscriptionRow>> {
234 self.db
235 .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 index236 "SELECT feature, subscription_id, status, period_end, started_by, started_at, updated_at
Paid features: a workspace turns on Deployments with a monthly plan237 FROM subscriptions WHERE workspace = ? AND feature = ?",
238 )
239 .bind(&[workspace.into(), feature.as_str().into()])?
240 .first::<SubscriptionRow>(None)
241 .await
242 }
243
244 /// Writes down what the processor says about a plan.
Stripe webhooks, enterprise invoices, and sudo for both245 pub(crate) async fn record(
Paid features: a workspace turns on Deployments with a monthly plan246 &self,
247 workspace: &str,
248 feature: Feature,
249 subscription: &StripeSubscription,
250 started_by: &str,
251 ) -> Result<()> {
252 let now = rfc3339(now_ms());
253 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 audit254 // Whether this plan was on already: the upgrade credit is for starting it.
255 let was_on = self
256 .subscription_row(workspace, feature)
257 .await?
258 .is_some_and(|row| row.subscription_id == subscription.id && status_from(&row.status).on());
Paid features: a workspace turns on Deployments with a monthly plan259 self.db
260 .prepare(
261 "INSERT INTO subscriptions
262 (workspace, feature, subscription_id, status, period_end, started_by, started_at, updated_at)
263 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?7)
264 ON CONFLICT (workspace, feature) DO UPDATE SET
265 subscription_id = ?3, status = ?4, period_end = ?5, updated_at = ?7,
266 started_by = CASE WHEN subscription_id = ?3 THEN started_by ELSE ?6 END,
267 started_at = CASE WHEN subscription_id = ?3 THEN started_at ELSE ?7 END",
268 )
269 .bind(&[
270 workspace.into(),
271 feature.as_str().into(),
272 subscription.id.as_str().into(),
273 status_text(status_of(subscription)).into(),
274 optional(period_end.as_deref()),
275 started_by.into(),
276 now.as_str().into(),
277 ])?
278 .run()
279 .await?;
Usage, Billing settings and prepaid AI credit; fixes from the UX audit280 // Starting the paid plan comes with $5 of AI credit, once (ai.rs).
281 if feature == Feature::Plan && !was_on && status_of(subscription) == SubscriptionStatus::Active {
282 self.grant_upgrade_credit(workspace).await?;
283 }
Paid features: a workspace turns on Deployments with a monthly plan284 Ok(())
285 }
286
287 /// 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 index288 /// the period it last knew of is over, at most once an hour.
Paid features: a workspace turns on Deployments with a monthly plan289 async fn current(&self, workspace: &str, feature: Feature) -> Result<Option<SubscriptionRow>> {
290 let Some(row) = self.subscription_row(workspace, feature).await? else {
291 return Ok(None);
292 };
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 index293 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 plan294 if let (true, Some(stripe)) = (stale, &self.stripe) {
Project dependencies: addresses, preview stacks, Affects, and agents who know295 match stripe.subscription(&row.subscription_id).await {
296 Ok(subscription) => self.record(workspace, feature, &subscription, &row.started_by).await?,
297 // A plan from another Stripe account: it has ended here.
298 Err(error) if is_missing(&error) => {
299 self.db
300 .prepare("UPDATE subscriptions SET status = 'canceled', updated_at = ? WHERE workspace = ? AND feature = ?")
301 .bind(&[rfc3339(now_ms()).into(), workspace.into(), feature.as_str().into()])?
302 .run()
303 .await?;
304 }
305 Err(error) => return Err(error),
306 }
Paid features: a workspace turns on Deployments with a monthly plan307 return self.subscription_row(workspace, feature).await;
308 }
309 Ok(Some(row))
310 }
311
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look312 /// 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 bar313 /// before the plan shows as the plan until its period ends. The
314 /// Security and quality activation is its own subscription.
315 async fn state(&self, workspace: &str, feature: Feature) -> Result<FeatureState> {
316 if feature == Feature::Security {
317 let subscription = self.current(workspace, Feature::Security).await?.and_then(|row| row.subscription());
318 let included = self.security_included(workspace).await?;
319 return Ok(FeatureState {
320 plan: self.plan(Feature::Security).await?,
321 on: included || self.stripe.is_none() || subscription.as_ref().is_some_and(|s| s.status.on()),
322 subscription,
323 included,
324 });
325 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look326 let subscription = match self.current(workspace, Feature::Plan).await?.and_then(|row| row.subscription()) {
327 Some(plan) if plan.status.on() => Some(plan),
328 plan => self
329 .current(workspace, Feature::Deployments)
330 .await?
331 .and_then(|row| row.subscription())
332 .filter(|legacy| legacy.status.on())
333 .or(plan),
334 };
335 let included = self.included(workspace).await?;
Paid features: a workspace turns on Deployments with a monthly plan336 Ok(FeatureState {
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily337 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 put338 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 plan339 subscription,
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put340 included,
Paid features: a workspace turns on Deployments with a monthly plan341 })
342 }
343
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look344 /// Whether the plan is on without its price: comped terms, an
345 /// enterprise's workspaces, or given by g1t staff.
346 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 put347 let account = self.account_of(workspace).await?;
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging348 Ok(account.terms.full_discount()
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look349 || account.kind == g1t_contracts::billing::AccountKind::Enterprise
350 || account.allowances.plan)
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put351 }
352
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar353 /// Whether the Security and quality activation is on without its
354 /// price: comped terms, or an enterprise's workspace. Giving the plan
355 /// as an allowance does not give the activation.
356 async fn security_included(&self, workspace: &str) -> Result<bool> {
357 let account = self.account_of(workspace).await?;
358 Ok(account.terms.kind == g1t_contracts::billing::TermsKind::Comped
359 || account.kind == g1t_contracts::billing::AccountKind::Enterprise)
360 }
361
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look362 /// Sets every Deployments subscription from before the plan to end
363 /// with its period, once, so no one pays for it and the plan both.
364 /// Until then it counts as the plan.
365 pub(crate) async fn retire_deployments_plans(&self) -> Result<()> {
366 let Some(stripe) = &self.stripe else { return Ok(()) };
367 #[derive(Deserialize)]
368 struct Legacy {
369 workspace: String,
370 subscription_id: String,
371 started_by: String,
372 period_end: Option<String>,
373 }
374 let legacy = self
375 .db
376 .prepare(
377 "SELECT workspace, subscription_id, started_by, period_end FROM subscriptions
378 WHERE feature = 'deployments' AND status = 'active' LIMIT 20",
379 )
380 .all()
381 .await?
382 .results::<Legacy>()?;
383 for plan in legacy {
384 match stripe.cancel_at_period_end(&plan.subscription_id, true).await {
385 Ok(subscription) => {
386 self.record(&plan.workspace, Feature::Deployments, &subscription, &plan.started_by).await?;
387 let account = self.account_of(&plan.workspace).await?;
388 self.audit(
389 &account.id,
390 "migration",
391 &format!(
392 "{}: the Deployments plan ends {} and is not renewed; deployments come with the g1t plan now",
393 plan.workspace,
394 plan.period_end.as_deref().map_or("at the end of its period", |end| &end[..10])
395 ),
396 "billing",
397 )
398 .await?;
399 }
400 Err(error) if is_missing(&error) => {
401 self.db
402 .prepare("UPDATE subscriptions SET status = 'canceled', updated_at = ? WHERE workspace = ? AND feature = 'deployments'")
403 .bind(&[rfc3339(now_ms()).into(), plan.workspace.as_str().into()])?
404 .run()
405 .await?;
406 }
407 Err(error) => worker::console_error!("could not end {}'s Deployments plan: {error}", plan.workspace),
408 }
409 }
410 Ok(())
411 }
412
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put413 /// Whether the workspace's plan for the feature is paid up.
414 pub(crate) async fn plan_on(&self, workspace: &str, feature: Feature) -> Result<bool> {
415 Ok(self
416 .current(workspace, feature)
417 .await?
418 .and_then(|row| row.subscription())
419 .is_some_and(|s| s.status.on()))
420 }
421
Paid features: a workspace turns on Deployments with a monthly plan422 pub(crate) async fn features(&self, a: FeaturesArgs) -> Result<Outcome<Vec<FeatureState>>> {
423 let workspace = a.workspace.to_lowercase();
424 if !a.viewer.is_some_and(|viewer| viewer.is_member(&workspace)) {
425 return Ok(members_only());
426 }
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar427 let plan = self.state(&workspace, Feature::Plan).await?;
428 let security = self.state(&workspace, Feature::Security).await?;
429 Ok(Outcome::Ok(vec![plan, security]))
Paid features: a workspace turns on Deployments with a monthly plan430 }
431
432 pub(crate) async fn subscribe(&self, a: SubscribeArgs) -> Result<Outcome<Checkout>> {
433 let workspace = a.workspace.to_lowercase();
434 if a.actor.role_in(&workspace) != Some(Role::Owner) {
435 return Ok(Outcome::fail(
436 FailureCode::Forbidden,
437 "Only an owner can turn on a paid feature.",
438 ));
439 }
440 let Some(stripe) = &self.stripe else {
441 return Ok(Outcome::fail(
442 FailureCode::Conflict,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look443 "Payments are not set up on this g1t, so the plan is already on.",
Paid features: a workspace turns on Deployments with a monthly plan444 ));
445 };
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look446 // Deployments come with the plan: asking for them starts the plan.
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar447 // The Security and quality activation is its own subscription.
448 let feature = if a.feature == Feature::Security { Feature::Security } else { Feature::Plan };
449 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 look450 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 put451 if state.included {
452 return Ok(Outcome::fail(
453 FailureCode::Conflict,
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar454 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 put455 ));
456 }
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar457 if self.plan_on(&workspace, feature).await? {
458 return Ok(Outcome::fail(FailureCode::Conflict, format!("{name} is already on for {workspace}.")));
Paid features: a workspace turns on Deployments with a monthly plan459 }
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily460 let plan = self.plan(feature).await?;
Paid features: a workspace turns on Deployments with a monthly plan461 let customer = self.row(&workspace).await?.and_then(|row| row.customer_id);
Usage, Billing settings and prepaid AI credit; fixes from the UX audit462 // The card from the card check, or else the customer's default
463 // payment method (one added on Stripe's billing page counts): the
464 // plan starts on it at once, with no second page. A card that needs
465 // the bank's approval again goes through Stripe's page instead.
466 let saved = match (customer.as_deref(), self.checked_card(&workspace).await?) {
467 (Some(_), Some(method)) => Some(method),
468 (Some(customer), None) => match stripe.default_payment_method(customer).await {
469 Ok(method) => method.map(|m| m.id),
470 Err(error) => {
471 worker::console_log!("{workspace}: the default payment method could not be read: {error}");
472 None
473 }
474 },
475 (None, _) => None,
476 };
477 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 look478 match stripe
Merge Stripe Tax, the card fee on card payments, and one free workspace per person479 .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 look480 .await
481 {
482 Ok(subscription) if matches!(subscription.status.as_str(), "active" | "trialing") => {
483 self.record(&workspace, feature, &subscription, &a.actor.username).await?;
484 let account = self.account_of(&workspace).await?;
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar485 self.audit(
486 &account.id,
487 "plan",
Usage, Billing settings and prepaid AI credit; fixes from the UX audit488 &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 bar489 &a.actor.username,
490 )
491 .await?;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look492 let separator = if a.return_url.contains('?') { '&' } else { '?' };
493 return Ok(Outcome::Ok(Checkout { url: format!("{}{separator}plan=started", a.return_url) }));
494 }
495 Ok(subscription) => {
496 // Incomplete: let it lapse, and use the page.
497 let _ = stripe.cancel_now(&subscription.id).await;
498 }
Usage, Billing settings and prepaid AI credit; fixes from the UX audit499 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 look500 }
501 }
Project dependencies: addresses, preview stacks, Affects, and agents who know502 let start = |customer: Option<String>| {
503 let plan = &plan;
504 let workspace = &workspace;
505 let return_url = &a.return_url;
506 async move {
507 stripe
508 .start_subscription(
509 workspace,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look510 feature.as_str(),
Project dependencies: addresses, preview stacks, Affects, and agents who know511 &plan.title,
512 plan.monthly_cents,
Merge Stripe Tax, the card fee on card payments, and one free workspace per person513 plan.card_fee_cents,
Project dependencies: addresses, preview stacks, Affects, and agents who know514 customer.as_deref(),
515 return_url,
516 )
517 .await
518 }
519 };
Usage, Billing settings and prepaid AI credit; fixes from the UX audit520 let started = match start(customer.clone()).await {
Project dependencies: addresses, preview stacks, Affects, and agents who know521 // A customer saved under another Stripe account: start afresh.
522 Err(error) if customer.is_some() && is_missing(&error) => {
523 self.forget_customer(&workspace).await?;
Usage, Billing settings and prepaid AI credit; fixes from the UX audit524 start(None).await
Project dependencies: addresses, preview stacks, Affects, and agents who know525 }
Usage, Billing settings and prepaid AI credit; fixes from the UX audit526 other => other,
Project dependencies: addresses, preview stacks, Affects, and agents who know527 };
Usage, Billing settings and prepaid AI credit; fixes from the UX audit528 let session = match started {
529 Ok(session) => session,
530 Err(error) => {
531 worker::console_error!("{workspace}: Stripe refused the plan's page: {error}");
532 return Ok(Outcome::fail(FailureCode::Conflict, crate::stripe::friendly(&error)));
533 }
Paid features: a workspace turns on Deployments with a monthly plan534 };
Usage, Billing settings and prepaid AI credit; fixes from the UX audit535 let Some(url) = session.url.clone() else {
536 return Ok(Outcome::fail(FailureCode::Conflict, "Stripe returned no payment page. Try again in a minute."));
537 };
538 if let Err(error) = self
539 .record_checkout(&crate::NewCheckout {
540 id: &session.id,
541 workspace: &workspace,
542 amount_cents: plan.monthly_cents,
Merge Stripe Tax, the card fee on card payments, and one free workspace per person543 fee_cents: plan.card_fee_cents,
Usage, Billing settings and prepaid AI credit; fixes from the UX audit544 created_by: &a.actor.username,
545 feature: Some(feature.as_str()),
546 })
547 .await
548 {
549 worker::console_error!("{workspace}: the plan's page could not be recorded: {error}");
550 return Ok(Outcome::fail(FailureCode::Conflict, "g1t could not keep track of the payment page. Nothing was charged; try again."));
551 }
Paid features: a workspace turns on Deployments with a monthly plan552 Ok(Outcome::Ok(Checkout { url }))
553 }
554
555 pub(crate) async fn confirm_subscription(
556 &self,
557 a: ConfirmSubscriptionArgs,
558 ) -> Result<Outcome<FeatureState>> {
559 let workspace = a.workspace.to_lowercase();
560 if !a.viewer.is_some_and(|viewer| viewer.is_member(&workspace)) {
561 return Ok(members_only());
562 }
563 let checkout = self
564 .db
565 .prepare(
566 "SELECT workspace, created_by, feature FROM checkouts
567 WHERE id = ? AND workspace = ? AND status = 'open' AND feature IS NOT NULL",
568 )
569 .bind(&[a.session.as_str().into(), workspace.as_str().into()])?
570 .first::<PlanCheckoutRow>(None)
571 .await?;
572 let (Some(stripe), Some(checkout)) = (&self.stripe, checkout) else {
573 // 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 look574 return Ok(Outcome::Ok(self.state(&workspace, Feature::Plan).await?));
Paid features: a workspace turns on Deployments with a monthly plan575 };
576 let Some(feature) = Feature::parse(&checkout.feature) else {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look577 return Ok(Outcome::fail(FailureCode::NotFound, "No such plan."));
Paid features: a workspace turns on Deployments with a monthly plan578 };
Usage, Billing settings and prepaid AI credit; fixes from the UX audit579 let session = match stripe.session(&a.session).await {
580 Ok(session) => session,
581 Err(error) => return Ok(Outcome::fail(FailureCode::Conflict, crate::stripe::friendly(&error))),
582 };
Paid features: a workspace turns on Deployments with a monthly plan583 if let (Some(subscription_id), true) = (&session.subscription, session.payment_status == "paid") {
584 let claimed = self
585 .db
586 .prepare("UPDATE checkouts SET status = 'paid' WHERE id = ? AND status = 'open' RETURNING id")
587 .bind(&[a.session.as_str().into()])?
588 .first::<Touched>(None)
589 .await?;
590 if claimed.is_some() {
Usage, Billing settings and prepaid AI credit; fixes from the UX audit591 let subscription = match stripe.subscription(subscription_id).await {
592 Ok(subscription) => subscription,
593 Err(error) => {
594 // Let the next look (or the webhook) settle it.
595 self.db.prepare("UPDATE checkouts SET status = 'open' WHERE id = ?").bind(&[a.session.as_str().into()])?.run().await?;
596 return Ok(Outcome::fail(FailureCode::Conflict, crate::stripe::friendly(&error)));
597 }
598 };
Paid features: a workspace turns on Deployments with a monthly plan599 self.record(&checkout.workspace, feature, &subscription, &checkout.created_by)
600 .await?;
601 // Keep the card's customer, so later payments need no retyping.
602 self.db
603 .prepare(
604 "INSERT INTO accounts (workspace, balance_micros, customer_id, created_at)
605 VALUES (?1, 0, ?2, ?3)
606 ON CONFLICT (workspace) DO UPDATE SET customer_id = COALESCE(customer_id, ?2)",
607 )
608 .bind(&[
609 checkout.workspace.as_str().into(),
610 optional(session.customer.as_deref()),
611 rfc3339(now_ms()).into(),
612 ])?
613 .run()
614 .await?;
615 }
616 }
617 Ok(Outcome::Ok(self.state(&workspace, feature).await?))
618 }
619
620 pub(crate) async fn cancel_subscription(
621 &self,
622 a: CancelSubscriptionArgs,
623 ) -> Result<Outcome<FeatureState>> {
624 let workspace = a.workspace.to_lowercase();
625 if a.actor.role_in(&workspace) != Some(Role::Owner) {
626 return Ok(Outcome::fail(
627 FailureCode::Forbidden,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look628 "Only an owner can change the workspace's plan.",
Paid features: a workspace turns on Deployments with a monthly plan629 ));
630 }
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar631 // The activation; or the plan, or a Deployments subscription from
632 // before it.
633 let row = if a.feature == Feature::Security {
634 self.current(&workspace, Feature::Security).await?.map(|row| (Feature::Security, row))
635 } else {
636 match self.current(&workspace, Feature::Plan).await? {
637 Some(row) if status_from(&row.status) != SubscriptionStatus::Canceled => Some((Feature::Plan, row)),
638 _ => self.current(&workspace, Feature::Deployments).await?.map(|row| (Feature::Deployments, row)),
639 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look640 };
641 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 bar642 let name = if a.feature == Feature::Security { "The Security and quality activation" } else { "The g1t plan" };
643 return Ok(Outcome::fail(FailureCode::NotFound, format!("{name} is not on for {workspace}.")));
Paid features: a workspace turns on Deployments with a monthly plan644 };
Usage, Billing settings and prepaid AI credit; fixes from the UX audit645 let subscription = match stripe.cancel_at_period_end(&row.subscription_id, !a.resume).await {
646 Ok(subscription) => subscription,
647 Err(error) => return Ok(Outcome::fail(FailureCode::Conflict, crate::stripe::friendly(&error))),
648 };
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look649 self.record(&workspace, feature, &subscription, &row.started_by)
Paid features: a workspace turns on Deployments with a monthly plan650 .await?;
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar651 let shown = if feature == Feature::Security { Feature::Security } else { Feature::Plan };
652 Ok(Outcome::Ok(self.state(&workspace, shown).await?))
Paid features: a workspace turns on Deployments with a monthly plan653 }
654
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar655 /// Whether the workspace has the plan, which deployments come with, or
656 /// the Security and quality activation.
Paid features: a workspace turns on Deployments with a monthly plan657 pub(crate) async fn has_feature(&self, a: HasFeatureArgs) -> Result<Outcome<bool>> {
658 let workspace = a.workspace.to_lowercase();
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar659 if a.feature == Feature::Security {
660 let state = self.state(&workspace, Feature::Security).await?;
661 if state.on {
662 return Ok(Outcome::Ok(true));
663 }
664 return Ok(Outcome::fail(
665 FailureCode::PaymentRequired,
666 format!(
667 "This needs the Security and quality activation ({} a month for the workspace), and {workspace} does not have it. An owner can turn it on at /{workspace}/-/billing.",
668 dollars(i64::from(state.plan.monthly_cents) * 10_000)
669 ),
670 ));
671 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look672 if self.has_plan(&workspace).await? {
Paid features: a workspace turns on Deployments with a monthly plan673 return Ok(Outcome::Ok(true));
674 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look675 let what = match a.feature {
676 Feature::Deployments => "Deployments come with the g1t plan",
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar677 Feature::Plan | Feature::Security => "This needs the g1t plan",
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look678 };
Paid features: a workspace turns on Deployments with a monthly plan679 Ok(Outcome::fail(
680 FailureCode::PaymentRequired,
681 format!(
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look682 "{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 plan683 ),
684 ))
685 }
686
687 pub(crate) async fn charge_feature(&self, a: ChargeFeatureArgs) -> Result<Outcome<bool>> {
688 if self.stripe.is_none() || a.cost_micros <= 0 {
689 return Ok(Outcome::Ok(false));
690 }
691 let workspace = a.workspace.to_lowercase();
692 let seen = self
693 .db
694 .prepare("SELECT id FROM ledger WHERE reference = ?")
695 .bind(&[a.reference.as_str().into()])?
696 .first::<Touched>(None)
697 .await?;
698 if seen.is_some() {
699 return Ok(Outcome::Ok(false));
700 }
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put701 let timestamp = rfc3339(now_ms());
702 let month = crate::credits::month_of(&timestamp);
703 let mut description = a.description.clone();
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas704 // A build: every second is metered, at the price book's build
705 // second, which the keeper keeps at what Cloudflare bills, rather
706 // than at what the caller worked out. The month's build time is
707 // tallied for the Billing page.
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put708 let cost_micros = match a.build_seconds.filter(|s| *s > 0 && a.feature == Feature::Deployments) {
709 Some(seconds) => {
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas710 self.tally("build_seconds", &workspace, &month, seconds.into()).await?;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look711 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 quotas712 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 put713 }
714 None => a.cost_micros,
715 };
Billing accounts, terms and enterprises; g1t is no longer free716 // 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 look717 // and only the account's terms change it. The plan's included usage
718 // 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 put719 // deployments.
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging720 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 put721 let drawn = self.draw(&workspace, charge, &month, &crate::credits::Eligible::default()).await?;
722 description.push_str(&drawn.note());
723 self.post_usage(crate::storage::UsageLine {
724 workspace: &workspace,
725 charged: charge - drawn.total(),
726 description: &description,
727 repo: a.repo.as_deref(),
728 task: a.feature.as_str(),
729 cost: cost_micros,
730 reference: &a.reference,
731 created_at: &timestamp,
732 drawn,
733 })
734 .await?;
Merge branch 'worktree-agent-a633ac0f7f66d419d'735 self.record_discount(&a.reference, discount).await?;
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays736 self.count_spend(&workspace, cost_micros, charge - drawn.total(), &drawn).await;
Paid features: a workspace turns on Deployments with a monthly plan737 Ok(Outcome::Ok(true))
738 }
739}
Deployments: a preview for every pull request, production on g1t.page740
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas741/// `50,000`: a count as the plan reads it.
742pub(crate) fn thousands(n: u64) -> String {
743 let digits = n.to_string();
744 let mut out = String::new();
745 for (i, c) in digits.chars().enumerate() {
746 if i > 0 && (digits.len() - i).is_multiple_of(3) {
747 out.push(',');
748 }
749 out.push(c);
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put750 }
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas751 out
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put752}
753
Deployments: a preview for every pull request, production on g1t.page754#[cfg(test)]
755mod tests {
756 use super::*;
757
758 #[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 index759 fn an_ended_plan_is_asked_about_at_most_once_an_hour() {
760 let now = 1_791_000_000_000;
761 let at = |ago_ms: u64| rfc3339(now - ago_ms);
762 let ended = at(24 * 60 * 60 * 1000);
763 // Ended, and last written a day ago: ask.
764 assert!(needs_refresh("active", Some(&ended), &ended, now));
765 // Ended, but written ten minutes ago: believe the row.
766 assert!(!needs_refresh("active", Some(&ended), &at(10 * 60 * 1000), now));
767 // An hour on, ask again.
768 assert!(needs_refresh("active", Some(&ended), &at(REFRESH_MS), now));
769 // No period known is the same as ended.
770 assert!(needs_refresh("past_due", None, &ended, now));
771 // A period still running, or a canceled plan, is never asked about.
772 assert!(!needs_refresh("active", Some(&rfc3339(now + 1000)), &ended, now));
773 assert!(!needs_refresh("canceled", Some(&ended), &ended, now));
774 }
775
776 #[test]
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily777 fn the_plan_text_quotes_a_build_minute_as_the_table_does() {
778 // The price book's build second (16.44 millionths at cost, plus
779 // 20%) is 19.73 millionths: a minute is 1,184 millionths, $0.0012,
780 // as the pricing page's table says. The old fixed cost (15) gave
781 // $0.0011.
782 let each = Price::price_for(16.439_893_610_418_67, 20);
783 assert_eq!(per_units(each, 60.0), "$0.0012");
784 assert_eq!(per_units(Price::price_for(15.0, 20), 60.0), "$0.0011");
785 assert_eq!(per_units(Price::price_for(150_000.0, 20), 1.0), "$0.18");
786 }
787
788 #[test]
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas789 fn every_build_second_is_metered_at_cost_plus_the_margin() {
790 // A 5-minute build at 15 millionths a second costs g1t 4,500, and
791 // is charged at cost plus 20%, from the first second: there are no
792 // included build minutes, only the plan's included usage.
793 let cost = 300 * costs::MICROS_PER_BUILD_SECOND;
794 assert_eq!(cost, 4_500);
795 assert_eq!(crate::credits::with_margin(cost, 20), 5_400);
796 }
797
798 #[test]
799 fn counts_read_with_thousands_separators() {
800 assert_eq!(thousands(0), "0");
801 assert_eq!(thousands(999), "999");
802 assert_eq!(thousands(50_000), "50,000");
803 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 put804 }
805
806 #[test]
807 fn storage_reads_in_gigabytes() {
808 assert_eq!(bytes(1_000_000_000), "1 GB");
809 assert_eq!(bytes(50_000_000_000), "50 GB");
810 assert_eq!(bytes(1_500_000_000), "1.5 GB");
811 assert_eq!(bytes(500_000_000), "500 MB");
812 }
813
814 #[test]
Money in billing's messages reads to the cent, and the deploy reads migrations again after a failure815 fn amounts_of_money_read_to_the_cent() {
816 assert_eq!(cents(3_986_990), "$3.99");
817 assert_eq!(cents(5_000_000), "$5.00");
818 assert_eq!(cents(4_000), "$0.00");
819 }
820
821 #[test]
Deployments: a preview for every pull request, production on g1t.page822 fn prices_under_a_cent_keep_their_digits() {
823 assert_eq!(dollars(1512), "$0.0015");
824 assert_eq!(dollars(24_000), "$0.024");
825 assert_eq!(dollars(360_000), "$0.36");
826 assert_eq!(dollars(5_000_000), "$5.00");
827 }
828}
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar829
830#[cfg(test)]
831mod security_activation {
832 use super::*;
833
834 #[test]
835 fn the_activation_is_priced_from_the_price_book() {
836 let book = std::collections::BTreeMap::from([(SECURITY_METER, 12_000_000.0)]);
837 let plan = security_plan_at(&book);
838 assert_eq!((plan.feature, plan.monthly_cents), (Feature::Security, 1200));
839 assert_eq!(plan.title, "Security and quality");
840 // The price book unreadable: $10, as the migration seeds it.
841 assert_eq!(security_plan_at(&std::collections::BTreeMap::new()).monthly_cents, 1000);
842 assert!(plan.includes.iter().any(|line| line.contains("public repositories have it free")));
843 assert!(plan.overage.contains("agent usage"));
844 }
845}

This file's history is long; its oldest lines are credited to the oldest commit read.