g1t/services/billing/src/features.rs

424 lines17,111 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;
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.
64fn 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 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}
412
413#[cfg(test)]
414mod 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}