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

569 lines23,919 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;
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put9use g1t_contracts::{FailureCode, Outcome, Role};
Paid features: a workspace turns on Deployments with a monthly plan10use 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
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put62/// `1 GB`, `50 GB`, or `500 MB`, as storage is priced (powers of ten).
63pub(crate) fn bytes(bytes: i64) -> String {
64 if bytes >= 1_000_000_000 && bytes % 1_000_000_000 == 0 {
65 format!("{} GB", bytes / 1_000_000_000)
66 } else if bytes >= 1_000_000_000 {
67 format!("{:.1} GB", bytes as f64 / 1e9)
68 } else {
69 format!("{} MB", bytes / 1_000_000)
70 }
71}
72
Deployments: a preview for every pull request, production on g1t.page73/// Dollars to the cent, or finer for prices under a cent, so that a
74/// build minute's $0.0015 does not read as nothing.
Usage limits: unpaid usage can only go so far75pub(crate) fn dollars(micros: i64) -> String {
Deployments: a preview for every pull request, production on g1t.page76 let text = format!("{:.4}", micros as f64 / MICROS_PER_DOLLAR as f64);
77 let (whole, fraction) = text.split_once('.').unwrap_or((&text, ""));
78 let fraction = fraction.trim_end_matches('0');
79 format!("${whole}.{fraction:0<2}")
Paid features: a workspace turns on Deployments with a monthly plan80}
81
82impl SubscriptionRow {
83 fn subscription(&self) -> Option<Subscription> {
84 Some(Subscription {
85 feature: Feature::parse(&self.feature)?,
86 status: status_from(&self.status),
87 period_end: self.period_end.clone(),
88 started_by: self.started_by.clone(),
89 started_at: self.started_at.clone(),
90 })
91 }
92}
93
94impl Billing {
95 pub(crate) fn plan(&self, feature: Feature) -> Plan {
96 match feature {
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put97 Feature::Team => Plan {
98 feature,
99 title: feature.title().to_owned(),
100 monthly_cents: self.plans.team_monthly_cents,
101 includes: vec![
102 format!(
103 "{} of usage credit each month, drawn first by the month's usage at cost plus {}%. Unused credit does not roll over.",
104 dollars(self.plans.team_included_micros),
105 self.margin_percent
106 ),
107 format!(
108 "{} of private repository storage, rather than {}",
109 bytes(self.plans.team_storage_bytes),
110 bytes(self.plans.free_storage_bytes)
111 ),
112 format!(
113 "The audit log kept {}, rather than {} days",
114 if self.plans.team_audit_days % 365 == 0 {
115 match self.plans.team_audit_days / 365 {
116 1 => "for 1 year".to_owned(),
117 years => format!("for {years} years"),
118 }
119 } else {
120 format!("for {} days", self.plans.team_audit_days)
121 },
122 self.plans.audit_days
123 ),
124 "Everyone in the workspace, at one price: never per person".to_owned(),
125 ],
126 overage: format!(
127 "Usage past the credit is charged as it is without the plan: at cost plus {}%.",
128 self.margin_percent
129 ),
130 },
Paid features: a workspace turns on Deployments with a monthly plan131 Feature::Deployments => Plan {
132 feature,
133 title: feature.title().to_owned(),
134 monthly_cents: self.deployments_monthly_cents,
135 includes: vec![
136 format!(
137 "{} apps deployed at once, production and previews together",
138 allowance::APPS
139 ),
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put140 format!("{} build minutes", self.plans.build_seconds / 60),
Paid features: a workspace turns on Deployments with a monthly plan141 format!("{} million requests", allowance::REQUESTS / 1_000_000),
142 format!("{} million CPU milliseconds", allowance::CPU_MS / 1_000_000),
Agents and memory, checks and conflicts, profiles, slug renames, custom domains143 format!(
144 "{} custom domains, with certificates, then {} each a month",
145 allowance::CUSTOM_DOMAINS,
146 dollars(crate::charge_micros(
147 allowance::MICROS_PER_DOMAIN_MONTH as f64 / MICROS_PER_DOLLAR as f64,
148 self.margin_percent
149 )),
150 ),
Paid features: a workspace turns on Deployments with a monthly plan151 "Previews that cost nothing while no one visits them".to_owned(),
152 ],
153 overage: format!(
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put154 "Usage past that is charged at Cloudflare's price plus {3}%: {4} per build minute, by the second, {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 plan155 dollars(crate::charge_micros(
156 allowance::MICROS_PER_APP_MONTH as f64 / MICROS_PER_DOLLAR as f64,
157 self.margin_percent
158 )),
159 dollars(crate::charge_micros(
160 allowance::MICROS_PER_MILLION_REQUESTS as f64 / MICROS_PER_DOLLAR as f64,
161 self.margin_percent
162 )),
163 dollars(crate::charge_micros(
164 allowance::MICROS_PER_MILLION_CPU_MS as f64 / MICROS_PER_DOLLAR as f64,
165 self.margin_percent
166 )),
Deployments: a preview for every pull request, production on g1t.page167 self.margin_percent,
168 dollars(crate::charge_micros(
169 (allowance::MICROS_PER_BUILD_SECOND * 60) as f64 / MICROS_PER_DOLLAR as f64,
170 self.margin_percent
171 )),
Paid features: a workspace turns on Deployments with a monthly plan172 ),
173 },
174 }
175 }
176
177 async fn subscription_row(&self, workspace: &str, feature: Feature) -> Result<Option<SubscriptionRow>> {
178 self.db
179 .prepare(
180 "SELECT feature, subscription_id, status, period_end, started_by, started_at
181 FROM subscriptions WHERE workspace = ? AND feature = ?",
182 )
183 .bind(&[workspace.into(), feature.as_str().into()])?
184 .first::<SubscriptionRow>(None)
185 .await
186 }
187
188 /// Writes down what the processor says about a plan.
Stripe webhooks, enterprise invoices, and sudo for both189 pub(crate) async fn record(
Paid features: a workspace turns on Deployments with a monthly plan190 &self,
191 workspace: &str,
192 feature: Feature,
193 subscription: &StripeSubscription,
194 started_by: &str,
195 ) -> Result<()> {
196 let now = rfc3339(now_ms());
197 let period_end = subscription.period_end().map(|seconds| rfc3339(seconds.max(0) as u64 * 1000));
198 self.db
199 .prepare(
200 "INSERT INTO subscriptions
201 (workspace, feature, subscription_id, status, period_end, started_by, started_at, updated_at)
202 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?7)
203 ON CONFLICT (workspace, feature) DO UPDATE SET
204 subscription_id = ?3, status = ?4, period_end = ?5, updated_at = ?7,
205 started_by = CASE WHEN subscription_id = ?3 THEN started_by ELSE ?6 END,
206 started_at = CASE WHEN subscription_id = ?3 THEN started_at ELSE ?7 END",
207 )
208 .bind(&[
209 workspace.into(),
210 feature.as_str().into(),
211 subscription.id.as_str().into(),
212 status_text(status_of(subscription)).into(),
213 optional(period_end.as_deref()),
214 started_by.into(),
215 now.as_str().into(),
216 ])?
217 .run()
218 .await?;
219 Ok(())
220 }
221
222 /// A workspace's plan for a feature, asking the processor again once
223 /// the period it last knew of is over.
224 async fn current(&self, workspace: &str, feature: Feature) -> Result<Option<SubscriptionRow>> {
225 let Some(row) = self.subscription_row(workspace, feature).await? else {
226 return Ok(None);
227 };
228 let stale = row.period_end.as_deref().is_none_or(|end| end <= rfc3339(now_ms()).as_str())
229 && row.status != "canceled";
230 if let (true, Some(stripe)) = (stale, &self.stripe) {
Project dependencies: addresses, preview stacks, Affects, and agents who know231 match stripe.subscription(&row.subscription_id).await {
232 Ok(subscription) => self.record(workspace, feature, &subscription, &row.started_by).await?,
233 // A plan from another Stripe account: it has ended here.
234 Err(error) if is_missing(&error) => {
235 self.db
236 .prepare("UPDATE subscriptions SET status = 'canceled', updated_at = ? WHERE workspace = ? AND feature = ?")
237 .bind(&[rfc3339(now_ms()).into(), workspace.into(), feature.as_str().into()])?
238 .run()
239 .await?;
240 }
241 Err(error) => return Err(error),
242 }
Paid features: a workspace turns on Deployments with a monthly plan243 return self.subscription_row(workspace, feature).await;
244 }
245 Ok(Some(row))
246 }
247
248 async fn state(&self, workspace: &str, feature: Feature) -> Result<FeatureState> {
249 let subscription = self
250 .current(workspace, feature)
251 .await?
252 .and_then(|row| row.subscription());
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put253 let included = self.included(workspace, feature).await?;
Paid features: a workspace turns on Deployments with a monthly plan254 Ok(FeatureState {
255 plan: self.plan(feature),
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put256 on: included || self.stripe.is_none() || subscription.as_ref().is_some_and(|s| s.status.on()),
Paid features: a workspace turns on Deployments with a monthly plan257 subscription,
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put258 included,
Paid features: a workspace turns on Deployments with a monthly plan259 })
260 }
261
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put262 /// Whether the feature is on without a plan: comped terms have every
263 /// feature, and g1t staff can give an account Team.
264 async fn included(&self, workspace: &str, feature: Feature) -> Result<bool> {
265 let account = self.account_of(workspace).await?;
266 Ok(account.terms.kind == g1t_contracts::billing::TermsKind::Comped
267 || (feature == Feature::Team && account.allowances.team))
268 }
269
270 /// Whether the workspace's plan for the feature is paid up.
271 pub(crate) async fn plan_on(&self, workspace: &str, feature: Feature) -> Result<bool> {
272 Ok(self
273 .current(workspace, feature)
274 .await?
275 .and_then(|row| row.subscription())
276 .is_some_and(|s| s.status.on()))
277 }
278
Paid features: a workspace turns on Deployments with a monthly plan279 pub(crate) async fn features(&self, a: FeaturesArgs) -> Result<Outcome<Vec<FeatureState>>> {
280 let workspace = a.workspace.to_lowercase();
281 if !a.viewer.is_some_and(|viewer| viewer.is_member(&workspace)) {
282 return Ok(members_only());
283 }
284 let mut states = Vec::new();
285 for feature in Feature::ALL {
286 states.push(self.state(&workspace, feature).await?);
287 }
288 Ok(Outcome::Ok(states))
289 }
290
291 pub(crate) async fn subscribe(&self, a: SubscribeArgs) -> Result<Outcome<Checkout>> {
292 let workspace = a.workspace.to_lowercase();
293 if a.actor.role_in(&workspace) != Some(Role::Owner) {
294 return Ok(Outcome::fail(
295 FailureCode::Forbidden,
296 "Only an owner can turn on a paid feature.",
297 ));
298 }
299 let Some(stripe) = &self.stripe else {
300 return Ok(Outcome::fail(
301 FailureCode::Conflict,
302 "Payments are not set up on this g1t, so every feature is already on.",
303 ));
304 };
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put305 let state = self.state(&workspace, a.feature).await?;
306 if state.included {
307 return Ok(Outcome::fail(
308 FailureCode::Conflict,
309 format!("{} is included for {workspace} already, at no charge.", a.feature.title()),
310 ));
311 }
312 if state.subscription.is_some_and(|s| s.status.on()) {
Paid features: a workspace turns on Deployments with a monthly plan313 return Ok(Outcome::fail(
314 FailureCode::Conflict,
315 format!("{} is already on for {workspace}.", a.feature.title()),
316 ));
317 }
318 let plan = self.plan(a.feature);
319 let customer = self.row(&workspace).await?.and_then(|row| row.customer_id);
Project dependencies: addresses, preview stacks, Affects, and agents who know320 let start = |customer: Option<String>| {
321 let plan = &plan;
322 let workspace = &workspace;
323 let return_url = &a.return_url;
324 async move {
325 stripe
326 .start_subscription(
327 workspace,
328 a.feature.as_str(),
329 &plan.title,
330 plan.monthly_cents,
331 customer.as_deref(),
332 return_url,
333 )
334 .await
335 }
336 };
337 let session = match start(customer.clone()).await {
338 Ok(session) => session,
339 // A customer saved under another Stripe account: start afresh.
340 Err(error) if customer.is_some() && is_missing(&error) => {
341 self.forget_customer(&workspace).await?;
342 start(None).await?
343 }
344 Err(error) => return Err(error),
345 };
Paid features: a workspace turns on Deployments with a monthly plan346 let Some(url) = session.url else {
347 return Err(worker::Error::RustError(
348 "the card processor returned no payment page".into(),
349 ));
350 };
351 self.db
352 .prepare(
353 "INSERT INTO checkouts (id, workspace, amount_cents, created_by, created_at, feature)
354 VALUES (?, ?, ?, ?, ?, ?)",
355 )
356 .bind(&[
357 session.id.into(),
358 workspace.into(),
359 plan.monthly_cents.into(),
360 a.actor.username.into(),
361 rfc3339(now_ms()).into(),
362 a.feature.as_str().into(),
363 ])?
364 .run()
365 .await?;
366 Ok(Outcome::Ok(Checkout { url }))
367 }
368
369 pub(crate) async fn confirm_subscription(
370 &self,
371 a: ConfirmSubscriptionArgs,
372 ) -> Result<Outcome<FeatureState>> {
373 let workspace = a.workspace.to_lowercase();
374 if !a.viewer.is_some_and(|viewer| viewer.is_member(&workspace)) {
375 return Ok(members_only());
376 }
377 let checkout = self
378 .db
379 .prepare(
380 "SELECT workspace, created_by, feature FROM checkouts
381 WHERE id = ? AND workspace = ? AND status = 'open' AND feature IS NOT NULL",
382 )
383 .bind(&[a.session.as_str().into(), workspace.as_str().into()])?
384 .first::<PlanCheckoutRow>(None)
385 .await?;
386 let (Some(stripe), Some(checkout)) = (&self.stripe, checkout) else {
387 // Unknown, someone else's, or already done: show where it stands.
388 return Ok(Outcome::Ok(self.state(&workspace, Feature::Deployments).await?));
389 };
390 let Some(feature) = Feature::parse(&checkout.feature) else {
391 return Ok(Outcome::fail(FailureCode::NotFound, "No such feature."));
392 };
393 let session = stripe.session(&a.session).await?;
394 if let (Some(subscription_id), true) = (&session.subscription, session.payment_status == "paid") {
395 let claimed = self
396 .db
397 .prepare("UPDATE checkouts SET status = 'paid' WHERE id = ? AND status = 'open' RETURNING id")
398 .bind(&[a.session.as_str().into()])?
399 .first::<Touched>(None)
400 .await?;
401 if claimed.is_some() {
402 let subscription = stripe.subscription(subscription_id).await?;
403 self.record(&checkout.workspace, feature, &subscription, &checkout.created_by)
404 .await?;
405 // Keep the card's customer, so later payments need no retyping.
406 self.db
407 .prepare(
408 "INSERT INTO accounts (workspace, balance_micros, customer_id, created_at)
409 VALUES (?1, 0, ?2, ?3)
410 ON CONFLICT (workspace) DO UPDATE SET customer_id = COALESCE(customer_id, ?2)",
411 )
412 .bind(&[
413 checkout.workspace.as_str().into(),
414 optional(session.customer.as_deref()),
415 rfc3339(now_ms()).into(),
416 ])?
417 .run()
418 .await?;
419 }
420 }
421 Ok(Outcome::Ok(self.state(&workspace, feature).await?))
422 }
423
424 pub(crate) async fn cancel_subscription(
425 &self,
426 a: CancelSubscriptionArgs,
427 ) -> Result<Outcome<FeatureState>> {
428 let workspace = a.workspace.to_lowercase();
429 if a.actor.role_in(&workspace) != Some(Role::Owner) {
430 return Ok(Outcome::fail(
431 FailureCode::Forbidden,
432 "Only an owner can change a workspace's plans.",
433 ));
434 }
435 let (Some(stripe), Some(row)) = (&self.stripe, self.current(&workspace, a.feature).await?) else {
436 return Ok(Outcome::fail(
437 FailureCode::NotFound,
438 format!("{} is not on for {workspace}.", a.feature.title()),
439 ));
440 };
441 let subscription = stripe
442 .cancel_at_period_end(&row.subscription_id, !a.resume)
443 .await?;
444 self.record(&workspace, a.feature, &subscription, &row.started_by)
445 .await?;
446 Ok(Outcome::Ok(self.state(&workspace, a.feature).await?))
447 }
448
449 pub(crate) async fn has_feature(&self, a: HasFeatureArgs) -> Result<Outcome<bool>> {
450 let workspace = a.workspace.to_lowercase();
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put451 // Comped accounts have every feature without a plan, and staff can
452 // give an account Team.
453 if self.included(&workspace, a.feature).await? {
Billing accounts, terms and enterprises; g1t is no longer free454 return Ok(Outcome::Ok(true));
455 }
Paid features: a workspace turns on Deployments with a monthly plan456 if self.state(&workspace, a.feature).await?.on {
457 return Ok(Outcome::Ok(true));
458 }
459 Ok(Outcome::fail(
460 FailureCode::PaymentRequired,
461 format!(
462 "{} is a paid feature, and it is not on for {workspace}. An owner can turn it on under Billing on the workspace's page.",
463 a.feature.title()
464 ),
465 ))
466 }
467
468 pub(crate) async fn charge_feature(&self, a: ChargeFeatureArgs) -> Result<Outcome<bool>> {
469 if self.stripe.is_none() || a.cost_micros <= 0 {
470 return Ok(Outcome::Ok(false));
471 }
472 let workspace = a.workspace.to_lowercase();
473 let seen = self
474 .db
475 .prepare("SELECT id FROM ledger WHERE reference = ?")
476 .bind(&[a.reference.as_str().into()])?
477 .first::<Touched>(None)
478 .await?;
479 if seen.is_some() {
480 return Ok(Outcome::Ok(false));
481 }
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put482 let timestamp = rfc3339(now_ms());
483 let month = crate::credits::month_of(&timestamp);
484 let mut description = a.description.clone();
485 // A build: the plan's build time this month pays for what it can.
486 let cost_micros = match a.build_seconds.filter(|s| *s > 0 && a.feature == Feature::Deployments) {
487 Some(seconds) => {
488 let included = self
489 .draw_allowance("build_seconds", &workspace, &month, seconds.into(), self.plans.build_seconds.into())
490 .await?;
491 if included > 0 {
492 description.push_str(&format!(
493 ", {} of it included in the plan",
494 if included == i64::from(seconds) { "all".to_owned() } else { format!("{included} s") }
495 ));
496 }
497 billable_build_cost(a.cost_micros, seconds, included)
498 }
499 None => a.cost_micros,
500 };
501 let cost = cost_micros as f64 / MICROS_PER_DOLLAR as f64;
Billing accounts, terms and enterprises; g1t is no longer free502 // Never free: the margin applies whatever FREE_WHILE_BUILDING says,
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put503 // and only the account's terms change it. The Team credit pays what
504 // it can; the trial and the open-source pool never pay for
505 // deployments.
Billing accounts, terms and enterprises; g1t is no longer free506 let charge = self.terms_of(&workspace).await?.apply(crate::charge_micros(cost, self.margin_percent));
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put507 let drawn = self.draw(&workspace, charge, &month, &crate::credits::Eligible::default()).await?;
508 description.push_str(&drawn.note());
509 self.post_usage(crate::storage::UsageLine {
510 workspace: &workspace,
511 charged: charge - drawn.total(),
512 description: &description,
513 repo: a.repo.as_deref(),
514 task: a.feature.as_str(),
515 cost: cost_micros,
516 reference: &a.reference,
517 created_at: &timestamp,
518 drawn,
519 })
520 .await?;
Paid features: a workspace turns on Deployments with a monthly plan521 Ok(Outcome::Ok(true))
522 }
523}
Deployments: a preview for every pull request, production on g1t.page524
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put525/// What of a build's cost is charged when `included` of its `seconds` were
526/// paid for by the plan: the rest, in proportion, rounded up.
527pub(crate) fn billable_build_cost(cost_micros: i64, seconds: u32, included: i64) -> i64 {
528 if seconds == 0 {
529 return cost_micros;
530 }
531 let billable = (i64::from(seconds) - included.max(0)).max(0);
532 (cost_micros as f64 * billable as f64 / f64::from(seconds)).ceil() as i64
533}
534
Deployments: a preview for every pull request, production on g1t.page535#[cfg(test)]
536mod tests {
537 use super::*;
538
539 #[test]
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put540 fn the_plan_pays_for_its_build_minutes_and_the_rest_is_charged() {
541 // A 5-minute build at 21 millionths a second costs 6,300.
542 assert_eq!(billable_build_cost(6_300, 300, 300), 0);
543 assert_eq!(billable_build_cost(6_300, 300, 0), 6_300);
544 // The allowance ran out a minute into it: four minutes are charged.
545 assert_eq!(billable_build_cost(6_300, 300, 60), 5_040);
546 // Then at cost plus 20%.
547 assert_eq!(crate::credits::with_margin(5_040, 20), 6_048);
548 // 200 minutes a month cost g1t about $0.25 of the plan's $5.
549 let month = crate::credits::Config::default().build_seconds;
550 assert_eq!(month, 12_000);
551 assert_eq!(i64::from(month) * allowance::MICROS_PER_BUILD_SECOND, 252_000);
552 }
553
554 #[test]
555 fn storage_reads_in_gigabytes() {
556 assert_eq!(bytes(1_000_000_000), "1 GB");
557 assert_eq!(bytes(50_000_000_000), "50 GB");
558 assert_eq!(bytes(1_500_000_000), "1.5 GB");
559 assert_eq!(bytes(500_000_000), "500 MB");
560 }
561
562 #[test]
Deployments: a preview for every pull request, production on g1t.page563 fn prices_under_a_cent_keep_their_digits() {
564 assert_eq!(dollars(1512), "$0.0015");
565 assert_eq!(dollars(24_000), "$0.024");
566 assert_eq!(dollars(360_000), "$0.36");
567 assert_eq!(dollars(5_000_000), "$5.00");
568 }
569}