g1t/services/billing/src/features.rs
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 plan | 1 | //! 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 | ||
| 6 | use g1t_contracts::billing::deployments_allowance as allowance; | |
| 7 | use g1t_contracts::billing::*; | |
| 8 | use g1t_contracts::time::rfc3339; | |
| 9 | use g1t_contracts::{FailureCode, Outcome, Role, new_id}; | |
| 10 | use g1t_kit::now_ms; | |
| 11 | use serde::Deserialize; | |
| 12 | use worker::Result; | |
| 13 | ||
| Project dependencies: addresses, preview stacks, Affects, and agents who know | 14 | use crate::stripe::{StripeSubscription, is_missing}; |
| Paid features: a workspace turns on Deployments with a monthly plan | 15 | use crate::{Billing, Touched, members_only, optional}; |
| 16 | ||
| 17 | #[derive(Deserialize)] | |
| 18 | struct 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)] | |
| 28 | struct PlanCheckoutRow { | |
| 29 | workspace: String, | |
| 30 | created_by: String, | |
| 31 | feature: String, | |
| 32 | } | |
| 33 | ||
| 34 | fn 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 | ||
| 43 | fn 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. | |
| 53 | fn 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.page | 62 | /// 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 far | 64 | pub(crate) fn dollars(micros: i64) -> String { |
| Deployments: a preview for every pull request, production on g1t.page | 65 | 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 plan | 69 | } |
| 70 | ||
| 71 | impl 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 | ||
| 83 | impl 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 domains | 97 | 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 plan | 105 | "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.page | 108 | "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 plan | 109 | 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.page | 121 | 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 plan | 126 | ), |
| 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 both | 143 | pub(crate) async fn record( |
| Paid features: a workspace turns on Deployments with a monthly plan | 144 | &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 know | 185 | 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 plan | 197 | 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 know | 248 | 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 plan | 274 | 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 free | 379 | // 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 plan | 383 | 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 free | 410 | // 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 plan | 413 | 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.page | 451 | |
| 452 | #[cfg(test)] | |
| 453 | mod 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 | } |