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