pr_01m47d24b0e6n91zwymwxg0vpx/services/billing/src/features.rs

455 lines18,675 bytesCodeBlame
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
6use g1t_contracts::billing::deployments_allowance as allowance;
7use g1t_contracts::billing::*;
8use g1t_contracts::time::rfc3339;
9use g1t_contracts::{FailureCode, Outcome, Role, new_id};
10use g1t_kit::now_ms;
11use serde::Deserialize;
12use worker::Result;
13
14use crate::stripe::{StripeSubscription, is_missing};
15use crate::{Billing, Touched, members_only, optional};
16
17#[derive(Deserialize)]
18struct 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)]
28struct PlanCheckoutRow {
29 workspace: String,
30 created_by: String,
31 feature: String,
32}
33
34fn 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
43fn 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.
53fn 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.
64pub(crate) 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
71impl 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
83impl 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 pub(crate) 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 // Comped accounts have every feature without a plan.
372 if self.terms_of(&workspace).await?.kind == g1t_contracts::billing::TermsKind::Comped {
373 return Ok(Outcome::Ok(true));
374 }
375 if self.state(&workspace, a.feature).await?.on {
376 return Ok(Outcome::Ok(true));
377 }
378 Ok(Outcome::fail(
379 FailureCode::PaymentRequired,
380 format!(
381 "{} is a paid feature, and it is not on for {workspace}. An owner can turn it on under Billing on the workspace's page.",
382 a.feature.title()
383 ),
384 ))
385 }
386
387 pub(crate) async fn charge_feature(&self, a: ChargeFeatureArgs) -> Result<Outcome<bool>> {
388 if self.stripe.is_none() || a.cost_micros <= 0 {
389 return Ok(Outcome::Ok(false));
390 }
391 let workspace = a.workspace.to_lowercase();
392 let seen = self
393 .db
394 .prepare("SELECT id FROM ledger WHERE reference = ?")
395 .bind(&[a.reference.as_str().into()])?
396 .first::<Touched>(None)
397 .await?;
398 if seen.is_some() {
399 return Ok(Outcome::Ok(false));
400 }
401 let cost = a.cost_micros as f64 / MICROS_PER_DOLLAR as f64;
402 // Never free: the margin applies whatever FREE_WHILE_BUILDING says,
403 // and only the account's terms change it.
404 let charge = self.terms_of(&workspace).await?.apply(crate::charge_micros(cost, self.margin_percent));
405 let now = now_ms();
406 let timestamp = rfc3339(now);
407 self.db
408 .batch(vec![
409 self.db
410 .prepare(
411 "INSERT INTO ledger
412 (id, workspace, kind, amount_micros, description, repo, task,
413 cost_micros, reference, created_at, billed_to)
414 VALUES (?, ?, 'usage', ?, ?, ?, ?, ?, ?, ?, 'g1t')",
415 )
416 .bind(&[
417 new_id("led", now).into(),
418 workspace.as_str().into(),
419 (-(charge as f64)).into(),
420 a.description.as_str().into(),
421 optional(a.repo.as_deref()),
422 a.feature.as_str().into(),
423 (a.cost_micros as f64).into(),
424 a.reference.as_str().into(),
425 timestamp.as_str().into(),
426 ])?,
427 self.db
428 .prepare(
429 "INSERT INTO accounts (workspace, balance_micros, created_at)
430 VALUES (?1, ?2, ?3)
431 ON CONFLICT (workspace) DO UPDATE SET balance_micros = balance_micros + ?2",
432 )
433 .bind(&[
434 workspace.as_str().into(),
435 (-(charge as f64)).into(),
436 timestamp.as_str().into(),
437 ])?,
438 ])
439 .await?;
440 Ok(Outcome::Ok(true))
441 }
442}
443
444#[cfg(test)]
445mod tests {
446 use super::*;
447
448 #[test]
449 fn prices_under_a_cent_keep_their_digits() {
450 assert_eq!(dollars(1512), "$0.0015");
451 assert_eq!(dollars(24_000), "$0.024");
452 assert_eq!(dollars(360_000), "$0.36");
453 assert_eq!(dollars(5_000_000), "$5.00");
454 }
455}