g1t/services/billing/src/features.rs

402 lines16,242 bytesCodeBlame

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 plan1//! 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
62fn dollars(micros: i64) -> String {
63 format!("${:.2}", micros as f64 / MICROS_PER_DOLLAR as f64)
64}
65
66impl 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
78impl 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}