flagon-io/g1t

public

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t/services/billing/src/features.rs

463 lines19,092 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
Project dependencies: addresses, preview stacks, Affects, and agents who know14use crate::stripe::{StripeSubscription, is_missing};
Paid features: a workspace turns on Deployments with a monthly plan15use 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
Deployments: a preview for every pull request, production on g1t.page62/// 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.
Usage limits: unpaid usage can only go so far64pub(crate) fn dollars(micros: i64) -> String {
Deployments: a preview for every pull request, production on g1t.page65 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 plan69}
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),
Agents and memory, checks and conflicts, profiles, slug renames, custom domains97 format!(
98 "{} custom domains, with certificates, then {} each a month",
99 allowance::CUSTOM_DOMAINS,
100 dollars(crate::charge_micros(
101 allowance::MICROS_PER_DOMAIN_MONTH as f64 / MICROS_PER_DOLLAR as f64,
102 self.margin_percent
103 )),
104 ),
Paid features: a workspace turns on Deployments with a monthly plan105 "Previews that cost nothing while no one visits them".to_owned(),
106 ],
107 overage: format!(
Deployments: a preview for every pull request, production on g1t.page108 "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 plan109 dollars(crate::charge_micros(
110 allowance::MICROS_PER_APP_MONTH as f64 / MICROS_PER_DOLLAR as f64,
111 self.margin_percent
112 )),
113 dollars(crate::charge_micros(
114 allowance::MICROS_PER_MILLION_REQUESTS as f64 / MICROS_PER_DOLLAR as f64,
115 self.margin_percent
116 )),
117 dollars(crate::charge_micros(
118 allowance::MICROS_PER_MILLION_CPU_MS as f64 / MICROS_PER_DOLLAR as f64,
119 self.margin_percent
120 )),
Deployments: a preview for every pull request, production on g1t.page121 self.margin_percent,
122 dollars(crate::charge_micros(
123 (allowance::MICROS_PER_BUILD_SECOND * 60) as f64 / MICROS_PER_DOLLAR as f64,
124 self.margin_percent
125 )),
Paid features: a workspace turns on Deployments with a monthly plan126 ),
127 },
128 }
129 }
130
131 async fn subscription_row(&self, workspace: &str, feature: Feature) -> Result<Option<SubscriptionRow>> {
132 self.db
133 .prepare(
134 "SELECT feature, subscription_id, status, period_end, started_by, started_at
135 FROM subscriptions WHERE workspace = ? AND feature = ?",
136 )
137 .bind(&[workspace.into(), feature.as_str().into()])?
138 .first::<SubscriptionRow>(None)
139 .await
140 }
141
142 /// Writes down what the processor says about a plan.
Stripe webhooks, enterprise invoices, and sudo for both143 pub(crate) async fn record(
Paid features: a workspace turns on Deployments with a monthly plan144 &self,
145 workspace: &str,
146 feature: Feature,
147 subscription: &StripeSubscription,
148 started_by: &str,
149 ) -> Result<()> {
150 let now = rfc3339(now_ms());
151 let period_end = subscription.period_end().map(|seconds| rfc3339(seconds.max(0) as u64 * 1000));
152 self.db
153 .prepare(
154 "INSERT INTO subscriptions
155 (workspace, feature, subscription_id, status, period_end, started_by, started_at, updated_at)
156 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?7)
157 ON CONFLICT (workspace, feature) DO UPDATE SET
158 subscription_id = ?3, status = ?4, period_end = ?5, updated_at = ?7,
159 started_by = CASE WHEN subscription_id = ?3 THEN started_by ELSE ?6 END,
160 started_at = CASE WHEN subscription_id = ?3 THEN started_at ELSE ?7 END",
161 )
162 .bind(&[
163 workspace.into(),
164 feature.as_str().into(),
165 subscription.id.as_str().into(),
166 status_text(status_of(subscription)).into(),
167 optional(period_end.as_deref()),
168 started_by.into(),
169 now.as_str().into(),
170 ])?
171 .run()
172 .await?;
173 Ok(())
174 }
175
176 /// A workspace's plan for a feature, asking the processor again once
177 /// the period it last knew of is over.
178 async fn current(&self, workspace: &str, feature: Feature) -> Result<Option<SubscriptionRow>> {
179 let Some(row) = self.subscription_row(workspace, feature).await? else {
180 return Ok(None);
181 };
182 let stale = row.period_end.as_deref().is_none_or(|end| end <= rfc3339(now_ms()).as_str())
183 && row.status != "canceled";
184 if let (true, Some(stripe)) = (stale, &self.stripe) {
Project dependencies: addresses, preview stacks, Affects, and agents who know185 match stripe.subscription(&row.subscription_id).await {
186 Ok(subscription) => self.record(workspace, feature, &subscription, &row.started_by).await?,
187 // A plan from another Stripe account: it has ended here.
188 Err(error) if is_missing(&error) => {
189 self.db
190 .prepare("UPDATE subscriptions SET status = 'canceled', updated_at = ? WHERE workspace = ? AND feature = ?")
191 .bind(&[rfc3339(now_ms()).into(), workspace.into(), feature.as_str().into()])?
192 .run()
193 .await?;
194 }
195 Err(error) => return Err(error),
196 }
Paid features: a workspace turns on Deployments with a monthly plan197 return self.subscription_row(workspace, feature).await;
198 }
199 Ok(Some(row))
200 }
201
202 async fn state(&self, workspace: &str, feature: Feature) -> Result<FeatureState> {
203 let subscription = self
204 .current(workspace, feature)
205 .await?
206 .and_then(|row| row.subscription());
207 Ok(FeatureState {
208 plan: self.plan(feature),
209 on: self.stripe.is_none() || subscription.as_ref().is_some_and(|s| s.status.on()),
210 subscription,
211 })
212 }
213
214 pub(crate) async fn features(&self, a: FeaturesArgs) -> Result<Outcome<Vec<FeatureState>>> {
215 let workspace = a.workspace.to_lowercase();
216 if !a.viewer.is_some_and(|viewer| viewer.is_member(&workspace)) {
217 return Ok(members_only());
218 }
219 let mut states = Vec::new();
220 for feature in Feature::ALL {
221 states.push(self.state(&workspace, feature).await?);
222 }
223 Ok(Outcome::Ok(states))
224 }
225
226 pub(crate) async fn subscribe(&self, a: SubscribeArgs) -> Result<Outcome<Checkout>> {
227 let workspace = a.workspace.to_lowercase();
228 if a.actor.role_in(&workspace) != Some(Role::Owner) {
229 return Ok(Outcome::fail(
230 FailureCode::Forbidden,
231 "Only an owner can turn on a paid feature.",
232 ));
233 }
234 let Some(stripe) = &self.stripe else {
235 return Ok(Outcome::fail(
236 FailureCode::Conflict,
237 "Payments are not set up on this g1t, so every feature is already on.",
238 ));
239 };
240 if self.state(&workspace, a.feature).await?.subscription.is_some_and(|s| s.status.on()) {
241 return Ok(Outcome::fail(
242 FailureCode::Conflict,
243 format!("{} is already on for {workspace}.", a.feature.title()),
244 ));
245 }
246 let plan = self.plan(a.feature);
247 let customer = self.row(&workspace).await?.and_then(|row| row.customer_id);
Project dependencies: addresses, preview stacks, Affects, and agents who know248 let start = |customer: Option<String>| {
249 let plan = &plan;
250 let workspace = &workspace;
251 let return_url = &a.return_url;
252 async move {
253 stripe
254 .start_subscription(
255 workspace,
256 a.feature.as_str(),
257 &plan.title,
258 plan.monthly_cents,
259 customer.as_deref(),
260 return_url,
261 )
262 .await
263 }
264 };
265 let session = match start(customer.clone()).await {
266 Ok(session) => session,
267 // A customer saved under another Stripe account: start afresh.
268 Err(error) if customer.is_some() && is_missing(&error) => {
269 self.forget_customer(&workspace).await?;
270 start(None).await?
271 }
272 Err(error) => return Err(error),
273 };
Paid features: a workspace turns on Deployments with a monthly plan274 let Some(url) = session.url else {
275 return Err(worker::Error::RustError(
276 "the card processor returned no payment page".into(),
277 ));
278 };
279 self.db
280 .prepare(
281 "INSERT INTO checkouts (id, workspace, amount_cents, created_by, created_at, feature)
282 VALUES (?, ?, ?, ?, ?, ?)",
283 )
284 .bind(&[
285 session.id.into(),
286 workspace.into(),
287 plan.monthly_cents.into(),
288 a.actor.username.into(),
289 rfc3339(now_ms()).into(),
290 a.feature.as_str().into(),
291 ])?
292 .run()
293 .await?;
294 Ok(Outcome::Ok(Checkout { url }))
295 }
296
297 pub(crate) async fn confirm_subscription(
298 &self,
299 a: ConfirmSubscriptionArgs,
300 ) -> Result<Outcome<FeatureState>> {
301 let workspace = a.workspace.to_lowercase();
302 if !a.viewer.is_some_and(|viewer| viewer.is_member(&workspace)) {
303 return Ok(members_only());
304 }
305 let checkout = self
306 .db
307 .prepare(
308 "SELECT workspace, created_by, feature FROM checkouts
309 WHERE id = ? AND workspace = ? AND status = 'open' AND feature IS NOT NULL",
310 )
311 .bind(&[a.session.as_str().into(), workspace.as_str().into()])?
312 .first::<PlanCheckoutRow>(None)
313 .await?;
314 let (Some(stripe), Some(checkout)) = (&self.stripe, checkout) else {
315 // Unknown, someone else's, or already done: show where it stands.
316 return Ok(Outcome::Ok(self.state(&workspace, Feature::Deployments).await?));
317 };
318 let Some(feature) = Feature::parse(&checkout.feature) else {
319 return Ok(Outcome::fail(FailureCode::NotFound, "No such feature."));
320 };
321 let session = stripe.session(&a.session).await?;
322 if let (Some(subscription_id), true) = (&session.subscription, session.payment_status == "paid") {
323 let claimed = self
324 .db
325 .prepare("UPDATE checkouts SET status = 'paid' WHERE id = ? AND status = 'open' RETURNING id")
326 .bind(&[a.session.as_str().into()])?
327 .first::<Touched>(None)
328 .await?;
329 if claimed.is_some() {
330 let subscription = stripe.subscription(subscription_id).await?;
331 self.record(&checkout.workspace, feature, &subscription, &checkout.created_by)
332 .await?;
333 // Keep the card's customer, so later payments need no retyping.
334 self.db
335 .prepare(
336 "INSERT INTO accounts (workspace, balance_micros, customer_id, created_at)
337 VALUES (?1, 0, ?2, ?3)
338 ON CONFLICT (workspace) DO UPDATE SET customer_id = COALESCE(customer_id, ?2)",
339 )
340 .bind(&[
341 checkout.workspace.as_str().into(),
342 optional(session.customer.as_deref()),
343 rfc3339(now_ms()).into(),
344 ])?
345 .run()
346 .await?;
347 }
348 }
349 Ok(Outcome::Ok(self.state(&workspace, feature).await?))
350 }
351
352 pub(crate) async fn cancel_subscription(
353 &self,
354 a: CancelSubscriptionArgs,
355 ) -> Result<Outcome<FeatureState>> {
356 let workspace = a.workspace.to_lowercase();
357 if a.actor.role_in(&workspace) != Some(Role::Owner) {
358 return Ok(Outcome::fail(
359 FailureCode::Forbidden,
360 "Only an owner can change a workspace's plans.",
361 ));
362 }
363 let (Some(stripe), Some(row)) = (&self.stripe, self.current(&workspace, a.feature).await?) else {
364 return Ok(Outcome::fail(
365 FailureCode::NotFound,
366 format!("{} is not on for {workspace}.", a.feature.title()),
367 ));
368 };
369 let subscription = stripe
370 .cancel_at_period_end(&row.subscription_id, !a.resume)
371 .await?;
372 self.record(&workspace, a.feature, &subscription, &row.started_by)
373 .await?;
374 Ok(Outcome::Ok(self.state(&workspace, a.feature).await?))
375 }
376
377 pub(crate) async fn has_feature(&self, a: HasFeatureArgs) -> Result<Outcome<bool>> {
378 let workspace = a.workspace.to_lowercase();
Billing accounts, terms and enterprises; g1t is no longer free379 // Comped accounts have every feature without a plan.
380 if self.terms_of(&workspace).await?.kind == g1t_contracts::billing::TermsKind::Comped {
381 return Ok(Outcome::Ok(true));
382 }
Paid features: a workspace turns on Deployments with a monthly plan383 if self.state(&workspace, a.feature).await?.on {
384 return Ok(Outcome::Ok(true));
385 }
386 Ok(Outcome::fail(
387 FailureCode::PaymentRequired,
388 format!(
389 "{} is a paid feature, and it is not on for {workspace}. An owner can turn it on under Billing on the workspace's page.",
390 a.feature.title()
391 ),
392 ))
393 }
394
395 pub(crate) async fn charge_feature(&self, a: ChargeFeatureArgs) -> Result<Outcome<bool>> {
396 if self.stripe.is_none() || a.cost_micros <= 0 {
397 return Ok(Outcome::Ok(false));
398 }
399 let workspace = a.workspace.to_lowercase();
400 let seen = self
401 .db
402 .prepare("SELECT id FROM ledger WHERE reference = ?")
403 .bind(&[a.reference.as_str().into()])?
404 .first::<Touched>(None)
405 .await?;
406 if seen.is_some() {
407 return Ok(Outcome::Ok(false));
408 }
409 let cost = a.cost_micros as f64 / MICROS_PER_DOLLAR as f64;
Billing accounts, terms and enterprises; g1t is no longer free410 // Never free: the margin applies whatever FREE_WHILE_BUILDING says,
411 // and only the account's terms change it.
412 let charge = self.terms_of(&workspace).await?.apply(crate::charge_micros(cost, self.margin_percent));
Paid features: a workspace turns on Deployments with a monthly plan413 let now = now_ms();
414 let timestamp = rfc3339(now);
415 self.db
416 .batch(vec![
417 self.db
418 .prepare(
419 "INSERT INTO ledger
420 (id, workspace, kind, amount_micros, description, repo, task,
421 cost_micros, reference, created_at, billed_to)
422 VALUES (?, ?, 'usage', ?, ?, ?, ?, ?, ?, ?, 'g1t')",
423 )
424 .bind(&[
425 new_id("led", now).into(),
426 workspace.as_str().into(),
427 (-(charge as f64)).into(),
428 a.description.as_str().into(),
429 optional(a.repo.as_deref()),
430 a.feature.as_str().into(),
431 (a.cost_micros as f64).into(),
432 a.reference.as_str().into(),
433 timestamp.as_str().into(),
434 ])?,
435 self.db
436 .prepare(
437 "INSERT INTO accounts (workspace, balance_micros, created_at)
438 VALUES (?1, ?2, ?3)
439 ON CONFLICT (workspace) DO UPDATE SET balance_micros = balance_micros + ?2",
440 )
441 .bind(&[
442 workspace.as_str().into(),
443 (-(charge as f64)).into(),
444 timestamp.as_str().into(),
445 ])?,
446 ])
447 .await?;
448 Ok(Outcome::Ok(true))
449 }
450}
Deployments: a preview for every pull request, production on g1t.page451
452#[cfg(test)]
453mod tests {
454 use super::*;
455
456 #[test]
457 fn prices_under_a_cent_keep_their_digits() {
458 assert_eq!(dollars(1512), "$0.0015");
459 assert_eq!(dollars(24_000), "$0.024");
460 assert_eq!(dollars(360_000), "$0.36");
461 assert_eq!(dollars(5_000_000), "$5.00");
462 }
463}