Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

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