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