g1t/services/billing/src/features.rs
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.
| Paid features: a workspace turns on Deployments with a monthly plan | 1 | //! Paid features a workspace turns on with a monthly plan, the way |
| 2 | //! Cloudflare's Workers for Platforms is bought: a price that includes an | |
| 3 | //! allowance, and usage past it charged from credit at cost plus the | |
| 4 | //! margin. None of it is free, whatever `FREE_WHILE_BUILDING` says. | |
| 5 | ||
| 6 | use g1t_contracts::billing::deployments_allowance as allowance; | |
| 7 | use g1t_contracts::billing::*; | |
| 8 | use g1t_contracts::time::rfc3339; | |
| Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put | 9 | use g1t_contracts::{FailureCode, Outcome, Role}; |
| Paid features: a workspace turns on Deployments with a monthly plan | 10 | use g1t_kit::now_ms; |
| 11 | use serde::Deserialize; | |
| 12 | use worker::Result; | |
| 13 | ||
| Project dependencies: addresses, preview stacks, Affects, and agents who know | 14 | use crate::stripe::{StripeSubscription, is_missing}; |
| Paid features: a workspace turns on Deployments with a monthly plan | 15 | use crate::{Billing, Touched, members_only, optional}; |
| 16 | ||
| 17 | #[derive(Deserialize)] | |
| 18 | struct SubscriptionRow { | |
| 19 | feature: String, | |
| 20 | subscription_id: String, | |
| 21 | status: String, | |
| 22 | period_end: Option<String>, | |
| 23 | started_by: String, | |
| 24 | started_at: String, | |
| 25 | } | |
| 26 | ||
| 27 | #[derive(Deserialize)] | |
| 28 | struct PlanCheckoutRow { | |
| 29 | workspace: String, | |
| 30 | created_by: String, | |
| 31 | feature: String, | |
| 32 | } | |
| 33 | ||
| 34 | fn status_from(text: &str) -> SubscriptionStatus { | |
| 35 | match text { | |
| 36 | "active" => SubscriptionStatus::Active, | |
| 37 | "canceling" => SubscriptionStatus::Canceling, | |
| 38 | "past_due" => SubscriptionStatus::PastDue, | |
| 39 | _ => SubscriptionStatus::Canceled, | |
| 40 | } | |
| 41 | } | |
| 42 | ||
| 43 | fn status_text(status: SubscriptionStatus) -> &'static str { | |
| 44 | match status { | |
| 45 | SubscriptionStatus::Active => "active", | |
| 46 | SubscriptionStatus::Canceling => "canceling", | |
| 47 | SubscriptionStatus::PastDue => "past_due", | |
| 48 | SubscriptionStatus::Canceled => "canceled", | |
| 49 | } | |
| 50 | } | |
| 51 | ||
| 52 | /// What the processor's state for a plan means here. | |
| 53 | fn status_of(subscription: &StripeSubscription) -> SubscriptionStatus { | |
| 54 | match subscription.status.as_str() { | |
| 55 | "active" | "trialing" if subscription.cancel_at_period_end => SubscriptionStatus::Canceling, | |
| 56 | "active" | "trialing" => SubscriptionStatus::Active, | |
| 57 | "past_due" | "unpaid" | "incomplete" | "paused" => SubscriptionStatus::PastDue, | |
| 58 | _ => SubscriptionStatus::Canceled, | |
| 59 | } | |
| 60 | } | |
| 61 | ||
| Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put | 62 | /// `1 GB`, `50 GB`, or `500 MB`, as storage is priced (powers of ten). |
| 63 | pub(crate) fn bytes(bytes: i64) -> String { | |
| 64 | if bytes >= 1_000_000_000 && bytes % 1_000_000_000 == 0 { | |
| 65 | format!("{} GB", bytes / 1_000_000_000) | |
| 66 | } else if bytes >= 1_000_000_000 { | |
| 67 | format!("{:.1} GB", bytes as f64 / 1e9) | |
| 68 | } else { | |
| 69 | format!("{} MB", bytes / 1_000_000) | |
| 70 | } | |
| 71 | } | |
| 72 | ||
| Deployments: a preview for every pull request, production on g1t.page | 73 | /// Dollars to the cent, or finer for prices under a cent, so that a |
| 74 | /// build minute's $0.0015 does not read as nothing. | |
| Usage limits: unpaid usage can only go so far | 75 | pub(crate) fn dollars(micros: i64) -> String { |
| Deployments: a preview for every pull request, production on g1t.page | 76 | let text = format!("{:.4}", micros as f64 / MICROS_PER_DOLLAR as f64); |
| 77 | let (whole, fraction) = text.split_once('.').unwrap_or((&text, "")); | |
| 78 | let fraction = fraction.trim_end_matches('0'); | |
| 79 | format!("${whole}.{fraction:0<2}") | |
| Paid features: a workspace turns on Deployments with a monthly plan | 80 | } |
| 81 | ||
| 82 | impl SubscriptionRow { | |
| 83 | fn subscription(&self) -> Option<Subscription> { | |
| 84 | Some(Subscription { | |
| 85 | feature: Feature::parse(&self.feature)?, | |
| 86 | status: status_from(&self.status), | |
| 87 | period_end: self.period_end.clone(), | |
| 88 | started_by: self.started_by.clone(), | |
| 89 | started_at: self.started_at.clone(), | |
| 90 | }) | |
| 91 | } | |
| 92 | } | |
| 93 | ||
| 94 | impl Billing { | |
| 95 | pub(crate) fn plan(&self, feature: Feature) -> Plan { | |
| 96 | match feature { | |
| Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put | 97 | Feature::Team => Plan { |
| 98 | feature, | |
| 99 | title: feature.title().to_owned(), | |
| 100 | monthly_cents: self.plans.team_monthly_cents, | |
| 101 | includes: vec![ | |
| 102 | format!( | |
| 103 | "{} of usage credit each month, drawn first by the month's usage at cost plus {}%. Unused credit does not roll over.", | |
| 104 | dollars(self.plans.team_included_micros), | |
| 105 | self.margin_percent | |
| 106 | ), | |
| 107 | format!( | |
| 108 | "{} of private repository storage, rather than {}", | |
| 109 | bytes(self.plans.team_storage_bytes), | |
| 110 | bytes(self.plans.free_storage_bytes) | |
| 111 | ), | |
| 112 | format!( | |
| 113 | "The audit log kept {}, rather than {} days", | |
| 114 | if self.plans.team_audit_days % 365 == 0 { | |
| 115 | match self.plans.team_audit_days / 365 { | |
| 116 | 1 => "for 1 year".to_owned(), | |
| 117 | years => format!("for {years} years"), | |
| 118 | } | |
| 119 | } else { | |
| 120 | format!("for {} days", self.plans.team_audit_days) | |
| 121 | }, | |
| 122 | self.plans.audit_days | |
| 123 | ), | |
| 124 | "Everyone in the workspace, at one price: never per person".to_owned(), | |
| 125 | ], | |
| 126 | overage: format!( | |
| 127 | "Usage past the credit is charged as it is without the plan: at cost plus {}%.", | |
| 128 | self.margin_percent | |
| 129 | ), | |
| 130 | }, | |
| Paid features: a workspace turns on Deployments with a monthly plan | 131 | Feature::Deployments => Plan { |
| 132 | feature, | |
| 133 | title: feature.title().to_owned(), | |
| 134 | monthly_cents: self.deployments_monthly_cents, | |
| 135 | includes: vec![ | |
| 136 | format!( | |
| 137 | "{} apps deployed at once, production and previews together", | |
| 138 | allowance::APPS | |
| 139 | ), | |
| Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put | 140 | format!("{} build minutes", self.plans.build_seconds / 60), |
| Paid features: a workspace turns on Deployments with a monthly plan | 141 | format!("{} million requests", allowance::REQUESTS / 1_000_000), |
| 142 | format!("{} million CPU milliseconds", allowance::CPU_MS / 1_000_000), | |
| Agents and memory, checks and conflicts, profiles, slug renames, custom domains | 143 | format!( |
| 144 | "{} custom domains, with certificates, then {} each a month", | |
| 145 | allowance::CUSTOM_DOMAINS, | |
| 146 | dollars(crate::charge_micros( | |
| 147 | allowance::MICROS_PER_DOMAIN_MONTH as f64 / MICROS_PER_DOLLAR as f64, | |
| 148 | self.margin_percent | |
| 149 | )), | |
| 150 | ), | |
| Paid features: a workspace turns on Deployments with a monthly plan | 151 | "Previews that cost nothing while no one visits them".to_owned(), |
| 152 | ], | |
| 153 | overage: format!( | |
| Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put | 154 | "Usage past that is charged at Cloudflare's price plus {3}%: {4} per build minute, by the second, {0} per extra app a month, {1} per million requests and {2} per million CPU milliseconds.", |
| Paid features: a workspace turns on Deployments with a monthly plan | 155 | dollars(crate::charge_micros( |
| 156 | allowance::MICROS_PER_APP_MONTH as f64 / MICROS_PER_DOLLAR as f64, | |
| 157 | self.margin_percent | |
| 158 | )), | |
| 159 | dollars(crate::charge_micros( | |
| 160 | allowance::MICROS_PER_MILLION_REQUESTS as f64 / MICROS_PER_DOLLAR as f64, | |
| 161 | self.margin_percent | |
| 162 | )), | |
| 163 | dollars(crate::charge_micros( | |
| 164 | allowance::MICROS_PER_MILLION_CPU_MS as f64 / MICROS_PER_DOLLAR as f64, | |
| 165 | self.margin_percent | |
| 166 | )), | |
| Deployments: a preview for every pull request, production on g1t.page | 167 | self.margin_percent, |
| 168 | dollars(crate::charge_micros( | |
| 169 | (allowance::MICROS_PER_BUILD_SECOND * 60) as f64 / MICROS_PER_DOLLAR as f64, | |
| 170 | self.margin_percent | |
| 171 | )), | |
| Paid features: a workspace turns on Deployments with a monthly plan | 172 | ), |
| 173 | }, | |
| 174 | } | |
| 175 | } | |
| 176 | ||
| 177 | async fn subscription_row(&self, workspace: &str, feature: Feature) -> Result<Option<SubscriptionRow>> { | |
| 178 | self.db | |
| 179 | .prepare( | |
| 180 | "SELECT feature, subscription_id, status, period_end, started_by, started_at | |
| 181 | FROM subscriptions WHERE workspace = ? AND feature = ?", | |
| 182 | ) | |
| 183 | .bind(&[workspace.into(), feature.as_str().into()])? | |
| 184 | .first::<SubscriptionRow>(None) | |
| 185 | .await | |
| 186 | } | |
| 187 | ||
| 188 | /// Writes down what the processor says about a plan. | |
| Stripe webhooks, enterprise invoices, and sudo for both | 189 | pub(crate) async fn record( |
| Paid features: a workspace turns on Deployments with a monthly plan | 190 | &self, |
| 191 | workspace: &str, | |
| 192 | feature: Feature, | |
| 193 | subscription: &StripeSubscription, | |
| 194 | started_by: &str, | |
| 195 | ) -> Result<()> { | |
| 196 | let now = rfc3339(now_ms()); | |
| 197 | let period_end = subscription.period_end().map(|seconds| rfc3339(seconds.max(0) as u64 * 1000)); | |
| 198 | self.db | |
| 199 | .prepare( | |
| 200 | "INSERT INTO subscriptions | |
| 201 | (workspace, feature, subscription_id, status, period_end, started_by, started_at, updated_at) | |
| 202 | VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?7) | |
| 203 | ON CONFLICT (workspace, feature) DO UPDATE SET | |
| 204 | subscription_id = ?3, status = ?4, period_end = ?5, updated_at = ?7, | |
| 205 | started_by = CASE WHEN subscription_id = ?3 THEN started_by ELSE ?6 END, | |
| 206 | started_at = CASE WHEN subscription_id = ?3 THEN started_at ELSE ?7 END", | |
| 207 | ) | |
| 208 | .bind(&[ | |
| 209 | workspace.into(), | |
| 210 | feature.as_str().into(), | |
| 211 | subscription.id.as_str().into(), | |
| 212 | status_text(status_of(subscription)).into(), | |
| 213 | optional(period_end.as_deref()), | |
| 214 | started_by.into(), | |
| 215 | now.as_str().into(), | |
| 216 | ])? | |
| 217 | .run() | |
| 218 | .await?; | |
| 219 | Ok(()) | |
| 220 | } | |
| 221 | ||
| 222 | /// A workspace's plan for a feature, asking the processor again once | |
| 223 | /// the period it last knew of is over. | |
| 224 | async fn current(&self, workspace: &str, feature: Feature) -> Result<Option<SubscriptionRow>> { | |
| 225 | let Some(row) = self.subscription_row(workspace, feature).await? else { | |
| 226 | return Ok(None); | |
| 227 | }; | |
| 228 | let stale = row.period_end.as_deref().is_none_or(|end| end <= rfc3339(now_ms()).as_str()) | |
| 229 | && row.status != "canceled"; | |
| 230 | if let (true, Some(stripe)) = (stale, &self.stripe) { | |
| Project dependencies: addresses, preview stacks, Affects, and agents who know | 231 | match stripe.subscription(&row.subscription_id).await { |
| 232 | Ok(subscription) => self.record(workspace, feature, &subscription, &row.started_by).await?, | |
| 233 | // A plan from another Stripe account: it has ended here. | |
| 234 | Err(error) if is_missing(&error) => { | |
| 235 | self.db | |
| 236 | .prepare("UPDATE subscriptions SET status = 'canceled', updated_at = ? WHERE workspace = ? AND feature = ?") | |
| 237 | .bind(&[rfc3339(now_ms()).into(), workspace.into(), feature.as_str().into()])? | |
| 238 | .run() | |
| 239 | .await?; | |
| 240 | } | |
| 241 | Err(error) => return Err(error), | |
| 242 | } | |
| Paid features: a workspace turns on Deployments with a monthly plan | 243 | return self.subscription_row(workspace, feature).await; |
| 244 | } | |
| 245 | Ok(Some(row)) | |
| 246 | } | |
| 247 | ||
| 248 | async fn state(&self, workspace: &str, feature: Feature) -> Result<FeatureState> { | |
| 249 | let subscription = self | |
| 250 | .current(workspace, feature) | |
| 251 | .await? | |
| 252 | .and_then(|row| row.subscription()); | |
| Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put | 253 | let included = self.included(workspace, feature).await?; |
| Paid features: a workspace turns on Deployments with a monthly plan | 254 | Ok(FeatureState { |
| 255 | plan: self.plan(feature), | |
| Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put | 256 | on: included || self.stripe.is_none() || subscription.as_ref().is_some_and(|s| s.status.on()), |
| Paid features: a workspace turns on Deployments with a monthly plan | 257 | subscription, |
| Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put | 258 | included, |
| Paid features: a workspace turns on Deployments with a monthly plan | 259 | }) |
| 260 | } | |
| 261 | ||
| Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put | 262 | /// Whether the feature is on without a plan: comped terms have every |
| 263 | /// feature, and g1t staff can give an account Team. | |
| 264 | async fn included(&self, workspace: &str, feature: Feature) -> Result<bool> { | |
| 265 | let account = self.account_of(workspace).await?; | |
| 266 | Ok(account.terms.kind == g1t_contracts::billing::TermsKind::Comped | |
| 267 | || (feature == Feature::Team && account.allowances.team)) | |
| 268 | } | |
| 269 | ||
| 270 | /// Whether the workspace's plan for the feature is paid up. | |
| 271 | pub(crate) async fn plan_on(&self, workspace: &str, feature: Feature) -> Result<bool> { | |
| 272 | Ok(self | |
| 273 | .current(workspace, feature) | |
| 274 | .await? | |
| 275 | .and_then(|row| row.subscription()) | |
| 276 | .is_some_and(|s| s.status.on())) | |
| 277 | } | |
| 278 | ||
| Paid features: a workspace turns on Deployments with a monthly plan | 279 | pub(crate) async fn features(&self, a: FeaturesArgs) -> Result<Outcome<Vec<FeatureState>>> { |
| 280 | let workspace = a.workspace.to_lowercase(); | |
| 281 | if !a.viewer.is_some_and(|viewer| viewer.is_member(&workspace)) { | |
| 282 | return Ok(members_only()); | |
| 283 | } | |
| 284 | let mut states = Vec::new(); | |
| 285 | for feature in Feature::ALL { | |
| 286 | states.push(self.state(&workspace, feature).await?); | |
| 287 | } | |
| 288 | Ok(Outcome::Ok(states)) | |
| 289 | } | |
| 290 | ||
| 291 | pub(crate) async fn subscribe(&self, a: SubscribeArgs) -> Result<Outcome<Checkout>> { | |
| 292 | let workspace = a.workspace.to_lowercase(); | |
| 293 | if a.actor.role_in(&workspace) != Some(Role::Owner) { | |
| 294 | return Ok(Outcome::fail( | |
| 295 | FailureCode::Forbidden, | |
| 296 | "Only an owner can turn on a paid feature.", | |
| 297 | )); | |
| 298 | } | |
| 299 | let Some(stripe) = &self.stripe else { | |
| 300 | return Ok(Outcome::fail( | |
| 301 | FailureCode::Conflict, | |
| 302 | "Payments are not set up on this g1t, so every feature is already on.", | |
| 303 | )); | |
| 304 | }; | |
| Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put | 305 | let state = self.state(&workspace, a.feature).await?; |
| 306 | if state.included { | |
| 307 | return Ok(Outcome::fail( | |
| 308 | FailureCode::Conflict, | |
| 309 | format!("{} is included for {workspace} already, at no charge.", a.feature.title()), | |
| 310 | )); | |
| 311 | } | |
| 312 | if state.subscription.is_some_and(|s| s.status.on()) { | |
| Paid features: a workspace turns on Deployments with a monthly plan | 313 | return Ok(Outcome::fail( |
| 314 | FailureCode::Conflict, | |
| 315 | format!("{} is already on for {workspace}.", a.feature.title()), | |
| 316 | )); | |
| 317 | } | |
| 318 | let plan = self.plan(a.feature); | |
| 319 | let customer = self.row(&workspace).await?.and_then(|row| row.customer_id); | |
| Project dependencies: addresses, preview stacks, Affects, and agents who know | 320 | let start = |customer: Option<String>| { |
| 321 | let plan = &plan; | |
| 322 | let workspace = &workspace; | |
| 323 | let return_url = &a.return_url; | |
| 324 | async move { | |
| 325 | stripe | |
| 326 | .start_subscription( | |
| 327 | workspace, | |
| 328 | a.feature.as_str(), | |
| 329 | &plan.title, | |
| 330 | plan.monthly_cents, | |
| 331 | customer.as_deref(), | |
| 332 | return_url, | |
| 333 | ) | |
| 334 | .await | |
| 335 | } | |
| 336 | }; | |
| 337 | let session = match start(customer.clone()).await { | |
| 338 | Ok(session) => session, | |
| 339 | // A customer saved under another Stripe account: start afresh. | |
| 340 | Err(error) if customer.is_some() && is_missing(&error) => { | |
| 341 | self.forget_customer(&workspace).await?; | |
| 342 | start(None).await? | |
| 343 | } | |
| 344 | Err(error) => return Err(error), | |
| 345 | }; | |
| Paid features: a workspace turns on Deployments with a monthly plan | 346 | let Some(url) = session.url else { |
| 347 | return Err(worker::Error::RustError( | |
| 348 | "the card processor returned no payment page".into(), | |
| 349 | )); | |
| 350 | }; | |
| 351 | self.db | |
| 352 | .prepare( | |
| 353 | "INSERT INTO checkouts (id, workspace, amount_cents, created_by, created_at, feature) | |
| 354 | VALUES (?, ?, ?, ?, ?, ?)", | |
| 355 | ) | |
| 356 | .bind(&[ | |
| 357 | session.id.into(), | |
| 358 | workspace.into(), | |
| 359 | plan.monthly_cents.into(), | |
| 360 | a.actor.username.into(), | |
| 361 | rfc3339(now_ms()).into(), | |
| 362 | a.feature.as_str().into(), | |
| 363 | ])? | |
| 364 | .run() | |
| 365 | .await?; | |
| 366 | Ok(Outcome::Ok(Checkout { url })) | |
| 367 | } | |
| 368 | ||
| 369 | pub(crate) async fn confirm_subscription( | |
| 370 | &self, | |
| 371 | a: ConfirmSubscriptionArgs, | |
| 372 | ) -> Result<Outcome<FeatureState>> { | |
| 373 | let workspace = a.workspace.to_lowercase(); | |
| 374 | if !a.viewer.is_some_and(|viewer| viewer.is_member(&workspace)) { | |
| 375 | return Ok(members_only()); | |
| 376 | } | |
| 377 | let checkout = self | |
| 378 | .db | |
| 379 | .prepare( | |
| 380 | "SELECT workspace, created_by, feature FROM checkouts | |
| 381 | WHERE id = ? AND workspace = ? AND status = 'open' AND feature IS NOT NULL", | |
| 382 | ) | |
| 383 | .bind(&[a.session.as_str().into(), workspace.as_str().into()])? | |
| 384 | .first::<PlanCheckoutRow>(None) | |
| 385 | .await?; | |
| 386 | let (Some(stripe), Some(checkout)) = (&self.stripe, checkout) else { | |
| 387 | // Unknown, someone else's, or already done: show where it stands. | |
| 388 | return Ok(Outcome::Ok(self.state(&workspace, Feature::Deployments).await?)); | |
| 389 | }; | |
| 390 | let Some(feature) = Feature::parse(&checkout.feature) else { | |
| 391 | return Ok(Outcome::fail(FailureCode::NotFound, "No such feature.")); | |
| 392 | }; | |
| 393 | let session = stripe.session(&a.session).await?; | |
| 394 | if let (Some(subscription_id), true) = (&session.subscription, session.payment_status == "paid") { | |
| 395 | let claimed = self | |
| 396 | .db | |
| 397 | .prepare("UPDATE checkouts SET status = 'paid' WHERE id = ? AND status = 'open' RETURNING id") | |
| 398 | .bind(&[a.session.as_str().into()])? | |
| 399 | .first::<Touched>(None) | |
| 400 | .await?; | |
| 401 | if claimed.is_some() { | |
| 402 | let subscription = stripe.subscription(subscription_id).await?; | |
| 403 | self.record(&checkout.workspace, feature, &subscription, &checkout.created_by) | |
| 404 | .await?; | |
| 405 | // Keep the card's customer, so later payments need no retyping. | |
| 406 | self.db | |
| 407 | .prepare( | |
| 408 | "INSERT INTO accounts (workspace, balance_micros, customer_id, created_at) | |
| 409 | VALUES (?1, 0, ?2, ?3) | |
| 410 | ON CONFLICT (workspace) DO UPDATE SET customer_id = COALESCE(customer_id, ?2)", | |
| 411 | ) | |
| 412 | .bind(&[ | |
| 413 | checkout.workspace.as_str().into(), | |
| 414 | optional(session.customer.as_deref()), | |
| 415 | rfc3339(now_ms()).into(), | |
| 416 | ])? | |
| 417 | .run() | |
| 418 | .await?; | |
| 419 | } | |
| 420 | } | |
| 421 | Ok(Outcome::Ok(self.state(&workspace, feature).await?)) | |
| 422 | } | |
| 423 | ||
| 424 | pub(crate) async fn cancel_subscription( | |
| 425 | &self, | |
| 426 | a: CancelSubscriptionArgs, | |
| 427 | ) -> Result<Outcome<FeatureState>> { | |
| 428 | let workspace = a.workspace.to_lowercase(); | |
| 429 | if a.actor.role_in(&workspace) != Some(Role::Owner) { | |
| 430 | return Ok(Outcome::fail( | |
| 431 | FailureCode::Forbidden, | |
| 432 | "Only an owner can change a workspace's plans.", | |
| 433 | )); | |
| 434 | } | |
| 435 | let (Some(stripe), Some(row)) = (&self.stripe, self.current(&workspace, a.feature).await?) else { | |
| 436 | return Ok(Outcome::fail( | |
| 437 | FailureCode::NotFound, | |
| 438 | format!("{} is not on for {workspace}.", a.feature.title()), | |
| 439 | )); | |
| 440 | }; | |
| 441 | let subscription = stripe | |
| 442 | .cancel_at_period_end(&row.subscription_id, !a.resume) | |
| 443 | .await?; | |
| 444 | self.record(&workspace, a.feature, &subscription, &row.started_by) | |
| 445 | .await?; | |
| 446 | Ok(Outcome::Ok(self.state(&workspace, a.feature).await?)) | |
| 447 | } | |
| 448 | ||
| 449 | pub(crate) async fn has_feature(&self, a: HasFeatureArgs) -> Result<Outcome<bool>> { | |
| 450 | let workspace = a.workspace.to_lowercase(); | |
| Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put | 451 | // Comped accounts have every feature without a plan, and staff can |
| 452 | // give an account Team. | |
| 453 | if self.included(&workspace, a.feature).await? { | |
| Billing accounts, terms and enterprises; g1t is no longer free | 454 | return Ok(Outcome::Ok(true)); |
| 455 | } | |
| Paid features: a workspace turns on Deployments with a monthly plan | 456 | if self.state(&workspace, a.feature).await?.on { |
| 457 | return Ok(Outcome::Ok(true)); | |
| 458 | } | |
| 459 | Ok(Outcome::fail( | |
| 460 | FailureCode::PaymentRequired, | |
| 461 | format!( | |
| 462 | "{} is a paid feature, and it is not on for {workspace}. An owner can turn it on under Billing on the workspace's page.", | |
| 463 | a.feature.title() | |
| 464 | ), | |
| 465 | )) | |
| 466 | } | |
| 467 | ||
| 468 | pub(crate) async fn charge_feature(&self, a: ChargeFeatureArgs) -> Result<Outcome<bool>> { | |
| 469 | if self.stripe.is_none() || a.cost_micros <= 0 { | |
| 470 | return Ok(Outcome::Ok(false)); | |
| 471 | } | |
| 472 | let workspace = a.workspace.to_lowercase(); | |
| 473 | let seen = self | |
| 474 | .db | |
| 475 | .prepare("SELECT id FROM ledger WHERE reference = ?") | |
| 476 | .bind(&[a.reference.as_str().into()])? | |
| 477 | .first::<Touched>(None) | |
| 478 | .await?; | |
| 479 | if seen.is_some() { | |
| 480 | return Ok(Outcome::Ok(false)); | |
| 481 | } | |
| Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put | 482 | let timestamp = rfc3339(now_ms()); |
| 483 | let month = crate::credits::month_of(×tamp); | |
| 484 | let mut description = a.description.clone(); | |
| 485 | // A build: the plan's build time this month pays for what it can. | |
| 486 | let cost_micros = match a.build_seconds.filter(|s| *s > 0 && a.feature == Feature::Deployments) { | |
| 487 | Some(seconds) => { | |
| 488 | let included = self | |
| 489 | .draw_allowance("build_seconds", &workspace, &month, seconds.into(), self.plans.build_seconds.into()) | |
| 490 | .await?; | |
| 491 | if included > 0 { | |
| 492 | description.push_str(&format!( | |
| 493 | ", {} of it included in the plan", | |
| 494 | if included == i64::from(seconds) { "all".to_owned() } else { format!("{included} s") } | |
| 495 | )); | |
| 496 | } | |
| 497 | billable_build_cost(a.cost_micros, seconds, included) | |
| 498 | } | |
| 499 | None => a.cost_micros, | |
| 500 | }; | |
| 501 | let cost = cost_micros as f64 / MICROS_PER_DOLLAR as f64; | |
| Billing accounts, terms and enterprises; g1t is no longer free | 502 | // Never free: the margin applies whatever FREE_WHILE_BUILDING says, |
| Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put | 503 | // and only the account's terms change it. The Team credit pays what |
| 504 | // it can; the trial and the open-source pool never pay for | |
| 505 | // deployments. | |
| Billing accounts, terms and enterprises; g1t is no longer free | 506 | let charge = self.terms_of(&workspace).await?.apply(crate::charge_micros(cost, self.margin_percent)); |
| Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put | 507 | let drawn = self.draw(&workspace, charge, &month, &crate::credits::Eligible::default()).await?; |
| 508 | description.push_str(&drawn.note()); | |
| 509 | self.post_usage(crate::storage::UsageLine { | |
| 510 | workspace: &workspace, | |
| 511 | charged: charge - drawn.total(), | |
| 512 | description: &description, | |
| 513 | repo: a.repo.as_deref(), | |
| 514 | task: a.feature.as_str(), | |
| 515 | cost: cost_micros, | |
| 516 | reference: &a.reference, | |
| 517 | created_at: ×tamp, | |
| 518 | drawn, | |
| 519 | }) | |
| 520 | .await?; | |
| Paid features: a workspace turns on Deployments with a monthly plan | 521 | Ok(Outcome::Ok(true)) |
| 522 | } | |
| 523 | } | |
| Deployments: a preview for every pull request, production on g1t.page | 524 | |
| Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put | 525 | /// What of a build's cost is charged when `included` of its `seconds` were |
| 526 | /// paid for by the plan: the rest, in proportion, rounded up. | |
| 527 | pub(crate) fn billable_build_cost(cost_micros: i64, seconds: u32, included: i64) -> i64 { | |
| 528 | if seconds == 0 { | |
| 529 | return cost_micros; | |
| 530 | } | |
| 531 | let billable = (i64::from(seconds) - included.max(0)).max(0); | |
| 532 | (cost_micros as f64 * billable as f64 / f64::from(seconds)).ceil() as i64 | |
| 533 | } | |
| 534 | ||
| Deployments: a preview for every pull request, production on g1t.page | 535 | #[cfg(test)] |
| 536 | mod tests { | |
| 537 | use super::*; | |
| 538 | ||
| 539 | #[test] | |
| Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put | 540 | fn the_plan_pays_for_its_build_minutes_and_the_rest_is_charged() { |
| 541 | // A 5-minute build at 21 millionths a second costs 6,300. | |
| 542 | assert_eq!(billable_build_cost(6_300, 300, 300), 0); | |
| 543 | assert_eq!(billable_build_cost(6_300, 300, 0), 6_300); | |
| 544 | // The allowance ran out a minute into it: four minutes are charged. | |
| 545 | assert_eq!(billable_build_cost(6_300, 300, 60), 5_040); | |
| 546 | // Then at cost plus 20%. | |
| 547 | assert_eq!(crate::credits::with_margin(5_040, 20), 6_048); | |
| 548 | // 200 minutes a month cost g1t about $0.25 of the plan's $5. | |
| 549 | let month = crate::credits::Config::default().build_seconds; | |
| 550 | assert_eq!(month, 12_000); | |
| 551 | assert_eq!(i64::from(month) * allowance::MICROS_PER_BUILD_SECOND, 252_000); | |
| 552 | } | |
| 553 | ||
| 554 | #[test] | |
| 555 | fn storage_reads_in_gigabytes() { | |
| 556 | assert_eq!(bytes(1_000_000_000), "1 GB"); | |
| 557 | assert_eq!(bytes(50_000_000_000), "50 GB"); | |
| 558 | assert_eq!(bytes(1_500_000_000), "1.5 GB"); | |
| 559 | assert_eq!(bytes(500_000_000), "500 MB"); | |
| 560 | } | |
| 561 | ||
| 562 | #[test] | |
| Deployments: a preview for every pull request, production on g1t.page | 563 | fn prices_under_a_cent_keep_their_digits() { |
| 564 | assert_eq!(dollars(1512), "$0.0015"); | |
| 565 | assert_eq!(dollars(24_000), "$0.024"); | |
| 566 | assert_eq!(dollars(360_000), "$0.36"); | |
| 567 | assert_eq!(dollars(5_000_000), "$5.00"); | |
| 568 | } | |
| 569 | } |