pr_01m47d24b0e6n91zwymwxg0vpx/services/billing/src/features.rs

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