g1t/services/billing/src/features.rs
| 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; |
| 9 | use g1t_contracts::{FailureCode, Outcome, Role, new_id}; |
| 10 | use g1t_kit::now_ms; |
| 11 | use serde::Deserialize; |
| 12 | use worker::Result; |
| 13 | |
| 14 | use crate::stripe::{StripeSubscription, is_missing}; |
| 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 | |
| 62 | /// Dollars to the cent, or finer for prices under a cent, so that a |
| 63 | /// build minute's $0.0015 does not read as nothing. |
| 64 | fn dollars(micros: i64) -> String { |
| 65 | let text = format!("{:.4}", micros as f64 / MICROS_PER_DOLLAR as f64); |
| 66 | let (whole, fraction) = text.split_once('.').unwrap_or((&text, "")); |
| 67 | let fraction = fraction.trim_end_matches('0'); |
| 68 | format!("${whole}.{fraction:0<2}") |
| 69 | } |
| 70 | |
| 71 | impl SubscriptionRow { |
| 72 | fn subscription(&self) -> Option<Subscription> { |
| 73 | Some(Subscription { |
| 74 | feature: Feature::parse(&self.feature)?, |
| 75 | status: status_from(&self.status), |
| 76 | period_end: self.period_end.clone(), |
| 77 | started_by: self.started_by.clone(), |
| 78 | started_at: self.started_at.clone(), |
| 79 | }) |
| 80 | } |
| 81 | } |
| 82 | |
| 83 | impl Billing { |
| 84 | pub(crate) fn plan(&self, feature: Feature) -> Plan { |
| 85 | match feature { |
| 86 | Feature::Deployments => Plan { |
| 87 | feature, |
| 88 | title: feature.title().to_owned(), |
| 89 | monthly_cents: self.deployments_monthly_cents, |
| 90 | includes: vec![ |
| 91 | format!( |
| 92 | "{} apps deployed at once, production and previews together", |
| 93 | allowance::APPS |
| 94 | ), |
| 95 | format!("{} million requests", allowance::REQUESTS / 1_000_000), |
| 96 | format!("{} million CPU milliseconds", allowance::CPU_MS / 1_000_000), |
| 97 | "Previews that cost nothing while no one visits them".to_owned(), |
| 98 | ], |
| 99 | overage: format!( |
| 100 | "Builds, and usage past that, come from credit at Cloudflare's price plus {3}%: {4} per build minute, {0} per extra app a month, {1} per million requests and {2} per million CPU milliseconds.", |
| 101 | dollars(crate::charge_micros( |
| 102 | allowance::MICROS_PER_APP_MONTH as f64 / MICROS_PER_DOLLAR as f64, |
| 103 | self.margin_percent |
| 104 | )), |
| 105 | dollars(crate::charge_micros( |
| 106 | allowance::MICROS_PER_MILLION_REQUESTS as f64 / MICROS_PER_DOLLAR as f64, |
| 107 | self.margin_percent |
| 108 | )), |
| 109 | dollars(crate::charge_micros( |
| 110 | allowance::MICROS_PER_MILLION_CPU_MS as f64 / MICROS_PER_DOLLAR as f64, |
| 111 | self.margin_percent |
| 112 | )), |
| 113 | self.margin_percent, |
| 114 | dollars(crate::charge_micros( |
| 115 | (allowance::MICROS_PER_BUILD_SECOND * 60) as f64 / MICROS_PER_DOLLAR as f64, |
| 116 | self.margin_percent |
| 117 | )), |
| 118 | ), |
| 119 | }, |
| 120 | } |
| 121 | } |
| 122 | |
| 123 | async fn subscription_row(&self, workspace: &str, feature: Feature) -> Result<Option<SubscriptionRow>> { |
| 124 | self.db |
| 125 | .prepare( |
| 126 | "SELECT feature, subscription_id, status, period_end, started_by, started_at |
| 127 | FROM subscriptions WHERE workspace = ? AND feature = ?", |
| 128 | ) |
| 129 | .bind(&[workspace.into(), feature.as_str().into()])? |
| 130 | .first::<SubscriptionRow>(None) |
| 131 | .await |
| 132 | } |
| 133 | |
| 134 | /// Writes down what the processor says about a plan. |
| 135 | async fn record( |
| 136 | &self, |
| 137 | workspace: &str, |
| 138 | feature: Feature, |
| 139 | subscription: &StripeSubscription, |
| 140 | started_by: &str, |
| 141 | ) -> Result<()> { |
| 142 | let now = rfc3339(now_ms()); |
| 143 | let period_end = subscription.period_end().map(|seconds| rfc3339(seconds.max(0) as u64 * 1000)); |
| 144 | self.db |
| 145 | .prepare( |
| 146 | "INSERT INTO subscriptions |
| 147 | (workspace, feature, subscription_id, status, period_end, started_by, started_at, updated_at) |
| 148 | VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?7) |
| 149 | ON CONFLICT (workspace, feature) DO UPDATE SET |
| 150 | subscription_id = ?3, status = ?4, period_end = ?5, updated_at = ?7, |
| 151 | started_by = CASE WHEN subscription_id = ?3 THEN started_by ELSE ?6 END, |
| 152 | started_at = CASE WHEN subscription_id = ?3 THEN started_at ELSE ?7 END", |
| 153 | ) |
| 154 | .bind(&[ |
| 155 | workspace.into(), |
| 156 | feature.as_str().into(), |
| 157 | subscription.id.as_str().into(), |
| 158 | status_text(status_of(subscription)).into(), |
| 159 | optional(period_end.as_deref()), |
| 160 | started_by.into(), |
| 161 | now.as_str().into(), |
| 162 | ])? |
| 163 | .run() |
| 164 | .await?; |
| 165 | Ok(()) |
| 166 | } |
| 167 | |
| 168 | /// A workspace's plan for a feature, asking the processor again once |
| 169 | /// the period it last knew of is over. |
| 170 | async fn current(&self, workspace: &str, feature: Feature) -> Result<Option<SubscriptionRow>> { |
| 171 | let Some(row) = self.subscription_row(workspace, feature).await? else { |
| 172 | return Ok(None); |
| 173 | }; |
| 174 | let stale = row.period_end.as_deref().is_none_or(|end| end <= rfc3339(now_ms()).as_str()) |
| 175 | && row.status != "canceled"; |
| 176 | if let (true, Some(stripe)) = (stale, &self.stripe) { |
| 177 | match stripe.subscription(&row.subscription_id).await { |
| 178 | Ok(subscription) => self.record(workspace, feature, &subscription, &row.started_by).await?, |
| 179 | // A plan from another Stripe account: it has ended here. |
| 180 | Err(error) if is_missing(&error) => { |
| 181 | self.db |
| 182 | .prepare("UPDATE subscriptions SET status = 'canceled', updated_at = ? WHERE workspace = ? AND feature = ?") |
| 183 | .bind(&[rfc3339(now_ms()).into(), workspace.into(), feature.as_str().into()])? |
| 184 | .run() |
| 185 | .await?; |
| 186 | } |
| 187 | Err(error) => return Err(error), |
| 188 | } |
| 189 | return self.subscription_row(workspace, feature).await; |
| 190 | } |
| 191 | Ok(Some(row)) |
| 192 | } |
| 193 | |
| 194 | async fn state(&self, workspace: &str, feature: Feature) -> Result<FeatureState> { |
| 195 | let subscription = self |
| 196 | .current(workspace, feature) |
| 197 | .await? |
| 198 | .and_then(|row| row.subscription()); |
| 199 | Ok(FeatureState { |
| 200 | plan: self.plan(feature), |
| 201 | on: self.stripe.is_none() || subscription.as_ref().is_some_and(|s| s.status.on()), |
| 202 | subscription, |
| 203 | }) |
| 204 | } |
| 205 | |
| 206 | pub(crate) async fn features(&self, a: FeaturesArgs) -> Result<Outcome<Vec<FeatureState>>> { |
| 207 | let workspace = a.workspace.to_lowercase(); |
| 208 | if !a.viewer.is_some_and(|viewer| viewer.is_member(&workspace)) { |
| 209 | return Ok(members_only()); |
| 210 | } |
| 211 | let mut states = Vec::new(); |
| 212 | for feature in Feature::ALL { |
| 213 | states.push(self.state(&workspace, feature).await?); |
| 214 | } |
| 215 | Ok(Outcome::Ok(states)) |
| 216 | } |
| 217 | |
| 218 | pub(crate) async fn subscribe(&self, a: SubscribeArgs) -> Result<Outcome<Checkout>> { |
| 219 | let workspace = a.workspace.to_lowercase(); |
| 220 | if a.actor.role_in(&workspace) != Some(Role::Owner) { |
| 221 | return Ok(Outcome::fail( |
| 222 | FailureCode::Forbidden, |
| 223 | "Only an owner can turn on a paid feature.", |
| 224 | )); |
| 225 | } |
| 226 | let Some(stripe) = &self.stripe else { |
| 227 | return Ok(Outcome::fail( |
| 228 | FailureCode::Conflict, |
| 229 | "Payments are not set up on this g1t, so every feature is already on.", |
| 230 | )); |
| 231 | }; |
| 232 | if self.state(&workspace, a.feature).await?.subscription.is_some_and(|s| s.status.on()) { |
| 233 | return Ok(Outcome::fail( |
| 234 | FailureCode::Conflict, |
| 235 | format!("{} is already on for {workspace}.", a.feature.title()), |
| 236 | )); |
| 237 | } |
| 238 | let plan = self.plan(a.feature); |
| 239 | let customer = self.row(&workspace).await?.and_then(|row| row.customer_id); |
| 240 | let start = |customer: Option<String>| { |
| 241 | let plan = &plan; |
| 242 | let workspace = &workspace; |
| 243 | let return_url = &a.return_url; |
| 244 | async move { |
| 245 | stripe |
| 246 | .start_subscription( |
| 247 | workspace, |
| 248 | a.feature.as_str(), |
| 249 | &plan.title, |
| 250 | plan.monthly_cents, |
| 251 | customer.as_deref(), |
| 252 | return_url, |
| 253 | ) |
| 254 | .await |
| 255 | } |
| 256 | }; |
| 257 | let session = match start(customer.clone()).await { |
| 258 | Ok(session) => session, |
| 259 | // A customer saved under another Stripe account: start afresh. |
| 260 | Err(error) if customer.is_some() && is_missing(&error) => { |
| 261 | self.forget_customer(&workspace).await?; |
| 262 | start(None).await? |
| 263 | } |
| 264 | Err(error) => return Err(error), |
| 265 | }; |
| 266 | let Some(url) = session.url else { |
| 267 | return Err(worker::Error::RustError( |
| 268 | "the card processor returned no payment page".into(), |
| 269 | )); |
| 270 | }; |
| 271 | self.db |
| 272 | .prepare( |
| 273 | "INSERT INTO checkouts (id, workspace, amount_cents, created_by, created_at, feature) |
| 274 | VALUES (?, ?, ?, ?, ?, ?)", |
| 275 | ) |
| 276 | .bind(&[ |
| 277 | session.id.into(), |
| 278 | workspace.into(), |
| 279 | plan.monthly_cents.into(), |
| 280 | a.actor.username.into(), |
| 281 | rfc3339(now_ms()).into(), |
| 282 | a.feature.as_str().into(), |
| 283 | ])? |
| 284 | .run() |
| 285 | .await?; |
| 286 | Ok(Outcome::Ok(Checkout { url })) |
| 287 | } |
| 288 | |
| 289 | pub(crate) async fn confirm_subscription( |
| 290 | &self, |
| 291 | a: ConfirmSubscriptionArgs, |
| 292 | ) -> Result<Outcome<FeatureState>> { |
| 293 | let workspace = a.workspace.to_lowercase(); |
| 294 | if !a.viewer.is_some_and(|viewer| viewer.is_member(&workspace)) { |
| 295 | return Ok(members_only()); |
| 296 | } |
| 297 | let checkout = self |
| 298 | .db |
| 299 | .prepare( |
| 300 | "SELECT workspace, created_by, feature FROM checkouts |
| 301 | WHERE id = ? AND workspace = ? AND status = 'open' AND feature IS NOT NULL", |
| 302 | ) |
| 303 | .bind(&[a.session.as_str().into(), workspace.as_str().into()])? |
| 304 | .first::<PlanCheckoutRow>(None) |
| 305 | .await?; |
| 306 | let (Some(stripe), Some(checkout)) = (&self.stripe, checkout) else { |
| 307 | // Unknown, someone else's, or already done: show where it stands. |
| 308 | return Ok(Outcome::Ok(self.state(&workspace, Feature::Deployments).await?)); |
| 309 | }; |
| 310 | let Some(feature) = Feature::parse(&checkout.feature) else { |
| 311 | return Ok(Outcome::fail(FailureCode::NotFound, "No such feature.")); |
| 312 | }; |
| 313 | let session = stripe.session(&a.session).await?; |
| 314 | if let (Some(subscription_id), true) = (&session.subscription, session.payment_status == "paid") { |
| 315 | let claimed = self |
| 316 | .db |
| 317 | .prepare("UPDATE checkouts SET status = 'paid' WHERE id = ? AND status = 'open' RETURNING id") |
| 318 | .bind(&[a.session.as_str().into()])? |
| 319 | .first::<Touched>(None) |
| 320 | .await?; |
| 321 | if claimed.is_some() { |
| 322 | let subscription = stripe.subscription(subscription_id).await?; |
| 323 | self.record(&checkout.workspace, feature, &subscription, &checkout.created_by) |
| 324 | .await?; |
| 325 | // Keep the card's customer, so later payments need no retyping. |
| 326 | self.db |
| 327 | .prepare( |
| 328 | "INSERT INTO accounts (workspace, balance_micros, customer_id, created_at) |
| 329 | VALUES (?1, 0, ?2, ?3) |
| 330 | ON CONFLICT (workspace) DO UPDATE SET customer_id = COALESCE(customer_id, ?2)", |
| 331 | ) |
| 332 | .bind(&[ |
| 333 | checkout.workspace.as_str().into(), |
| 334 | optional(session.customer.as_deref()), |
| 335 | rfc3339(now_ms()).into(), |
| 336 | ])? |
| 337 | .run() |
| 338 | .await?; |
| 339 | } |
| 340 | } |
| 341 | Ok(Outcome::Ok(self.state(&workspace, feature).await?)) |
| 342 | } |
| 343 | |
| 344 | pub(crate) async fn cancel_subscription( |
| 345 | &self, |
| 346 | a: CancelSubscriptionArgs, |
| 347 | ) -> Result<Outcome<FeatureState>> { |
| 348 | let workspace = a.workspace.to_lowercase(); |
| 349 | if a.actor.role_in(&workspace) != Some(Role::Owner) { |
| 350 | return Ok(Outcome::fail( |
| 351 | FailureCode::Forbidden, |
| 352 | "Only an owner can change a workspace's plans.", |
| 353 | )); |
| 354 | } |
| 355 | let (Some(stripe), Some(row)) = (&self.stripe, self.current(&workspace, a.feature).await?) else { |
| 356 | return Ok(Outcome::fail( |
| 357 | FailureCode::NotFound, |
| 358 | format!("{} is not on for {workspace}.", a.feature.title()), |
| 359 | )); |
| 360 | }; |
| 361 | let subscription = stripe |
| 362 | .cancel_at_period_end(&row.subscription_id, !a.resume) |
| 363 | .await?; |
| 364 | self.record(&workspace, a.feature, &subscription, &row.started_by) |
| 365 | .await?; |
| 366 | Ok(Outcome::Ok(self.state(&workspace, a.feature).await?)) |
| 367 | } |
| 368 | |
| 369 | pub(crate) async fn has_feature(&self, a: HasFeatureArgs) -> Result<Outcome<bool>> { |
| 370 | let workspace = a.workspace.to_lowercase(); |
| 371 | if self.state(&workspace, a.feature).await?.on { |
| 372 | return Ok(Outcome::Ok(true)); |
| 373 | } |
| 374 | Ok(Outcome::fail( |
| 375 | FailureCode::PaymentRequired, |
| 376 | format!( |
| 377 | "{} is a paid feature, and it is not on for {workspace}. An owner can turn it on under Billing on the workspace's page.", |
| 378 | a.feature.title() |
| 379 | ), |
| 380 | )) |
| 381 | } |
| 382 | |
| 383 | pub(crate) async fn charge_feature(&self, a: ChargeFeatureArgs) -> Result<Outcome<bool>> { |
| 384 | if self.stripe.is_none() || a.cost_micros <= 0 { |
| 385 | return Ok(Outcome::Ok(false)); |
| 386 | } |
| 387 | let workspace = a.workspace.to_lowercase(); |
| 388 | let seen = self |
| 389 | .db |
| 390 | .prepare("SELECT id FROM ledger WHERE reference = ?") |
| 391 | .bind(&[a.reference.as_str().into()])? |
| 392 | .first::<Touched>(None) |
| 393 | .await?; |
| 394 | if seen.is_some() { |
| 395 | return Ok(Outcome::Ok(false)); |
| 396 | } |
| 397 | let cost = a.cost_micros as f64 / MICROS_PER_DOLLAR as f64; |
| 398 | // Never free: the margin applies whatever FREE_WHILE_BUILDING says. |
| 399 | let charge = crate::charge_micros(cost, self.margin_percent); |
| 400 | let now = now_ms(); |
| 401 | let timestamp = rfc3339(now); |
| 402 | self.db |
| 403 | .batch(vec![ |
| 404 | self.db |
| 405 | .prepare( |
| 406 | "INSERT INTO ledger |
| 407 | (id, workspace, kind, amount_micros, description, repo, task, |
| 408 | cost_micros, reference, created_at, billed_to) |
| 409 | VALUES (?, ?, 'usage', ?, ?, ?, ?, ?, ?, ?, 'g1t')", |
| 410 | ) |
| 411 | .bind(&[ |
| 412 | new_id("led", now).into(), |
| 413 | workspace.as_str().into(), |
| 414 | (-(charge as f64)).into(), |
| 415 | a.description.as_str().into(), |
| 416 | optional(a.repo.as_deref()), |
| 417 | a.feature.as_str().into(), |
| 418 | (a.cost_micros as f64).into(), |
| 419 | a.reference.as_str().into(), |
| 420 | timestamp.as_str().into(), |
| 421 | ])?, |
| 422 | self.db |
| 423 | .prepare( |
| 424 | "INSERT INTO accounts (workspace, balance_micros, created_at) |
| 425 | VALUES (?1, ?2, ?3) |
| 426 | ON CONFLICT (workspace) DO UPDATE SET balance_micros = balance_micros + ?2", |
| 427 | ) |
| 428 | .bind(&[ |
| 429 | workspace.as_str().into(), |
| 430 | (-(charge as f64)).into(), |
| 431 | timestamp.as_str().into(), |
| 432 | ])?, |
| 433 | ]) |
| 434 | .await?; |
| 435 | Ok(Outcome::Ok(true)) |
| 436 | } |
| 437 | } |
| 438 | |
| 439 | #[cfg(test)] |
| 440 | mod tests { |
| 441 | use super::*; |
| 442 | |
| 443 | #[test] |
| 444 | fn prices_under_a_cent_keep_their_digits() { |
| 445 | assert_eq!(dollars(1512), "$0.0015"); |
| 446 | assert_eq!(dollars(24_000), "$0.024"); |
| 447 | assert_eq!(dollars(360_000), "$0.36"); |
| 448 | assert_eq!(dollars(5_000_000), "$5.00"); |
| 449 | } |
| 450 | } |