pr_01m47d15m3e54sn21z27rpy5n9/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; | |
| 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; | |
| 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 | ||
| Deployments: a preview for every pull request, production on g1t.page | 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. | |
| Paid features: a workspace turns on Deployments with a monthly plan | 64 | fn dollars(micros: i64) -> String { |
| Deployments: a preview for every pull request, production on g1t.page | 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}") | |
| Paid features: a workspace turns on Deployments with a monthly plan | 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!( | |
| Deployments: a preview for every pull request, production on g1t.page | 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.", |
| Paid features: a workspace turns on Deployments with a monthly plan | 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 | )), | |
| Deployments: a preview for every pull request, production on g1t.page | 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 | )), | |
| Paid features: a workspace turns on Deployments with a monthly plan | 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 | let subscription = stripe.subscription(&row.subscription_id).await?; | |
| 178 | self.record(workspace, feature, &subscription, &row.started_by).await?; | |
| 179 | return self.subscription_row(workspace, feature).await; | |
| 180 | } | |
| 181 | Ok(Some(row)) | |
| 182 | } | |
| 183 | ||
| 184 | async fn state(&self, workspace: &str, feature: Feature) -> Result<FeatureState> { | |
| 185 | let subscription = self | |
| 186 | .current(workspace, feature) | |
| 187 | .await? | |
| 188 | .and_then(|row| row.subscription()); | |
| 189 | Ok(FeatureState { | |
| 190 | plan: self.plan(feature), | |
| 191 | on: self.stripe.is_none() || subscription.as_ref().is_some_and(|s| s.status.on()), | |
| 192 | subscription, | |
| 193 | }) | |
| 194 | } | |
| 195 | ||
| 196 | pub(crate) async fn features(&self, a: FeaturesArgs) -> Result<Outcome<Vec<FeatureState>>> { | |
| 197 | let workspace = a.workspace.to_lowercase(); | |
| 198 | if !a.viewer.is_some_and(|viewer| viewer.is_member(&workspace)) { | |
| 199 | return Ok(members_only()); | |
| 200 | } | |
| 201 | let mut states = Vec::new(); | |
| 202 | for feature in Feature::ALL { | |
| 203 | states.push(self.state(&workspace, feature).await?); | |
| 204 | } | |
| 205 | Ok(Outcome::Ok(states)) | |
| 206 | } | |
| 207 | ||
| 208 | pub(crate) async fn subscribe(&self, a: SubscribeArgs) -> Result<Outcome<Checkout>> { | |
| 209 | let workspace = a.workspace.to_lowercase(); | |
| 210 | if a.actor.role_in(&workspace) != Some(Role::Owner) { | |
| 211 | return Ok(Outcome::fail( | |
| 212 | FailureCode::Forbidden, | |
| 213 | "Only an owner can turn on a paid feature.", | |
| 214 | )); | |
| 215 | } | |
| 216 | let Some(stripe) = &self.stripe else { | |
| 217 | return Ok(Outcome::fail( | |
| 218 | FailureCode::Conflict, | |
| 219 | "Payments are not set up on this g1t, so every feature is already on.", | |
| 220 | )); | |
| 221 | }; | |
| 222 | if self.state(&workspace, a.feature).await?.subscription.is_some_and(|s| s.status.on()) { | |
| 223 | return Ok(Outcome::fail( | |
| 224 | FailureCode::Conflict, | |
| 225 | format!("{} is already on for {workspace}.", a.feature.title()), | |
| 226 | )); | |
| 227 | } | |
| 228 | let plan = self.plan(a.feature); | |
| 229 | let customer = self.row(&workspace).await?.and_then(|row| row.customer_id); | |
| 230 | let session = stripe | |
| 231 | .start_subscription( | |
| 232 | &workspace, | |
| 233 | a.feature.as_str(), | |
| 234 | &plan.title, | |
| 235 | plan.monthly_cents, | |
| 236 | customer.as_deref(), | |
| 237 | &a.return_url, | |
| 238 | ) | |
| 239 | .await?; | |
| 240 | let Some(url) = session.url else { | |
| 241 | return Err(worker::Error::RustError( | |
| 242 | "the card processor returned no payment page".into(), | |
| 243 | )); | |
| 244 | }; | |
| 245 | self.db | |
| 246 | .prepare( | |
| 247 | "INSERT INTO checkouts (id, workspace, amount_cents, created_by, created_at, feature) | |
| 248 | VALUES (?, ?, ?, ?, ?, ?)", | |
| 249 | ) | |
| 250 | .bind(&[ | |
| 251 | session.id.into(), | |
| 252 | workspace.into(), | |
| 253 | plan.monthly_cents.into(), | |
| 254 | a.actor.username.into(), | |
| 255 | rfc3339(now_ms()).into(), | |
| 256 | a.feature.as_str().into(), | |
| 257 | ])? | |
| 258 | .run() | |
| 259 | .await?; | |
| 260 | Ok(Outcome::Ok(Checkout { url })) | |
| 261 | } | |
| 262 | ||
| 263 | pub(crate) async fn confirm_subscription( | |
| 264 | &self, | |
| 265 | a: ConfirmSubscriptionArgs, | |
| 266 | ) -> Result<Outcome<FeatureState>> { | |
| 267 | let workspace = a.workspace.to_lowercase(); | |
| 268 | if !a.viewer.is_some_and(|viewer| viewer.is_member(&workspace)) { | |
| 269 | return Ok(members_only()); | |
| 270 | } | |
| 271 | let checkout = self | |
| 272 | .db | |
| 273 | .prepare( | |
| 274 | "SELECT workspace, created_by, feature FROM checkouts | |
| 275 | WHERE id = ? AND workspace = ? AND status = 'open' AND feature IS NOT NULL", | |
| 276 | ) | |
| 277 | .bind(&[a.session.as_str().into(), workspace.as_str().into()])? | |
| 278 | .first::<PlanCheckoutRow>(None) | |
| 279 | .await?; | |
| 280 | let (Some(stripe), Some(checkout)) = (&self.stripe, checkout) else { | |
| 281 | // Unknown, someone else's, or already done: show where it stands. | |
| 282 | return Ok(Outcome::Ok(self.state(&workspace, Feature::Deployments).await?)); | |
| 283 | }; | |
| 284 | let Some(feature) = Feature::parse(&checkout.feature) else { | |
| 285 | return Ok(Outcome::fail(FailureCode::NotFound, "No such feature.")); | |
| 286 | }; | |
| 287 | let session = stripe.session(&a.session).await?; | |
| 288 | if let (Some(subscription_id), true) = (&session.subscription, session.payment_status == "paid") { | |
| 289 | let claimed = self | |
| 290 | .db | |
| 291 | .prepare("UPDATE checkouts SET status = 'paid' WHERE id = ? AND status = 'open' RETURNING id") | |
| 292 | .bind(&[a.session.as_str().into()])? | |
| 293 | .first::<Touched>(None) | |
| 294 | .await?; | |
| 295 | if claimed.is_some() { | |
| 296 | let subscription = stripe.subscription(subscription_id).await?; | |
| 297 | self.record(&checkout.workspace, feature, &subscription, &checkout.created_by) | |
| 298 | .await?; | |
| 299 | // Keep the card's customer, so later payments need no retyping. | |
| 300 | self.db | |
| 301 | .prepare( | |
| 302 | "INSERT INTO accounts (workspace, balance_micros, customer_id, created_at) | |
| 303 | VALUES (?1, 0, ?2, ?3) | |
| 304 | ON CONFLICT (workspace) DO UPDATE SET customer_id = COALESCE(customer_id, ?2)", | |
| 305 | ) | |
| 306 | .bind(&[ | |
| 307 | checkout.workspace.as_str().into(), | |
| 308 | optional(session.customer.as_deref()), | |
| 309 | rfc3339(now_ms()).into(), | |
| 310 | ])? | |
| 311 | .run() | |
| 312 | .await?; | |
| 313 | } | |
| 314 | } | |
| 315 | Ok(Outcome::Ok(self.state(&workspace, feature).await?)) | |
| 316 | } | |
| 317 | ||
| 318 | pub(crate) async fn cancel_subscription( | |
| 319 | &self, | |
| 320 | a: CancelSubscriptionArgs, | |
| 321 | ) -> Result<Outcome<FeatureState>> { | |
| 322 | let workspace = a.workspace.to_lowercase(); | |
| 323 | if a.actor.role_in(&workspace) != Some(Role::Owner) { | |
| 324 | return Ok(Outcome::fail( | |
| 325 | FailureCode::Forbidden, | |
| 326 | "Only an owner can change a workspace's plans.", | |
| 327 | )); | |
| 328 | } | |
| 329 | let (Some(stripe), Some(row)) = (&self.stripe, self.current(&workspace, a.feature).await?) else { | |
| 330 | return Ok(Outcome::fail( | |
| 331 | FailureCode::NotFound, | |
| 332 | format!("{} is not on for {workspace}.", a.feature.title()), | |
| 333 | )); | |
| 334 | }; | |
| 335 | let subscription = stripe | |
| 336 | .cancel_at_period_end(&row.subscription_id, !a.resume) | |
| 337 | .await?; | |
| 338 | self.record(&workspace, a.feature, &subscription, &row.started_by) | |
| 339 | .await?; | |
| 340 | Ok(Outcome::Ok(self.state(&workspace, a.feature).await?)) | |
| 341 | } | |
| 342 | ||
| 343 | pub(crate) async fn has_feature(&self, a: HasFeatureArgs) -> Result<Outcome<bool>> { | |
| 344 | let workspace = a.workspace.to_lowercase(); | |
| 345 | if self.state(&workspace, a.feature).await?.on { | |
| 346 | return Ok(Outcome::Ok(true)); | |
| 347 | } | |
| 348 | Ok(Outcome::fail( | |
| 349 | FailureCode::PaymentRequired, | |
| 350 | format!( | |
| 351 | "{} is a paid feature, and it is not on for {workspace}. An owner can turn it on under Billing on the workspace's page.", | |
| 352 | a.feature.title() | |
| 353 | ), | |
| 354 | )) | |
| 355 | } | |
| 356 | ||
| 357 | pub(crate) async fn charge_feature(&self, a: ChargeFeatureArgs) -> Result<Outcome<bool>> { | |
| 358 | if self.stripe.is_none() || a.cost_micros <= 0 { | |
| 359 | return Ok(Outcome::Ok(false)); | |
| 360 | } | |
| 361 | let workspace = a.workspace.to_lowercase(); | |
| 362 | let seen = self | |
| 363 | .db | |
| 364 | .prepare("SELECT id FROM ledger WHERE reference = ?") | |
| 365 | .bind(&[a.reference.as_str().into()])? | |
| 366 | .first::<Touched>(None) | |
| 367 | .await?; | |
| 368 | if seen.is_some() { | |
| 369 | return Ok(Outcome::Ok(false)); | |
| 370 | } | |
| 371 | let cost = a.cost_micros as f64 / MICROS_PER_DOLLAR as f64; | |
| 372 | // Never free: the margin applies whatever FREE_WHILE_BUILDING says. | |
| 373 | let charge = crate::charge_micros(cost, self.margin_percent); | |
| 374 | let now = now_ms(); | |
| 375 | let timestamp = rfc3339(now); | |
| 376 | self.db | |
| 377 | .batch(vec![ | |
| 378 | self.db | |
| 379 | .prepare( | |
| 380 | "INSERT INTO ledger | |
| 381 | (id, workspace, kind, amount_micros, description, repo, task, | |
| 382 | cost_micros, reference, created_at, billed_to) | |
| 383 | VALUES (?, ?, 'usage', ?, ?, ?, ?, ?, ?, ?, 'g1t')", | |
| 384 | ) | |
| 385 | .bind(&[ | |
| 386 | new_id("led", now).into(), | |
| 387 | workspace.as_str().into(), | |
| 388 | (-(charge as f64)).into(), | |
| 389 | a.description.as_str().into(), | |
| 390 | optional(a.repo.as_deref()), | |
| 391 | a.feature.as_str().into(), | |
| 392 | (a.cost_micros as f64).into(), | |
| 393 | a.reference.as_str().into(), | |
| 394 | timestamp.as_str().into(), | |
| 395 | ])?, | |
| 396 | self.db | |
| 397 | .prepare( | |
| 398 | "INSERT INTO accounts (workspace, balance_micros, created_at) | |
| 399 | VALUES (?1, ?2, ?3) | |
| 400 | ON CONFLICT (workspace) DO UPDATE SET balance_micros = balance_micros + ?2", | |
| 401 | ) | |
| 402 | .bind(&[ | |
| 403 | workspace.as_str().into(), | |
| 404 | (-(charge as f64)).into(), | |
| 405 | timestamp.as_str().into(), | |
| 406 | ])?, | |
| 407 | ]) | |
| 408 | .await?; | |
| 409 | Ok(Outcome::Ok(true)) | |
| 410 | } | |
| 411 | } | |
| Deployments: a preview for every pull request, production on g1t.page | 412 | |
| 413 | #[cfg(test)] | |
| 414 | mod tests { | |
| 415 | use super::*; | |
| 416 | ||
| 417 | #[test] | |
| 418 | fn prices_under_a_cent_keep_their_digits() { | |
| 419 | assert_eq!(dollars(1512), "$0.0015"); | |
| 420 | assert_eq!(dollars(24_000), "$0.024"); | |
| 421 | assert_eq!(dollars(360_000), "$0.36"); | |
| 422 | assert_eq!(dollars(5_000_000), "$5.00"); | |
| 423 | } | |
| 424 | } |