g1t/services/billing/src/features.rs

708 lines31,449 bytesCodeBlame
1//! The g1t plan: one monthly price per workspace, never per person, that
2//! includes $10 of usage. Everything that costs g1t money is metered from
3//! the first unit at cost plus the margin and drawn from that $10 first;
4//! past it, it is charged, up to the workspace's spend limit. There are no
5//! per-feature quotas: no count of apps, build minutes, requests or
6//! domains ever stops a workspace on the plan. Only its spend limit does
7//! (and g1t's protections against abuse). Projects, previews and
8//! repositories cost g1t next to nothing and are not metered. None of it is
9//! free, whatever `FREE_WHILE_BUILDING` says.
10//!
11//! Deployments were once a plan of their own. They come with the g1t plan
12//! now: `has_feature(deployments)` answers whether the workspace has the
13//! plan, and a Deployments subscription from before keeps working until
14//! its period ends. Billing sets each one to end then, once
15//! (`retire_deployments_plans`), so no one pays for both.
16
17use g1t_contracts::billing::deployment_costs as costs;
18use g1t_contracts::billing::*;
19use g1t_contracts::time::rfc3339;
20use g1t_contracts::{FailureCode, Outcome, Role};
21use g1t_kit::now_ms;
22use serde::Deserialize;
23use worker::Result;
24
25use crate::stripe::{StripeSubscription, is_missing};
26use crate::{Billing, Touched, members_only, optional};
27
28#[derive(Deserialize)]
29struct SubscriptionRow {
30 feature: String,
31 subscription_id: String,
32 status: String,
33 period_end: Option<String>,
34 started_by: String,
35 started_at: String,
36 updated_at: String,
37}
38
39/// How long a plan's row is believed after it was last written, once its
40/// period is over, before the processor is asked again.
41const REFRESH_MS: u64 = 60 * 60 * 1000;
42
43/// Whether to ask the processor about a plan again: its period is over (or
44/// unknown) and it is not canceled, and it was not written in the last hour.
45/// Without the hour a plan the processor still shows as ended would be
46/// asked about on every page.
47fn needs_refresh(status: &str, period_end: Option<&str>, updated_at: &str, now_ms: u64) -> bool {
48 let now = rfc3339(now_ms);
49 let over = period_end.is_none_or(|end| end <= now.as_str()) && status != "canceled";
50 over && updated_at <= rfc3339(now_ms.saturating_sub(REFRESH_MS)).as_str()
51}
52
53#[derive(Deserialize)]
54struct PlanCheckoutRow {
55 workspace: String,
56 created_by: String,
57 feature: String,
58}
59
60fn status_from(text: &str) -> SubscriptionStatus {
61 match text {
62 "active" => SubscriptionStatus::Active,
63 "canceling" => SubscriptionStatus::Canceling,
64 "past_due" => SubscriptionStatus::PastDue,
65 _ => SubscriptionStatus::Canceled,
66 }
67}
68
69fn status_text(status: SubscriptionStatus) -> &'static str {
70 match status {
71 SubscriptionStatus::Active => "active",
72 SubscriptionStatus::Canceling => "canceling",
73 SubscriptionStatus::PastDue => "past_due",
74 SubscriptionStatus::Canceled => "canceled",
75 }
76}
77
78/// What the processor's state for a plan means here.
79fn status_of(subscription: &StripeSubscription) -> SubscriptionStatus {
80 match subscription.status.as_str() {
81 "active" | "trialing" if subscription.cancel_at_period_end => SubscriptionStatus::Canceling,
82 "active" | "trialing" => SubscriptionStatus::Active,
83 "past_due" | "unpaid" | "incomplete" | "paused" => SubscriptionStatus::PastDue,
84 _ => SubscriptionStatus::Canceled,
85 }
86}
87
88/// `1 GB`, `50 GB`, or `500 MB`, as storage is priced (powers of ten).
89pub(crate) fn bytes(bytes: i64) -> String {
90 if bytes >= 1_000_000_000 && bytes % 1_000_000_000 == 0 {
91 format!("{} GB", bytes / 1_000_000_000)
92 } else if bytes >= 1_000_000_000 {
93 format!("{:.1} GB", bytes as f64 / 1e9)
94 } else {
95 format!("{} MB", bytes / 1_000_000)
96 }
97}
98
99/// Dollars to the cent, or finer for prices under a cent, so that a
100/// build minute's $0.0015 does not read as nothing.
101pub(crate) fn dollars(micros: i64) -> String {
102 let text = format!("{:.4}", micros as f64 / MICROS_PER_DOLLAR as f64);
103 let (whole, fraction) = text.split_once('.').unwrap_or((&text, ""));
104 let fraction = fraction.trim_end_matches('0');
105 format!("${whole}.{fraction:0<2}")
106}
107
108/// An amount of money to the cent, as balances and amounts owed read:
109/// `$3.99`, never `$3.987`. Prices use [`dollars`].
110pub(crate) fn cents(micros: i64) -> String {
111 format!("${:.2}", micros as f64 / MICROS_PER_DOLLAR as f64)
112}
113
114/// `units` at `each` micros a unit, as the pricing page writes it: a
115/// build second's price times 60 is the build minute both quote.
116pub(crate) fn per_units(each: f64, units: f64) -> String {
117 dollars((each * units).round() as i64)
118}
119
120impl SubscriptionRow {
121 fn subscription(&self) -> Option<Subscription> {
122 Some(Subscription {
123 feature: Feature::parse(&self.feature)?,
124 status: status_from(&self.status),
125 period_end: self.period_end.clone(),
126 started_by: self.started_by.clone(),
127 started_at: self.started_at.clone(),
128 })
129 }
130}
131
132impl Billing {
133 /// What the g1t plan costs and includes, as it is sold now, at the
134 /// price book's prices (the same figures as the pricing page's table).
135 pub(crate) async fn plan(&self, _feature: Feature) -> Result<Plan> {
136 let mut book = std::collections::BTreeMap::new();
137 for meter in ["build_second", "app_requests", "app_cpu", "custom_domain_month", "private_storage", "git_operations"] {
138 if let Some((_, price)) = self.price(meter).await? {
139 book.insert(meter, price);
140 }
141 }
142 Ok(self.plan_at(&book))
143 }
144
145 /// The plan at the given prices per unit (micros, after the markup);
146 /// the published costs plus the margin for any not given.
147 pub(crate) fn plan_at(&self, book: &std::collections::BTreeMap<&str, f64>) -> Plan {
148 let p = &self.plans;
149 let price_of = |meter: &str, cost: i64, units: f64| {
150 per_units(book.get(meter).copied().unwrap_or_else(|| Price::price_for(cost as f64, self.margin_percent)), units)
151 };
152 Plan {
153 feature: Feature::Plan,
154 title: Feature::Plan.title().to_owned(),
155 monthly_cents: p.plan_monthly_cents,
156 includes: vec![
157 format!(
158 "{} of usage each month at cost plus {}%, used first",
159 dollars(p.plan_included_micros),
160 self.margin_percent
161 ),
162 "Everyone in the workspace at one price, never per person".to_owned(),
163 "Unlimited projects, previews and repositories".to_owned(),
164 "Agents, checks, workflows, the merge queue, deployments and semantic search".to_owned(),
165 format!(
166 "Usage past {} is charged at cost plus {}%, up to your spend limit",
167 dollars(p.plan_included_micros),
168 self.margin_percent
169 ),
170 ],
171 overage: format!(
172 "Everything is metered from the first unit at what it costs g1t plus {}%: sandbox time and deploy builds by the second ({} a build minute), models at what the provider charged, {} per million app requests, {} per million CPU milliseconds, {} a month per custom domain, private storage past the free {} at {} per GB-month, and git operations past the free {} a month at {} per 1,000. Unused included usage does not roll over.",
173 self.margin_percent,
174 price_of("build_second", costs::MICROS_PER_BUILD_SECOND, 60.0),
175 price_of("app_requests", costs::MICROS_PER_MILLION_REQUESTS, 1.0),
176 price_of("app_cpu", costs::MICROS_PER_MILLION_CPU_MS, 1.0),
177 price_of("custom_domain_month", costs::MICROS_PER_DOMAIN_MONTH, 1.0),
178 bytes(p.free_storage_bytes),
179 price_of("private_storage", crate::storage::STORAGE_MICROS_PER_GB_MONTH, 1.0),
180 thousands(p.git_included),
181 price_of("git_operations", crate::storage::GIT_MICROS_PER_THOUSAND, 1.0),
182 ),
183 }
184 }
185
186 /// When the workspace's plan started, and when the period paid for
187 /// ends: its first billing cycle is the first month.
188 pub(crate) async fn plan_cycle(&self, workspace: &str) -> Result<Option<(String, Option<String>)>> {
189 let row = match self.current(workspace, Feature::Plan).await? {
190 Some(row) => Some(row),
191 None => self.current(workspace, Feature::Deployments).await?,
192 };
193 Ok(row
194 .filter(|row| status_from(&row.status).on())
195 .map(|row| (row.started_at, row.period_end)))
196 }
197
198 async fn subscription_row(&self, workspace: &str, feature: Feature) -> Result<Option<SubscriptionRow>> {
199 self.db
200 .prepare(
201 "SELECT feature, subscription_id, status, period_end, started_by, started_at, updated_at
202 FROM subscriptions WHERE workspace = ? AND feature = ?",
203 )
204 .bind(&[workspace.into(), feature.as_str().into()])?
205 .first::<SubscriptionRow>(None)
206 .await
207 }
208
209 /// Writes down what the processor says about a plan.
210 pub(crate) async fn record(
211 &self,
212 workspace: &str,
213 feature: Feature,
214 subscription: &StripeSubscription,
215 started_by: &str,
216 ) -> Result<()> {
217 let now = rfc3339(now_ms());
218 let period_end = subscription.period_end().map(|seconds| rfc3339(seconds.max(0) as u64 * 1000));
219 self.db
220 .prepare(
221 "INSERT INTO subscriptions
222 (workspace, feature, subscription_id, status, period_end, started_by, started_at, updated_at)
223 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?7)
224 ON CONFLICT (workspace, feature) DO UPDATE SET
225 subscription_id = ?3, status = ?4, period_end = ?5, updated_at = ?7,
226 started_by = CASE WHEN subscription_id = ?3 THEN started_by ELSE ?6 END,
227 started_at = CASE WHEN subscription_id = ?3 THEN started_at ELSE ?7 END",
228 )
229 .bind(&[
230 workspace.into(),
231 feature.as_str().into(),
232 subscription.id.as_str().into(),
233 status_text(status_of(subscription)).into(),
234 optional(period_end.as_deref()),
235 started_by.into(),
236 now.as_str().into(),
237 ])?
238 .run()
239 .await?;
240 Ok(())
241 }
242
243 /// A workspace's plan for a feature, asking the processor again once
244 /// the period it last knew of is over, at most once an hour.
245 async fn current(&self, workspace: &str, feature: Feature) -> Result<Option<SubscriptionRow>> {
246 let Some(row) = self.subscription_row(workspace, feature).await? else {
247 return Ok(None);
248 };
249 let stale = needs_refresh(&row.status, row.period_end.as_deref(), &row.updated_at, now_ms());
250 if let (true, Some(stripe)) = (stale, &self.stripe) {
251 match stripe.subscription(&row.subscription_id).await {
252 Ok(subscription) => self.record(workspace, feature, &subscription, &row.started_by).await?,
253 // A plan from another Stripe account: it has ended here.
254 Err(error) if is_missing(&error) => {
255 self.db
256 .prepare("UPDATE subscriptions SET status = 'canceled', updated_at = ? WHERE workspace = ? AND feature = ?")
257 .bind(&[rfc3339(now_ms()).into(), workspace.into(), feature.as_str().into()])?
258 .run()
259 .await?;
260 }
261 Err(error) => return Err(error),
262 }
263 return self.subscription_row(workspace, feature).await;
264 }
265 Ok(Some(row))
266 }
267
268 /// The plan as a workspace sees it. A Deployments subscription from
269 /// before the plan shows as the plan until its period ends.
270 async fn state(&self, workspace: &str, _feature: Feature) -> Result<FeatureState> {
271 let subscription = match self.current(workspace, Feature::Plan).await?.and_then(|row| row.subscription()) {
272 Some(plan) if plan.status.on() => Some(plan),
273 plan => self
274 .current(workspace, Feature::Deployments)
275 .await?
276 .and_then(|row| row.subscription())
277 .filter(|legacy| legacy.status.on())
278 .or(plan),
279 };
280 let included = self.included(workspace).await?;
281 Ok(FeatureState {
282 plan: self.plan(Feature::Plan).await?,
283 on: included || self.stripe.is_none() || subscription.as_ref().is_some_and(|s| s.status.on()),
284 subscription,
285 included,
286 })
287 }
288
289 /// Whether the plan is on without its price: comped terms, an
290 /// enterprise's workspaces, or given by g1t staff.
291 async fn included(&self, workspace: &str) -> Result<bool> {
292 let account = self.account_of(workspace).await?;
293 Ok(account.terms.kind == g1t_contracts::billing::TermsKind::Comped
294 || account.kind == g1t_contracts::billing::AccountKind::Enterprise
295 || account.allowances.plan)
296 }
297
298 /// Sets every Deployments subscription from before the plan to end
299 /// with its period, once, so no one pays for it and the plan both.
300 /// Until then it counts as the plan.
301 pub(crate) async fn retire_deployments_plans(&self) -> Result<()> {
302 let Some(stripe) = &self.stripe else { return Ok(()) };
303 #[derive(Deserialize)]
304 struct Legacy {
305 workspace: String,
306 subscription_id: String,
307 started_by: String,
308 period_end: Option<String>,
309 }
310 let legacy = self
311 .db
312 .prepare(
313 "SELECT workspace, subscription_id, started_by, period_end FROM subscriptions
314 WHERE feature = 'deployments' AND status = 'active' LIMIT 20",
315 )
316 .all()
317 .await?
318 .results::<Legacy>()?;
319 for plan in legacy {
320 match stripe.cancel_at_period_end(&plan.subscription_id, true).await {
321 Ok(subscription) => {
322 self.record(&plan.workspace, Feature::Deployments, &subscription, &plan.started_by).await?;
323 let account = self.account_of(&plan.workspace).await?;
324 self.audit(
325 &account.id,
326 "migration",
327 &format!(
328 "{}: the Deployments plan ends {} and is not renewed; deployments come with the g1t plan now",
329 plan.workspace,
330 plan.period_end.as_deref().map_or("at the end of its period", |end| &end[..10])
331 ),
332 "billing",
333 )
334 .await?;
335 }
336 Err(error) if is_missing(&error) => {
337 self.db
338 .prepare("UPDATE subscriptions SET status = 'canceled', updated_at = ? WHERE workspace = ? AND feature = 'deployments'")
339 .bind(&[rfc3339(now_ms()).into(), plan.workspace.as_str().into()])?
340 .run()
341 .await?;
342 }
343 Err(error) => worker::console_error!("could not end {}'s Deployments plan: {error}", plan.workspace),
344 }
345 }
346 Ok(())
347 }
348
349 /// Whether the workspace's plan for the feature is paid up.
350 pub(crate) async fn plan_on(&self, workspace: &str, feature: Feature) -> Result<bool> {
351 Ok(self
352 .current(workspace, feature)
353 .await?
354 .and_then(|row| row.subscription())
355 .is_some_and(|s| s.status.on()))
356 }
357
358 pub(crate) async fn features(&self, a: FeaturesArgs) -> Result<Outcome<Vec<FeatureState>>> {
359 let workspace = a.workspace.to_lowercase();
360 if !a.viewer.is_some_and(|viewer| viewer.is_member(&workspace)) {
361 return Ok(members_only());
362 }
363 Ok(Outcome::Ok(vec![self.state(&workspace, Feature::Plan).await?]))
364 }
365
366 pub(crate) async fn subscribe(&self, a: SubscribeArgs) -> Result<Outcome<Checkout>> {
367 let workspace = a.workspace.to_lowercase();
368 if a.actor.role_in(&workspace) != Some(Role::Owner) {
369 return Ok(Outcome::fail(
370 FailureCode::Forbidden,
371 "Only an owner can turn on a paid feature.",
372 ));
373 }
374 let Some(stripe) = &self.stripe else {
375 return Ok(Outcome::fail(
376 FailureCode::Conflict,
377 "Payments are not set up on this g1t, so the plan is already on.",
378 ));
379 };
380 // Deployments come with the plan: asking for them starts the plan.
381 let feature = Feature::Plan;
382 let state = self.state(&workspace, feature).await?;
383 if state.included {
384 return Ok(Outcome::fail(
385 FailureCode::Conflict,
386 format!("The g1t plan is included for {workspace} already, at no charge."),
387 ));
388 }
389 if self.plan_on(&workspace, Feature::Plan).await? {
390 return Ok(Outcome::fail(FailureCode::Conflict, format!("The g1t plan is already on for {workspace}.")));
391 }
392 let plan = self.plan(feature).await?;
393 let customer = self.row(&workspace).await?.and_then(|row| row.customer_id);
394 // The card from the card check: the plan starts on it at once, with
395 // no second page. A card that needs the bank's approval again goes
396 // through Stripe's page instead.
397 if let (Some(customer), Some(method)) = (customer.as_deref(), self.checked_card(&workspace).await?) {
398 match stripe
399 .subscribe_with_card(&workspace, feature.as_str(), &plan.title, plan.monthly_cents, customer, &method)
400 .await
401 {
402 Ok(subscription) if matches!(subscription.status.as_str(), "active" | "trialing") => {
403 self.record(&workspace, feature, &subscription, &a.actor.username).await?;
404 let account = self.account_of(&workspace).await?;
405 self.audit(&account.id, "plan", &format!("{workspace}: the g1t plan started on the checked card"), &a.actor.username)
406 .await?;
407 let separator = if a.return_url.contains('?') { '&' } else { '?' };
408 return Ok(Outcome::Ok(Checkout { url: format!("{}{separator}plan=started", a.return_url) }));
409 }
410 Ok(subscription) => {
411 // Incomplete: let it lapse, and use the page.
412 let _ = stripe.cancel_now(&subscription.id).await;
413 }
414 Err(error) => worker::console_log!("{workspace}: the plan could not start on the checked card: {error}"),
415 }
416 }
417 let start = |customer: Option<String>| {
418 let plan = &plan;
419 let workspace = &workspace;
420 let return_url = &a.return_url;
421 async move {
422 stripe
423 .start_subscription(
424 workspace,
425 feature.as_str(),
426 &plan.title,
427 plan.monthly_cents,
428 customer.as_deref(),
429 return_url,
430 )
431 .await
432 }
433 };
434 let session = match start(customer.clone()).await {
435 Ok(session) => session,
436 // A customer saved under another Stripe account: start afresh.
437 Err(error) if customer.is_some() && is_missing(&error) => {
438 self.forget_customer(&workspace).await?;
439 start(None).await?
440 }
441 Err(error) => return Err(error),
442 };
443 let Some(url) = session.url else {
444 return Err(worker::Error::RustError(
445 "the card processor returned no payment page".into(),
446 ));
447 };
448 self.db
449 .prepare(
450 "INSERT INTO checkouts (id, workspace, amount_cents, created_by, created_at, feature)
451 VALUES (?, ?, ?, ?, ?, ?)",
452 )
453 .bind(&[
454 session.id.into(),
455 workspace.into(),
456 plan.monthly_cents.into(),
457 a.actor.username.into(),
458 rfc3339(now_ms()).into(),
459 feature.as_str().into(),
460 ])?
461 .run()
462 .await?;
463 Ok(Outcome::Ok(Checkout { url }))
464 }
465
466 pub(crate) async fn confirm_subscription(
467 &self,
468 a: ConfirmSubscriptionArgs,
469 ) -> Result<Outcome<FeatureState>> {
470 let workspace = a.workspace.to_lowercase();
471 if !a.viewer.is_some_and(|viewer| viewer.is_member(&workspace)) {
472 return Ok(members_only());
473 }
474 let checkout = self
475 .db
476 .prepare(
477 "SELECT workspace, created_by, feature FROM checkouts
478 WHERE id = ? AND workspace = ? AND status = 'open' AND feature IS NOT NULL",
479 )
480 .bind(&[a.session.as_str().into(), workspace.as_str().into()])?
481 .first::<PlanCheckoutRow>(None)
482 .await?;
483 let (Some(stripe), Some(checkout)) = (&self.stripe, checkout) else {
484 // Unknown, someone else's, or already done: show where it stands.
485 return Ok(Outcome::Ok(self.state(&workspace, Feature::Plan).await?));
486 };
487 let Some(feature) = Feature::parse(&checkout.feature) else {
488 return Ok(Outcome::fail(FailureCode::NotFound, "No such plan."));
489 };
490 let session = stripe.session(&a.session).await?;
491 if let (Some(subscription_id), true) = (&session.subscription, session.payment_status == "paid") {
492 let claimed = self
493 .db
494 .prepare("UPDATE checkouts SET status = 'paid' WHERE id = ? AND status = 'open' RETURNING id")
495 .bind(&[a.session.as_str().into()])?
496 .first::<Touched>(None)
497 .await?;
498 if claimed.is_some() {
499 let subscription = stripe.subscription(subscription_id).await?;
500 self.record(&checkout.workspace, feature, &subscription, &checkout.created_by)
501 .await?;
502 // Keep the card's customer, so later payments need no retyping.
503 self.db
504 .prepare(
505 "INSERT INTO accounts (workspace, balance_micros, customer_id, created_at)
506 VALUES (?1, 0, ?2, ?3)
507 ON CONFLICT (workspace) DO UPDATE SET customer_id = COALESCE(customer_id, ?2)",
508 )
509 .bind(&[
510 checkout.workspace.as_str().into(),
511 optional(session.customer.as_deref()),
512 rfc3339(now_ms()).into(),
513 ])?
514 .run()
515 .await?;
516 }
517 }
518 Ok(Outcome::Ok(self.state(&workspace, feature).await?))
519 }
520
521 pub(crate) async fn cancel_subscription(
522 &self,
523 a: CancelSubscriptionArgs,
524 ) -> Result<Outcome<FeatureState>> {
525 let workspace = a.workspace.to_lowercase();
526 if a.actor.role_in(&workspace) != Some(Role::Owner) {
527 return Ok(Outcome::fail(
528 FailureCode::Forbidden,
529 "Only an owner can change the workspace's plan.",
530 ));
531 }
532 // The plan, or a Deployments subscription from before it.
533 let row = match self.current(&workspace, Feature::Plan).await? {
534 Some(row) if status_from(&row.status) != SubscriptionStatus::Canceled => Some((Feature::Plan, row)),
535 _ => self.current(&workspace, Feature::Deployments).await?.map(|row| (Feature::Deployments, row)),
536 };
537 let (Some(stripe), Some((feature, row))) = (&self.stripe, row) else {
538 return Ok(Outcome::fail(FailureCode::NotFound, format!("The g1t plan is not on for {workspace}.")));
539 };
540 let subscription = stripe
541 .cancel_at_period_end(&row.subscription_id, !a.resume)
542 .await?;
543 self.record(&workspace, feature, &subscription, &row.started_by)
544 .await?;
545 Ok(Outcome::Ok(self.state(&workspace, Feature::Plan).await?))
546 }
547
548 /// Whether the workspace has the plan, which deployments come with.
549 pub(crate) async fn has_feature(&self, a: HasFeatureArgs) -> Result<Outcome<bool>> {
550 let workspace = a.workspace.to_lowercase();
551 if self.has_plan(&workspace).await? {
552 return Ok(Outcome::Ok(true));
553 }
554 let what = match a.feature {
555 Feature::Deployments => "Deployments come with the g1t plan",
556 Feature::Plan => "This needs the g1t plan",
557 };
558 Ok(Outcome::fail(
559 FailureCode::PaymentRequired,
560 format!(
561 "{what} ($20 a month for the workspace, with $10 of usage included), and {workspace} does not have it. An owner can start it at /{workspace}/-/billing."
562 ),
563 ))
564 }
565
566 pub(crate) async fn charge_feature(&self, a: ChargeFeatureArgs) -> Result<Outcome<bool>> {
567 if self.stripe.is_none() || a.cost_micros <= 0 {
568 return Ok(Outcome::Ok(false));
569 }
570 let workspace = a.workspace.to_lowercase();
571 let seen = self
572 .db
573 .prepare("SELECT id FROM ledger WHERE reference = ?")
574 .bind(&[a.reference.as_str().into()])?
575 .first::<Touched>(None)
576 .await?;
577 if seen.is_some() {
578 return Ok(Outcome::Ok(false));
579 }
580 let timestamp = rfc3339(now_ms());
581 let month = crate::credits::month_of(&timestamp);
582 let mut description = a.description.clone();
583 // A build: every second is metered, at the price book's build
584 // second, which the keeper keeps at what Cloudflare bills, rather
585 // than at what the caller worked out. The month's build time is
586 // tallied for the Billing page.
587 let cost_micros = match a.build_seconds.filter(|s| *s > 0 && a.feature == Feature::Deployments) {
588 Some(seconds) => {
589 self.tally("build_seconds", &workspace, &month, seconds.into()).await?;
590 let measured = self.price("build_second").await?.map(|(cost, _)| (f64::from(seconds) * cost).ceil() as i64);
591 measured.unwrap_or(a.cost_micros)
592 }
593 None => a.cost_micros,
594 };
595 let cost = cost_micros as f64 / MICROS_PER_DOLLAR as f64;
596 // Never free: the margin applies whatever FREE_WHILE_BUILDING says,
597 // and only the account's terms change it. The plan's included usage
598 // pays what it can; the trial and the open-source pool never pay for
599 // deployments.
600 let (charge, discount) = self.terms_of(&workspace).await?.discounted(crate::charge_micros(cost, self.margin_percent));
601 let drawn = self.draw(&workspace, charge, &month, &crate::credits::Eligible::default()).await?;
602 description.push_str(&drawn.note());
603 self.post_usage(crate::storage::UsageLine {
604 workspace: &workspace,
605 charged: charge - drawn.total(),
606 description: &description,
607 repo: a.repo.as_deref(),
608 task: a.feature.as_str(),
609 cost: cost_micros,
610 reference: &a.reference,
611 created_at: &timestamp,
612 drawn,
613 })
614 .await?;
615 self.record_discount(&a.reference, discount).await?;
616 self.count_spend(&workspace, cost_micros, charge - drawn.total(), &drawn).await;
617 Ok(Outcome::Ok(true))
618 }
619}
620
621/// `50,000`: a count as the plan reads it.
622pub(crate) fn thousands(n: u64) -> String {
623 let digits = n.to_string();
624 let mut out = String::new();
625 for (i, c) in digits.chars().enumerate() {
626 if i > 0 && (digits.len() - i).is_multiple_of(3) {
627 out.push(',');
628 }
629 out.push(c);
630 }
631 out
632}
633
634#[cfg(test)]
635mod tests {
636 use super::*;
637
638 #[test]
639 fn an_ended_plan_is_asked_about_at_most_once_an_hour() {
640 let now = 1_791_000_000_000;
641 let at = |ago_ms: u64| rfc3339(now - ago_ms);
642 let ended = at(24 * 60 * 60 * 1000);
643 // Ended, and last written a day ago: ask.
644 assert!(needs_refresh("active", Some(&ended), &ended, now));
645 // Ended, but written ten minutes ago: believe the row.
646 assert!(!needs_refresh("active", Some(&ended), &at(10 * 60 * 1000), now));
647 // An hour on, ask again.
648 assert!(needs_refresh("active", Some(&ended), &at(REFRESH_MS), now));
649 // No period known is the same as ended.
650 assert!(needs_refresh("past_due", None, &ended, now));
651 // A period still running, or a canceled plan, is never asked about.
652 assert!(!needs_refresh("active", Some(&rfc3339(now + 1000)), &ended, now));
653 assert!(!needs_refresh("canceled", Some(&ended), &ended, now));
654 }
655
656 #[test]
657 fn the_plan_text_quotes_a_build_minute_as_the_table_does() {
658 // The price book's build second (16.44 millionths at cost, plus
659 // 20%) is 19.73 millionths: a minute is 1,184 millionths, $0.0012,
660 // as the pricing page's table says. The old fixed cost (15) gave
661 // $0.0011.
662 let each = Price::price_for(16.439_893_610_418_67, 20);
663 assert_eq!(per_units(each, 60.0), "$0.0012");
664 assert_eq!(per_units(Price::price_for(15.0, 20), 60.0), "$0.0011");
665 assert_eq!(per_units(Price::price_for(150_000.0, 20), 1.0), "$0.18");
666 }
667
668 #[test]
669 fn every_build_second_is_metered_at_cost_plus_the_margin() {
670 // A 5-minute build at 15 millionths a second costs g1t 4,500, and
671 // is charged at cost plus 20%, from the first second: there are no
672 // included build minutes, only the plan's included usage.
673 let cost = 300 * costs::MICROS_PER_BUILD_SECOND;
674 assert_eq!(cost, 4_500);
675 assert_eq!(crate::credits::with_margin(cost, 20), 5_400);
676 }
677
678 #[test]
679 fn counts_read_with_thousands_separators() {
680 assert_eq!(thousands(0), "0");
681 assert_eq!(thousands(999), "999");
682 assert_eq!(thousands(50_000), "50,000");
683 assert_eq!(thousands(1_234_567), "1,234,567");
684 }
685
686 #[test]
687 fn storage_reads_in_gigabytes() {
688 assert_eq!(bytes(1_000_000_000), "1 GB");
689 assert_eq!(bytes(50_000_000_000), "50 GB");
690 assert_eq!(bytes(1_500_000_000), "1.5 GB");
691 assert_eq!(bytes(500_000_000), "500 MB");
692 }
693
694 #[test]
695 fn amounts_of_money_read_to_the_cent() {
696 assert_eq!(cents(3_986_990), "$3.99");
697 assert_eq!(cents(5_000_000), "$5.00");
698 assert_eq!(cents(4_000), "$0.00");
699 }
700
701 #[test]
702 fn prices_under_a_cent_keep_their_digits() {
703 assert_eq!(dollars(1512), "$0.0015");
704 assert_eq!(dollars(24_000), "$0.024");
705 assert_eq!(dollars(360_000), "$0.36");
706 assert_eq!(dollars(5_000_000), "$5.00");
707 }
708}