diff --git a/lib/api_organizations/src/usage.rs b/lib/api_organizations/src/usage.rs index 25627f3d7..63c47cada 100644 --- a/lib/api_organizations/src/usage.rs +++ b/lib/api_organizations/src/usage.rs @@ -2,6 +2,7 @@ use std::time::Duration; +use bencher_billing::Biller; use bencher_endpoint::{CorsResponse, Endpoint, Get, ResponseOk}; use bencher_json::{ DateTime, OrganizationResourceId, PlanLevel, @@ -15,9 +16,9 @@ use bencher_schema::{ model::{ organization::{ OrganizationId, QueryOrganization, - plan::{QueryPlan, metered_bills_public_metrics}, + plan::{QueryPlan, metered_bills_active_series}, }, - project::metric::QueryMetric, + project::{metric::QueryMetric, series}, runner::job::QueryJob, user::auth::{AuthUser, BearerToken}, }, @@ -87,71 +88,7 @@ async fn get_inner( // Bencher Cloud if let Ok(biller) = context.biller() { - let Ok(query_plan) = QueryPlan::belonging_to(&query_organization) - .first::(auth_conn!(context)) - .map_err(resource_not_found_err!(Plan, query_organization)) - // Cloud Free - else { - return free_plan_usage( - auth_conn!(context), - &query_organization, - UsageKind::CloudFree, - ); - }; - - // Metered plan - if let Some(json_plan) = query_plan.to_metered_plan(biller).await? { - let start_time = json_plan.current_period_start; - let end_time = json_plan.current_period_end; - let (metrics, runner_minutes) = metered_plan_usage( - auth_conn!(context), - query_organization.id, - json_plan.level, - start_time, - end_time, - )?; - Ok(JsonUsage { - organization: query_organization.uuid, - kind: UsageKind::CloudMetered, - plan: Some(json_plan), - license: None, - start_time, - end_time, - metrics: Some(metrics), - runner_minutes: Some(runner_minutes), - }) - // Licensed plan - } else if let Some(json_plan) = query_plan.to_licensed_plan(biller, licensor).await? { - let Some(json_license) = json_plan.license.clone() else { - return Err(issue_error( - "No license JSON found for licensed plan", - &format!( - "Failed to find license for licensed plan ({query_plan:?}) as JSON ({json_plan:?})", - ), - "License JSON not found", - )); - }; - let start_time = json_license.issued_at; - let end_time = json_license.expiration; - Ok(JsonUsage { - organization: query_organization.uuid, - kind: UsageKind::CloudSelfHostedLicensed, - plan: Some(json_plan), - license: Some(json_license), - start_time, - end_time, - metrics: None, - runner_minutes: None, - }) - } else { - Err(issue_error( - "Failed to find subscription for plan usage", - &format!( - "Failed to find plan (metered or licensed) for organization ({query_organization:?}) even though plan exists ({query_plan:?})." - ), - "Failed to find subscription for plan usage", - )) - } + cloud_plan_usage(context, biller, &query_organization).await // Self-Hosted Licensed } else if let Some(license) = query_organization.license.clone() { let json_license = licensor @@ -173,6 +110,7 @@ async fn get_inner( start_time, end_time, metrics: Some(metrics), + active_series: None, runner_minutes: Some(runner_minutes), }) // Self-Hosted Free @@ -185,6 +123,88 @@ async fn get_inner( } } +/// Usage for a Bencher Cloud organization: a metered plan, a licensed plan managed via +/// Bencher Cloud, or Cloud Free when the organization has no plan. +async fn cloud_plan_usage( + context: &ApiContext, + biller: &Biller, + query_organization: &QueryOrganization, +) -> Result { + let licensor = &context.licensor; + + let Ok(query_plan) = QueryPlan::belonging_to(query_organization) + .first::(auth_conn!(context)) + .map_err(resource_not_found_err!(Plan, query_organization)) + // Cloud Free + else { + return free_plan_usage( + auth_conn!(context), + query_organization, + UsageKind::CloudFree, + ); + }; + + // Metered plan + if let Some(json_plan) = query_plan.to_metered_plan(biller).await? { + let start_time = json_plan.current_period_start; + let end_time = json_plan.current_period_end; + let MeteredUsage { + metrics, + active_series, + runner_minutes, + } = metered_plan_usage( + auth_conn!(context), + query_organization.id, + json_plan.level, + start_time, + end_time, + )?; + Ok(JsonUsage { + organization: query_organization.uuid, + kind: UsageKind::CloudMetered, + plan: Some(json_plan), + license: None, + start_time, + end_time, + metrics, + active_series, + runner_minutes: Some(runner_minutes), + }) + // Licensed plan + } else if let Some(json_plan) = query_plan.to_licensed_plan(biller, licensor).await? { + let Some(json_license) = json_plan.license.clone() else { + return Err(issue_error( + "No license JSON found for licensed plan", + &format!( + "Failed to find license for licensed plan ({query_plan:?}) as JSON ({json_plan:?})", + ), + "License JSON not found", + )); + }; + let start_time = json_license.issued_at; + let end_time = json_license.expiration; + Ok(JsonUsage { + organization: query_organization.uuid, + kind: UsageKind::CloudSelfHostedLicensed, + plan: Some(json_plan), + license: Some(json_license), + start_time, + end_time, + metrics: None, + active_series: None, + runner_minutes: None, + }) + } else { + Err(issue_error( + "Failed to find subscription for plan usage", + &format!( + "Failed to find plan (metered or licensed) for organization ({query_organization:?}) even though plan exists ({query_plan:?})." + ), + "Failed to find subscription for plan usage", + )) + } +} + fn query_usage( conn: &mut DbConnection, organization_id: OrganizationId, @@ -213,33 +233,46 @@ fn free_plan_usage( start_time, end_time, metrics: Some(metrics), + active_series: None, runner_minutes: Some(runner_minutes), }) } -/// Estimate billable usage for a metered (Bencher Cloud) plan. +/// The billable usage figures for a metered plan: exactly one of `metrics` (legacy +/// Team/Enterprise) or `active_series` (Pro) is set, plus bare metal `runner_minutes`. +struct MeteredUsage { + metrics: Option, + active_series: Option, + runner_minutes: u32, +} + +/// Billable usage for a metered (Bencher Cloud) plan. /// -/// Legacy Team (and metered Enterprise) plans are metered on both Public and Private -/// Project metrics, so the returned metrics figure matches their bill. Pro now bills on -/// active series rather than metrics, so for Pro this returns only its Private Project -/// metrics as an informational figure that no longer matches the bill; surfacing the -/// active-series count here is a follow-up. Bare metal runner minutes are metered -/// regardless of visibility and plan level. +/// Pro bills on monthly-active series (all visibilities), so `active_series` is populated +/// and `metrics` is left `None`. Legacy Team (and metered Enterprise) plans bill on both +/// Public and Private Project metrics, so `metrics` is populated and `active_series` is +/// `None`. Bare metal runner minutes are metered regardless of visibility and plan level. fn metered_plan_usage( conn: &mut DbConnection, organization_id: OrganizationId, level: PlanLevel, start_time: DateTime, end_time: DateTime, -) -> Result<(u32, u32), HttpError> { - let metrics = if metered_bills_public_metrics(level) { - // Team (and metered Enterprise): public metrics are billed, so count all. - QueryMetric::usage(conn, organization_id, start_time, end_time)? +) -> Result { + let (metrics, active_series) = if metered_bills_active_series(level) { + // Pro: billed on monthly-active series, not metrics. + let active_series = series::count_active(conn, organization_id, start_time, end_time)?; + (None, Some(active_series)) } else { - // Pro: public metrics are free, so count only private metrics. - QueryMetric::private_usage(conn, organization_id, start_time, end_time)? + // Team (and metered Enterprise): billed on all (public + private) metrics. + let metrics = QueryMetric::usage(conn, organization_id, start_time, end_time)?; + (Some(metrics), None) }; let runner_minutes = QueryJob::runner_minutes_usage(conn, organization_id, start_time, end_time)?; - Ok((metrics, runner_minutes)) + Ok(MeteredUsage { + metrics, + active_series, + runner_minutes, + }) } diff --git a/lib/bencher_json/src/lib.rs b/lib/bencher_json/src/lib.rs index cc2ca6742..eddfe4b9c 100644 --- a/lib/bencher_json/src/lib.rs +++ b/lib/bencher_json/src/lib.rs @@ -54,7 +54,7 @@ pub use organization::{ }; #[cfg(feature = "plus")] pub use organization::{ - plan::JsonPlan, + plan::{JsonPlan, JsonPriceTier}, sso::{JsonNewSso, JsonSso, JsonSsos, SsoUuid}, usage::JsonUsage, }; diff --git a/lib/bencher_json/src/organization/plan.rs b/lib/bencher_json/src/organization/plan.rs index 981f194b0..431712952 100644 --- a/lib/bencher_json/src/organization/plan.rs +++ b/lib/bencher_json/src/organization/plan.rs @@ -51,6 +51,9 @@ pub struct JsonPlan { pub card: JsonCardDetails, pub level: PlanLevel, pub unit_amount: BigInt, + /// The tiered price ladder for this plan, ordered ascending by `up_to`. Populated + /// for the Pro tiered active-series price; `None` for flat-priced or licensed plans. + pub tiers: Option>, /// When the metered subscription was created. The first (free-trial) billing /// period is the one that contains this timestamp. pub created: DateTime, @@ -85,6 +88,26 @@ pub struct JsonLicense { pub expiration: DateTime, } +/// One tier of a Stripe tiered price, surfaced so the Console can render the price +/// ladder from the billed source of truth instead of hardcoding it. +/// +/// Field order mirrors Stripe's tier shape: `up_to`, then `unit_amount`, then +/// `flat_amount`. A tier may carry both a `unit_amount` and a `flat_amount` (Stripe +/// allows it); the two are independent and additive, not mutually exclusive. Today the +/// Pro price sets only `flat_amount`. +#[typeshare::typeshare] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(JsonSchema))] +pub struct JsonPriceTier { + /// Inclusive upper bound (active-series count) for this tier. `None` is the + /// unbounded top tier, presented as "Get in Touch". + pub up_to: Option, + /// Per-series price (cents) charged within this tier. May coexist with `flat_amount`. + pub unit_amount: Option, + /// Flat price (cents) for the whole tier. May coexist with `unit_amount`. + pub flat_amount: Option, +} + #[cfg(test)] mod tests { use bencher_valid::{ExpirationMonth, ExpirationYear}; diff --git a/lib/bencher_json/src/organization/usage.rs b/lib/bencher_json/src/organization/usage.rs index 4ff4dfe2e..2ffc1a982 100644 --- a/lib/bencher_json/src/organization/usage.rs +++ b/lib/bencher_json/src/organization/usage.rs @@ -25,8 +25,12 @@ pub struct JsonUsage { pub start_time: DateTime, /// The end time of the usage. pub end_time: DateTime, - /// The metrics usage amount. + /// The metrics usage amount. Not populated for Pro metered plans, which bill on + /// active series (see `active_series`) rather than metrics. pub metrics: Option, + /// The active series usage amount. Populated for Pro metered plans, whose bill is + /// based on monthly-active series (distinct testbed x benchmark x measure). + pub active_series: Option, /// The runner minutes usage amount. pub runner_minutes: Option, } diff --git a/lib/bencher_schema/src/model/organization/plan.rs b/lib/bencher_schema/src/model/organization/plan.rs index 732241c7f..3ccfc26c7 100644 --- a/lib/bencher_schema/src/model/organization/plan.rs +++ b/lib/bencher_schema/src/model/organization/plan.rs @@ -469,22 +469,6 @@ impl PlanKind { } } -/// Whether a metered (Stripe) subscription counts *public* project metrics in the usage -/// estimate shown by the usage endpoint. -/// -/// Only legacy Team (and metered Enterprise, which the price heuristic resolves to -/// `Team`) plans bill public metrics, so their estimate counts all metrics; Pro's -/// estimate counts only private metrics. This governs only the metrics figure shown for -/// metered plans. Pro itself now bills on active series, not metrics (see -/// [`metered_bills_active_series`]). -pub fn metered_bills_public_metrics(level: PlanLevel) -> bool { - // Matched exhaustively so a new `PlanLevel` variant forces a decision here. - match level { - PlanLevel::Free | PlanLevel::Pro => false, - PlanLevel::Team | PlanLevel::Enterprise => true, - } -} - /// Whether a metered (Stripe) subscription is billed on the active-series meter. /// /// Active-series billing is the Pro plan only: Pro's tiered price bills the base fee and @@ -571,23 +555,7 @@ impl LicenseUsage { mod tests { use bencher_json::{DateTime, PlanLevel}; - use super::{MeteredPlan, PlanKind, metered_bills_active_series, metered_bills_public_metrics}; - - #[test] - fn does_not_bill_public_metrics() { - assert!(!metered_bills_public_metrics(PlanLevel::Free)); - // Pro is the only metered tier whose public metrics are free. - assert!(!metered_bills_public_metrics(PlanLevel::Pro)); - } - - #[test] - fn bills_public_metrics() { - // Legacy Team and metered Enterprise are billed for public metrics. Free is - // included for completeness even though metered plans only resolve to - // Pro/Team via the Pro-price heuristic. - assert!(metered_bills_public_metrics(PlanLevel::Team)); - assert!(metered_bills_public_metrics(PlanLevel::Enterprise)); - } + use super::{MeteredPlan, PlanKind, metered_bills_active_series}; #[test] fn bills_active_series_pro_only() { diff --git a/lib/bencher_schema/src/model/project/metric.rs b/lib/bencher_schema/src/model/project/metric.rs index 698ff5502..a04b64056 100644 --- a/lib/bencher_schema/src/model/project/metric.rs +++ b/lib/bencher_schema/src/model/project/metric.rs @@ -1,5 +1,3 @@ -#[cfg(feature = "plus")] -use bencher_json::project::Visibility; use bencher_json::{JsonMetric, JsonNewMetric, MetricUuid}; #[cfg(feature = "plus")] use diesel::{ExpressionMethods as _, QueryDsl as _, RunQueryDsl as _}; @@ -37,6 +35,10 @@ pub struct QueryMetric { impl QueryMetric { fn_from_uuid!(metric, MetricUuid, Metric); + /// Count metric usage for an organization over a time window, across all project + /// visibilities. This is the billable figure for legacy Team (and metered + /// Enterprise) plans and the licensed entitlements check; Pro bills on active + /// series instead (see `series::count_active`). #[cfg(feature = "plus")] pub fn usage( conn: &mut DbConnection, @@ -44,42 +46,7 @@ impl QueryMetric { start_time: bencher_json::DateTime, end_time: bencher_json::DateTime, ) -> Result { - Self::usage_inner(conn, organization_id, start_time, end_time, None) - } - - /// Count private-project metric usage for an organization over a time window. - /// - /// This is the billable figure only for plan levels where Public Project Metrics - /// are free (see `metered_bills_public_metrics`); levels that bill public metrics - /// use `usage` (all visibilities) instead. The selection lives in the metered - /// usage estimate and mirrors the skip in `PlanKind::check_usage`. - #[cfg(feature = "plus")] - pub fn private_usage( - conn: &mut DbConnection, - organization_id: OrganizationId, - start_time: bencher_json::DateTime, - end_time: bencher_json::DateTime, - ) -> Result { - Self::usage_inner( - conn, - organization_id, - start_time, - end_time, - Some(Visibility::Private), - ) - } - - /// Count metric usage for an organization over a time window, optionally - /// restricted to projects of a given `visibility`. - #[cfg(feature = "plus")] - fn usage_inner( - conn: &mut DbConnection, - organization_id: OrganizationId, - start_time: bencher_json::DateTime, - end_time: bencher_json::DateTime, - visibility: Option, - ) -> Result { - let mut query = schema::metric::table + schema::metric::table .inner_join( schema::report_benchmark::table .inner_join(schema::benchmark::table.inner_join(schema::project::table)) @@ -90,16 +57,11 @@ impl QueryMetric { .filter(schema::report::end_time.ge(start_time)) .filter(schema::report::end_time.le(end_time)) .select(diesel::dsl::count_star()) - .into_boxed(); - if let Some(visibility) = visibility { - query = query.filter(schema::project::visibility.eq(visibility)); - } - query .get_result::(conn) .map_err(|e| { crate::error::issue_error( "Failed to count metric usage", - &format!("Failed to count metric usage (visibility: {visibility:?}) for organization ({organization_id}) between {start_time} and {end_time}."), + &format!("Failed to count metric usage for organization ({organization_id}) between {start_time} and {end_time}."), e, ) })? @@ -107,7 +69,7 @@ impl QueryMetric { .map_err(|e| { crate::error::issue_error( "Failed to count metric usage", - &format!("Failed to count metric usage (visibility: {visibility:?}) for organization ({organization_id}) between {start_time} and {end_time}."), + &format!("Failed to count metric usage for organization ({organization_id}) between {start_time} and {end_time}."), e, ) }) @@ -163,8 +125,8 @@ impl InsertMetric { } } -// `private_usage` and `Visibility::Private` are `plus`-only, so this module -// compiles with the `plus` feature (as the rest of the test target already does). +// `usage` and `Visibility::Private` are `plus`-only, so this module compiles +// with the `plus` feature (as the rest of the test target already does). #[cfg(test)] mod tests { use bencher_json::{DateTime, project::Visibility}; @@ -244,7 +206,7 @@ mod tests { } #[test] - fn private_usage_counts_only_private_projects() { + fn usage_counts_public_and_private_projects() { let mut conn = setup_test_db(); // `create_base_entities` makes a Public project (visibility 0). let base = create_base_entities(&mut conn); @@ -259,18 +221,7 @@ mod tests { DateTime::TEST, ) .unwrap(); - let private = QueryMetric::private_usage( - &mut conn, - base.organization_id, - DateTime::TEST, - DateTime::TEST, - ) - .unwrap(); assert_eq!(all, 2, "usage counts Public and Private Project metrics"); - assert_eq!( - private, 1, - "private_usage counts only Private Project metrics" - ); } } diff --git a/lib/bencher_schema/src/model/project/series.rs b/lib/bencher_schema/src/model/project/series.rs index 9c10fcdd9..063b0abb6 100644 --- a/lib/bencher_schema/src/model/project/series.rs +++ b/lib/bencher_schema/src/model/project/series.rs @@ -99,7 +99,7 @@ pub fn count_active( /// restricted to private projects. /// /// Not currently used for billing (Pro bills all visibilities via [`count_active`]); -/// kept for a private-only view. Mirrors `QueryMetric::private_usage`. +/// kept for a private-only view. pub fn count_active_private( conn: &mut DbConnection, organization_id: OrganizationId, diff --git a/plus/bencher_billing/src/biller.rs b/plus/bencher_billing/src/biller.rs index 85aa2d5ea..8d87bd81b 100644 --- a/plus/bencher_billing/src/biller.rs +++ b/plus/bencher_billing/src/biller.rs @@ -647,6 +647,7 @@ impl Biller { self.products .tier_base_fee_cents(&subscription_item.price.id), )?; + let tiers = self.products.price_tiers(&subscription_item.price.id); let status = Self::map_status(&subscription.status); @@ -656,6 +657,7 @@ impl Biller { card, level, unit_amount: unit_amount.into(), + tiers, created, current_period_start, current_period_end, diff --git a/plus/bencher_billing/src/products.rs b/plus/bencher_billing/src/products.rs index 41b637162..3be05331c 100644 --- a/plus/bencher_billing/src/products.rs +++ b/plus/bencher_billing/src/products.rs @@ -1,11 +1,13 @@ use std::collections::{HashMap, HashSet}; use bencher_json::system::config::{JsonProduct, JsonProducts}; +use bencher_json::{BigInt, JsonPriceTier}; use stripe::Client as StripeClient; use stripe_product::{ Price as StripePrice, PriceId, Product as StripeProduct, ProductId, price::RetrievePrice, product::RetrieveProduct, }; +use stripe_shared::PriceTier; use crate::BillingError; @@ -63,6 +65,27 @@ impl Products { /// unknown or not tiered. Relies on prices being retrieved with `tiers` expanded /// (see [`Product::pricing`]). pub fn tier_base_fee_cents(&self, price_id: &PriceId) -> Option { + self.find_price(price_id)? + .tiers + .as_ref()? + .first()? + .flat_amount + } + + /// The full tiered price ladder for `price_id`, mapped to JSON so the Console can + /// render it from the billed source of truth instead of hardcoding it. `None` if the + /// price is unknown or not tiered. Relies on prices retrieved with `tiers` expanded + /// (see [`Product::pricing`]). + pub fn price_tiers(&self, price_id: &PriceId) -> Option> { + self.find_price(price_id)? + .tiers + .as_ref() + .map(|tiers| tiers.iter().map(to_json_tier).collect()) + } + + /// The cached price with `price_id`, searched across all products' metered and + /// licensed prices. + fn find_price(&self, price_id: &PriceId) -> Option<&StripePrice> { [ &self.pro, &self.metrics, @@ -73,7 +96,21 @@ impl Products { .into_iter() .flat_map(|product| product.metered.values().chain(product.licensed.values())) .find(|price| &price.id == price_id) - .and_then(|price| price.tiers.as_ref()?.first()?.flat_amount) + } +} + +/// Map a Stripe price tier to its JSON form: `up_to` is the inclusive series upper bound +/// (`None` is the unbounded top tier), and `unit_amount`/`flat_amount` are mapped +/// independently from cents (a tier may carry both, additively). +fn to_json_tier(tier: &PriceTier) -> JsonPriceTier { + let cents = |amount: Option| amount.and_then(|c| u64::try_from(c).ok()).map(BigInt::from); + JsonPriceTier { + // An `up_to` outside `u32` maps to `None`, which downstream means the unbounded + // "Get in Touch" tier. Stripe bounds are far below `u32::MAX`, so this is + // unreachable in practice; a real ladder is never silently reshaped. + up_to: tier.up_to.and_then(|n| u32::try_from(n).ok()), + unit_amount: cents(tier.unit_amount), + flat_amount: cents(tier.flat_amount), } } @@ -127,3 +164,57 @@ impl Product { Ok(biller_pricing) } } + +#[cfg(test)] +mod tests { + use stripe_shared::PriceTier; + + use super::to_json_tier; + + fn price_tier( + up_to: Option, + unit_amount: Option, + flat_amount: Option, + ) -> PriceTier { + PriceTier { + flat_amount, + flat_amount_decimal: None, + unit_amount, + unit_amount_decimal: None, + up_to, + } + } + + #[test] + fn to_json_tier_flat_only() { + let json = to_json_tier(&price_tier(Some(250), None, Some(10_000))); + assert_eq!(json.up_to, Some(250)); + assert_eq!(json.unit_amount.map(u64::from), None); + assert_eq!(json.flat_amount.map(u64::from), Some(10_000)); + } + + #[test] + fn to_json_tier_unit_only() { + let json = to_json_tier(&price_tier(Some(500), Some(50), None)); + assert_eq!(json.up_to, Some(500)); + assert_eq!(json.unit_amount.map(u64::from), Some(50)); + assert_eq!(json.flat_amount.map(u64::from), None); + } + + #[test] + fn to_json_tier_flat_and_unit() { + // Stripe allows a tier to carry both a flat fee and a per-unit amount. + let json = to_json_tier(&price_tier(Some(375), Some(25), Some(15_000))); + assert_eq!(json.up_to, Some(375)); + assert_eq!(json.unit_amount.map(u64::from), Some(25)); + assert_eq!(json.flat_amount.map(u64::from), Some(15_000)); + } + + #[test] + fn to_json_tier_unbounded_top() { + // The unbounded top tier (`up_to: None`) is presented as "Get in Touch". + let json = to_json_tier(&price_tier(None, None, Some(20_000))); + assert_eq!(json.up_to, None); + assert_eq!(json.flat_amount.map(u64::from), Some(20_000)); + } +} diff --git a/services/api/openapi.json b/services/api/openapi.json index 97a040f27..cb7993252 100644 --- a/services/api/openapi.json +++ b/services/api/openapi.json @@ -14947,6 +14947,14 @@ "status": { "$ref": "#/components/schemas/PlanStatus" }, + "tiers": { + "nullable": true, + "description": "The tiered price ladder for this plan, ordered ascending by `up_to`. Populated for the Pro tiered active-series price; `None` for flat-priced or licensed plans.", + "type": "array", + "items": { + "$ref": "#/components/schemas/JsonPriceTier" + } + }, "unit_amount": { "$ref": "#/components/schemas/BigInt" } @@ -15184,6 +15192,37 @@ } } }, + "JsonPriceTier": { + "description": "One tier of a Stripe tiered price, surfaced so the Console can render the price ladder from the billed source of truth instead of hardcoding it.\n\nField order mirrors Stripe's tier shape: `up_to`, then `unit_amount`, then `flat_amount`. A tier may carry both a `unit_amount` and a `flat_amount` (Stripe allows it); the two are independent and additive, not mutually exclusive. Today the Pro price sets only `flat_amount`.", + "type": "object", + "properties": { + "flat_amount": { + "nullable": true, + "description": "Flat price (cents) for the whole tier. May coexist with `unit_amount`.", + "allOf": [ + { + "$ref": "#/components/schemas/BigInt" + } + ] + }, + "unit_amount": { + "nullable": true, + "description": "Per-series price (cents) charged within this tier. May coexist with `flat_amount`.", + "allOf": [ + { + "$ref": "#/components/schemas/BigInt" + } + ] + }, + "up_to": { + "nullable": true, + "description": "Inclusive upper bound (active-series count) for this tier. `None` is the unbounded top tier, presented as \"Get in Touch\".", + "type": "integer", + "format": "uint32", + "minimum": 0 + } + } + }, "JsonProduct": { "type": "object", "properties": { @@ -17715,6 +17754,13 @@ "JsonUsage": { "type": "object", "properties": { + "active_series": { + "nullable": true, + "description": "The active series usage amount. Populated for Pro metered plans, whose bill is based on monthly-active series (distinct testbed x benchmark x measure).", + "type": "integer", + "format": "uint32", + "minimum": 0 + }, "end_time": { "description": "The end time of the usage.", "allOf": [ @@ -17742,7 +17788,7 @@ }, "metrics": { "nullable": true, - "description": "The metrics usage amount.", + "description": "The metrics usage amount. Not populated for Pro metered plans, which bill on active series (see `active_series`) rather than metrics.", "type": "integer", "format": "uint32", "minimum": 0 diff --git a/services/console/CLAUDE.md b/services/console/CLAUDE.md index f8cc13b30..9acd02d82 100644 --- a/services/console/CLAUDE.md +++ b/services/console/CLAUDE.md @@ -43,7 +43,15 @@ Runs at: http://localhost:3000 ## Testing ```bash -&& npm test +npm test +``` + +`npm test` runs `npm run setup && vitest`, and `vitest` (without `run`) can start +in watch mode and never exit, even when output is piped. For non-interactive or +scripted runs (agents, CI-like checks), use a single run instead: + +```bash +npx vitest run ``` ## Formatting diff --git a/services/console/src/components/console/billing/BillingPanel.tsx b/services/console/src/components/console/billing/BillingPanel.tsx index 5c7ea9203..ef1c4ed3d 100644 --- a/services/console/src/components/console/billing/BillingPanel.tsx +++ b/services/console/src/components/console/billing/BillingPanel.tsx @@ -1,6 +1,7 @@ import * as Sentry from "@sentry/astro"; import type { Params } from "astro"; import { + For, Match, type Resource, Show, @@ -22,15 +23,19 @@ import { import { authUser } from "../../../util/auth"; import { useSearchParams } from "../../../util/url"; import { - PRO_BASE_USD, - PRO_INCLUDED_CREDIT_USD, + currentSeriesTier, + currentSeriesTierIndex, fmtDate, + fmtTierPrice, fmtUsd, fmtUsdPrecise, + isContactTier, isFirstBillingPeriod, planLevel, planLevelPrice, runnerMinutePrice, + seriesTierRange, + tierEstimateUsd, } from "../../../util/convert"; import { BENCHER_CALENDLY_URL } from "../../../util/ext"; import { httpGet, httpPatch } from "../../../util/http"; @@ -366,11 +371,78 @@ const ResumePlanButton = (props: { ); }; +// Shared cancel/resume control for any Bencher Cloud metered plan. +const PlanCancelResume = (props: { + apiUrl: string; + user: JsonAuthUser; + usage: Resource; + handleRefresh: () => void; +}) => { + return ( + + } + > +

+ Your plan is scheduled to cancel on{" "} + {fmtDate(props.usage()?.plan?.current_period_end)}. You keep access + until then. +

+ +
+ ); +}; + +// Bencher Cloud metered plans. Pro bills on active series (its own panel); legacy Team and +// metered Enterprise still bill on metrics. const CloudMeteredPanel = (props: { apiUrl: string; user: JsonAuthUser; usage: Resource; handleRefresh: () => void; +}) => { + const isPro = createMemo(() => props.usage()?.plan?.level === PlanLevel.Pro); + return ( + + } + > + + + ); +}; + +// Legacy metered metrics plans (Team and metered Enterprise): billed per metric plus bare +// metal runner minutes. +const MeteredMetricsPanel = (props: { + apiUrl: string; + user: JsonAuthUser; + usage: Resource; + handleRefresh: () => void; }) => { const metricPrice = createMemo(() => planLevelPrice(props.usage()?.plan?.level), @@ -385,13 +457,72 @@ const CloudMeteredPanel = (props: { () => (props.usage()?.runner_minutes ?? 0) * minutePrice(), ); const estTotalCost = createMemo(() => estMetricsCost() + estRunnerCost()); - const isPro = createMemo(() => props.usage()?.plan?.level === PlanLevel.Pro); - const creditRemaining = createMemo(() => - Math.max(0, PRO_INCLUDED_CREDIT_USD - estTotalCost()), + + return ( +
+

+ {planLevel(props.usage()?.plan?.level)} Tier (Bencher Cloud Metered) +

+

+ {fmtDate(props.usage()?.start_time)} -{" "} + {fmtDate(props.usage()?.end_time)} +

+

Cost per Metric: {fmtUsd(metricPrice())}

+

+ Estimated Metrics Used: {props.usage()?.metrics?.toLocaleString() ?? 0} +

+

Estimated Metrics Cost: {fmtUsd(estMetricsCost())}

+
+

Cost per Runner Minute: {fmtUsdPrecise(minutePrice())} / min

+

+ Estimated Runner Minutes Used:{" "} + {props.usage()?.runner_minutes?.toLocaleString() ?? 0} +

+

Estimated Runner Cost: {fmtUsd(estRunnerCost())}

+
+

Current Estimated Total: {fmtUsd(estTotalCost())}

+
+ +
+ {manageSubscription} +
+
+ +
+ ); +}; + +// Pro plans: billed a flat monthly fee per active-series band, read from the Stripe price +// tiers (`usage.plan.tiers`). Bare metal runner minutes bill separately on top, except +// during the first-month free trial, when the base fee is waived and runner minutes are +// included (the whole bill is $0). +const ProMeteredPanel = (props: { + apiUrl: string; + user: JsonAuthUser; + usage: Resource; + handleRefresh: () => void; +}) => { + const tiers = createMemo(() => props.usage()?.plan?.tiers); + const activeSeries = createMemo(() => props.usage()?.active_series ?? 0); + const currentTier = createMemo(() => + currentSeriesTier(tiers(), activeSeries()), + ); + const currentRange = createMemo(() => { + const tierList = tiers(); + const index = currentSeriesTierIndex(tierList, activeSeries()); + return tierList && index >= 0 ? seriesTierRange(tierList, index) : ""; + }); + const contactSales = createMemo(() => isContactTier(currentTier())); + const minutePrice = createMemo(() => + runnerMinutePrice(props.usage()?.plan?.level), ); - // Pro bill = $20 base + overage at the same rates = max($20, usage). - const estProBill = createMemo( - () => PRO_BASE_USD + Math.max(0, estTotalCost() - PRO_INCLUDED_CREDIT_USD), + const estRunnerCost = createMemo( + () => (props.usage()?.runner_minutes ?? 0) * minutePrice(), ); const firstPeriod = createMemo(() => isFirstBillingPeriod( @@ -399,10 +530,12 @@ const CloudMeteredPanel = (props: { props.usage()?.plan?.current_period_start, ), ); - // Pro waives the flat base for the first month via a one-time 100%-off coupon. - // Cap at the bill so it never goes negative. - const trialDiscount = createMemo(() => - isPro() && firstPeriod() ? Math.min(PRO_BASE_USD, estProBill()) : 0, + // The standard estimate: the current band's flat fee plus runner cost. During the + // first-month trial the actual bill is $0 (base waived, runner minutes included); + // this estimate is still shown so trial users can see what a period will cost. + const estTotal = createMemo( + () => + (tierEstimateUsd(currentTier(), activeSeries()) ?? 0) + estRunnerCost(), ); return ( @@ -414,46 +547,91 @@ const CloudMeteredPanel = (props: { {fmtDate(props.usage()?.start_time)} -{" "} {fmtDate(props.usage()?.end_time)} - -

- Included Usage Credit: {fmtUsd(PRO_INCLUDED_CREDIT_USD)} / month -

+

Active Benchmark Series: {activeSeries().toLocaleString()}

+

- Estimated Credit Used:{" "} - {fmtUsd(Math.min(estTotalCost(), PRO_INCLUDED_CREDIT_USD))} + Series Tier: {currentRange()} ({fmtTierPrice(currentTier())})

-

Estimated Credit Remaining: {fmtUsd(creditRemaining())}

-
-

Cost per Metric: {fmtUsd(metricPrice())}

-

- Estimated Metrics Used: {props.usage()?.metrics?.toLocaleString() ?? 0} -

-

Estimated Metrics Cost: {fmtUsd(estMetricsCost())}


-

Cost per Runner Minute: {fmtUsdPrecise(minutePrice())} / min

- Estimated Runner Minutes Used:{" "} + Bare Metal Runner Minutes Used:{" "} {props.usage()?.runner_minutes?.toLocaleString() ?? 0}

-

Estimated Runner Cost: {fmtUsd(estRunnerCost())}

+ +

+ Cost per Runner Minute: {fmtUsdPrecise(minutePrice())} / min +

+

Estimated Runner Cost: {fmtUsd(estRunnerCost())}

+ + } + > +

Runner minutes are included during your first month.

+

-

- Current Estimated Total:{" "} - {fmtUsd(isPro() ? estProBill() : estTotalCost())} -

- -

First Month Free Trial: -{fmtUsd(trialDiscount())}

-

- Estimated Total This Period:{" "} - {fmtUsd(Math.max(0, estProBill() - trialDiscount()))} -

+ + Your usage is beyond the self-serve tiers.{" "} + + Get in touch + {" "} + to set up a plan that fits. +

+ } + > +

Current Estimated Total: {fmtUsd(estTotal())}

+ +

+ First Month Free Trial: base fee waived and runner minutes included +

+

Estimated Total This Period: {fmtUsd(0)}

+
- + +
+

Pro pricing

- $20/mo base includes $20 of usage. Overage bills at the same rates - once the included credit is used. + You are billed a flat monthly fee based on your number of active + benchmark series:

+ + + + + + + + + + {(tier, index) => ( + + + + + )} + + +
Active seriesPrice
{seriesTierRange(tiers() ?? [], index())} + + Get in Touch + + } + > + {fmtTierPrice(tier)} + +

@@ -461,29 +639,12 @@ const CloudMeteredPanel = (props: { {manageSubscription}

- - } - > -

- Your plan is scheduled to cancel on{" "} - {fmtDate(props.usage()?.plan?.current_period_end)}. You keep access - until then. -

- -
+ ); }; diff --git a/services/console/src/components/pricing/InnerPricingTable.tsx b/services/console/src/components/pricing/InnerPricingTable.tsx index fa2c2c0c1..7a3856031 100644 --- a/services/console/src/components/pricing/InnerPricingTable.tsx +++ b/services/console/src/components/pricing/InnerPricingTable.tsx @@ -36,7 +36,7 @@ interface Tier { popular?: boolean; ctaStyle: "primary" | "outlined"; features: Feature[]; - metrics: HighlightLine[]; + series: HighlightLine[]; runners: RunnerSpec; } @@ -53,7 +53,7 @@ const TIERS: Tier[] = [ { mark: "dash", label: "Private projects" }, { mark: "check", label: "Community support" }, ], - metrics: [{ label: "Public Metrics", value: "Free" }], + series: [{ label: "Public Series", value: "Free" }], runners: { concurrentJobs: "1", jobTimeout: "5 min", @@ -65,18 +65,18 @@ const TIERS: Tier[] = [ plan: PlanLevel.Pro, title: "Pro", tagline: "For performance-critical projects", - price: "$20", - priceUnit: " / month + additional usage", + price: "$100", + priceUnit: " / month", popular: true, ctaStyle: "primary", features: [ - { mark: "check", label: "$20 of included usage credit" }, + { mark: "check", label: "250 benchmark series included" }, { mark: "check", label: "Public & Private projects" }, { mark: "check", label: "Priority support" }, ], - metrics: [ - { label: "Public Metrics", value: "Free" }, - { label: "Private Metrics", value: "$0.01 / result" }, + series: [ + { label: "Included series", value: "250" }, + { label: "Additional series", value: "Tiered" }, ], runners: { concurrentJobs: "Unlimited", @@ -97,7 +97,7 @@ const TIERS: Tier[] = [ { mark: "check", label: "On-premise deployment" }, { mark: "check", label: "Dedicated onboarding" }, ], - metrics: [{ label: "Public Metrics" }, { label: "Private Metrics" }], + series: [{ label: "Public Series" }, { label: "Private Series" }], runners: { concurrentJobs: "Unlimited", jobTimeout: "Unlimited", @@ -189,11 +189,8 @@ const InnerPricingTable = (props: Props) => { )} - - + +
diff --git a/services/console/src/pages/pricing.astro b/services/console/src/pages/pricing.astro index 627859583..9eaafc6e7 100644 --- a/services/console/src/pages/pricing.astro +++ b/services/console/src/pages/pricing.astro @@ -64,12 +64,11 @@ const description =

- What counts as a benchmark result? - Each benchmark run produces one result called a Metric. - 5 benchmarks × 10 runs = 50 Metrics. - Even if your harness runs each benchmark 1,000 times internally for accuracy, - that's still 1 Metric per run. - On Pro, private Metrics and Bencher Bare Metal runner time both draw from one shared monthly usage credit. + What is a benchmark series? + A series is a unique combination of testbed, benchmark, and measure. + 5 benchmarks × 2 measures on 1 testbed = 10 series, + no matter how many times you run them. + Pro is a flat monthly fee based on how many series are active that month, across both public and private projects.

@@ -78,22 +77,25 @@ const description =

FAQ

-
METRICS & BILLING
+
SERIES & BILLING
A benchmark result (called a Metric) is a single point-in-time measurement. Each benchmark run produces one Metric, so 5 benchmarks × 10 runs = 50 Metrics. Even if your harness samples a benchmark 1,000 times internally for accuracy, that still counts as 1 Metric per run. A benchmark with multiple measures (like latency and throughput) creates one Metric per measure per run, so 5 benchmarks × 2 measures × 10 runs = 100 Metrics. + + A benchmark series is a unique combination of testbed, benchmark, and measure. It is the unit the Pro plan is billed on. A series is active in a month if it reports at least one result that month, no matter how many results or runs. So 5 benchmarks × 2 measures on 1 testbed is 10 series whether you run them once or a thousand times. Active series are counted across both public and private projects. + A Public Project is visible to anyone who can reach your Bencher instance. On Bencher Cloud, all Public Projects are listed here. A Private Project is only visible to members of your organization and requires a paid plan (Pro or Enterprise). - - On Bencher Cloud, the Pro plan is $20/month and includes $20 of usage credit. Private Metrics ($0.01 each) and Bencher Bare Metal runner time ($1.00/hour) both draw from this single monthly credit, so most teams pay exactly $20. If you go past the included credit, additional usage is billed at the same rates, with no spend cliff. Credit is granted fresh each month and does not roll over. Bencher Self-Hosted Metrics are billed annually based on a licensed quantity. + + On Bencher Cloud, the Pro plan is a flat monthly fee based on your active benchmark series: $100/month for up to 250 series, $150/month for 251 to 375, and $200/month for 376 to 500. Above 500 series, get in touch and we will set up a plan that fits. Bencher Bare Metal runner time ($1.00/hour) is billed separately on top, except during your first month free trial. Bencher Self-Hosted is billed annually based on a licensed quantity. - Yes. Pro starts with a 1-month free trial: your first month's $20 base fee is waived and you get $20 of usage credit to spend across Metrics and Bare Metal. A credit card is required to start the trial. After 30 days it converts to the standard $20/month. There is no per-seat pricing on any plan. + Yes. Pro starts with a 1-month free trial: your first month's base fee is waived and Bencher Bare Metal runner minutes are included, so your first month is free. A credit card is required to start the trial. After 30 days it converts to the standard monthly fee for your active series. There is no per-seat pricing on any plan. - - No. Metrics from Public Projects are always free and unlimited. Only Private Project Metrics draw from your Pro usage credit. The Free plan supports Public Projects only, with daily rate limits. + + The Free plan supports Public Projects only, free of charge, with daily rate limits. On the Pro plan, your active benchmark series are counted across all your projects, both public and private.
    @@ -144,13 +146,13 @@ const description =

    See the bare metal runner reference for details.

    -

    On Bencher Cloud, Bare Metal On-Demand runner time costs $1.00/hr ($0.01666/min) and draws from the same monthly Pro usage credit as private Metrics. One credit pool covers both, so you reason about a single number. You're only billed for the minutes your benchmarks are actively running, and overage past the included credit is billed at the same rate.

    +

    On Bencher Cloud, Bare Metal On-Demand runner time costs $1.00/hr ($0.01666/min), billed separately on top of your Pro series fee. You're only billed for the minutes your benchmarks are actively running. During your first month free trial, runner minutes are included.

    Bencher Self-Hosted users do not pay for runner minutes.

    -

    The Pro plan is $20/month and includes $20 of usage credit shared across Metrics and runner time. There is no per-seat pricing and no separate runner minimum.

    +

    The Pro plan starts at $100/month for your active benchmark series. There is no per-seat pricing and no separate runner minimum.

      -
    • On-Demand (Pro, Enterprise): billed for the minutes your benchmarks are actively running, drawn from your monthly credit
    • +
    • On-Demand (Pro, Enterprise): billed for the minutes your benchmarks are actively running
    • Dedicated (Enterprise): flat monthly rate
    • Custom (Enterprise): usage-based
    diff --git a/services/console/src/types/bencher.ts b/services/console/src/types/bencher.ts index 25f9fee80..26d3a76eb 100644 --- a/services/console/src/types/bencher.ts +++ b/services/console/src/types/bencher.ts @@ -917,6 +917,27 @@ export interface JsonPerfQuery { end_time?: string; } +/** + * One tier of a Stripe tiered price, surfaced so the Console can render the price + * ladder from the billed source of truth instead of hardcoding it. + * + * Field order mirrors Stripe's tier shape: `up_to`, then `unit_amount`, then + * `flat_amount`. A tier may carry both a `unit_amount` and a `flat_amount` (Stripe + * allows it); the two are independent and additive, not mutually exclusive. Today the + * Pro price sets only `flat_amount`. + */ +export interface JsonPriceTier { + /** + * Inclusive upper bound (active-series count) for this tier. `None` is the + * unbounded top tier, presented as "Get in Touch". + */ + up_to?: number; + /** Per-series price (cents) charged within this tier. May coexist with `flat_amount`. */ + unit_amount?: number; + /** Flat price (cents) for the whole tier. May coexist with `unit_amount`. */ + flat_amount?: number; +} + export enum PlanStatus { Active = "active", Canceled = "canceled", @@ -934,6 +955,11 @@ export interface JsonPlan { card: JsonCardDetails; level: PlanLevel; unit_amount: number; + /** + * The tiered price ladder for this plan, ordered ascending by `up_to`. Populated + * for the Pro tiered active-series price; `None` for flat-priced or licensed plans. + */ + tiers?: JsonPriceTier[]; /** * When the metered subscription was created. The first (free-trial) billing * period is the one that contains this timestamp. @@ -1189,8 +1215,16 @@ export interface JsonUsage { start_time: string; /** The end time of the usage. */ end_time: string; - /** The metrics usage amount. */ + /** + * The metrics usage amount. Not populated for Pro metered plans, which bill on + * active series (see `active_series`) rather than metrics. + */ metrics?: number; + /** + * The active series usage amount. Populated for Pro metered plans, whose bill is + * based on monthly-active series (distinct testbed x benchmark x measure). + */ + active_series?: number; /** The runner minutes usage amount. */ runner_minutes?: number; } diff --git a/services/console/src/util/convert.test.ts b/services/console/src/util/convert.test.ts index 424252a3e..1112743e0 100644 --- a/services/console/src/util/convert.test.ts +++ b/services/console/src/util/convert.test.ts @@ -1,26 +1,34 @@ import { describe, expect, test } from "vitest"; -import { PlanLevel } from "../types/bencher"; +import { type JsonPriceTier, PlanLevel } from "../types/bencher"; import { addToArray, arrayFromString, arrayToString, base64ToBytes, bytesToBase64, + currentSeriesTier, + currentSeriesTierIndex, dateTimeMillis, dateToTime, decodeBase64, encodeBase64, fmtDate, + fmtTierPrice, fmtUsd, fmtUsdPrecise, isBoolParam, + isContactTier, isFirstBillingPeriod, planLevel, planLevelPrice, prettyPrintFloat, removeFromArray, runnerMinutePrice, + seriesTierRange, sizeArray, + tierEstimateUsd, + tierFlatUsd, + tierUnitUsd, timeToDate, timeToDateIso, timeToDateOnlyIso, @@ -337,3 +345,120 @@ describe("prettyPrintFloat", () => { expect(prettyPrintFloat(0)).toBe("0.00"); }); }); + +// A representative Pro ladder: 0-250 $100, 251-375 $150, 376-500 $200, 501+ Get in Touch. +const SERIES_TIERS: JsonPriceTier[] = [ + { up_to: 250, flat_amount: 10_000 }, + { up_to: 375, flat_amount: 15_000 }, + { up_to: 500, flat_amount: 20_000 }, + {}, +]; + +describe("currentSeriesTier", () => { + test("selects the band containing the series count", () => { + expect(currentSeriesTier(SERIES_TIERS, 0)?.up_to).toBe(250); + expect(currentSeriesTier(SERIES_TIERS, 250)?.up_to).toBe(250); + expect(currentSeriesTier(SERIES_TIERS, 251)?.up_to).toBe(375); + expect(currentSeriesTier(SERIES_TIERS, 375)?.up_to).toBe(375); + expect(currentSeriesTier(SERIES_TIERS, 376)?.up_to).toBe(500); + expect(currentSeriesTier(SERIES_TIERS, 500)?.up_to).toBe(500); + }); + + test("returns the unbounded contact tier above the last bound", () => { + expect(currentSeriesTier(SERIES_TIERS, 501)?.up_to).toBeUndefined(); + expect(currentSeriesTier(SERIES_TIERS, 10_000)?.up_to).toBeUndefined(); + }); + + test("returns undefined when tiers are absent", () => { + expect(currentSeriesTier(undefined, 100)).toBeUndefined(); + }); +}); + +describe("currentSeriesTierIndex", () => { + test("selects the index of the band containing the series count", () => { + expect(currentSeriesTierIndex(SERIES_TIERS, 0)).toBe(0); + expect(currentSeriesTierIndex(SERIES_TIERS, 250)).toBe(0); + expect(currentSeriesTierIndex(SERIES_TIERS, 251)).toBe(1); + expect(currentSeriesTierIndex(SERIES_TIERS, 375)).toBe(1); + expect(currentSeriesTierIndex(SERIES_TIERS, 376)).toBe(2); + expect(currentSeriesTierIndex(SERIES_TIERS, 500)).toBe(2); + }); + + test("returns the unbounded contact tier index above the last bound", () => { + expect(currentSeriesTierIndex(SERIES_TIERS, 501)).toBe(3); + expect(currentSeriesTierIndex(SERIES_TIERS, 10_000)).toBe(3); + }); + + test("returns -1 when tiers are absent", () => { + expect(currentSeriesTierIndex(undefined, 100)).toBe(-1); + }); +}); + +describe("seriesTierRange", () => { + test("formats inclusive ranges from tier bounds", () => { + expect(seriesTierRange(SERIES_TIERS, 0)).toBe("0-250"); + expect(seriesTierRange(SERIES_TIERS, 1)).toBe("251-375"); + expect(seriesTierRange(SERIES_TIERS, 2)).toBe("376-500"); + }); + + test("formats the unbounded top tier with a plus", () => { + expect(seriesTierRange(SERIES_TIERS, 3)).toBe("501+"); + }); +}); + +describe("isContactTier", () => { + test("true only for the unbounded top tier", () => { + expect(isContactTier({})).toBe(true); + expect(isContactTier({ up_to: 250, flat_amount: 10_000 })).toBe(false); + expect(isContactTier(undefined)).toBe(true); + }); +}); + +describe("tierFlatUsd / tierUnitUsd", () => { + test("convert cents to USD, or null when absent", () => { + const tier = { up_to: 250, flat_amount: 10_000, unit_amount: 5 }; + expect(tierFlatUsd(tier)).toBe(100); + expect(tierUnitUsd(tier)).toBe(0.05); + expect(tierFlatUsd({ up_to: 250 })).toBeNull(); + expect(tierUnitUsd({ up_to: 250 })).toBeNull(); + }); +}); + +describe("fmtTierPrice", () => { + test("flat-only tier", () => { + expect(fmtTierPrice({ up_to: 250, flat_amount: 10_000 })).toBe( + "$100.00 / month", + ); + }); + + test("per-series-only tier", () => { + expect(fmtTierPrice({ up_to: 250, unit_amount: 5 })).toBe("$0.05 / series"); + }); + + test("tier with both a flat fee and a per-series fee", () => { + expect( + fmtTierPrice({ up_to: 250, flat_amount: 10_000, unit_amount: 5 }), + ).toBe("$100.00 / month + $0.05 / series"); + }); + + test("unbounded contact tier", () => { + expect(fmtTierPrice({})).toBe("Get in Touch"); + expect(fmtTierPrice(undefined)).toBe("Get in Touch"); + }); +}); + +describe("tierEstimateUsd", () => { + test("flat-only tier ignores the series count", () => { + expect(tierEstimateUsd({ up_to: 250, flat_amount: 10_000 }, 200)).toBe(100); + }); + + test("adds the per-series fee times the series count", () => { + expect( + tierEstimateUsd({ up_to: 500, flat_amount: 10_000, unit_amount: 5 }, 300), + ).toBe(100 + 0.05 * 300); + }); + + test("returns null for the contact tier", () => { + expect(tierEstimateUsd({}, 600)).toBeNull(); + }); +}); diff --git a/services/console/src/util/convert.ts b/services/console/src/util/convert.ts index 38151110c..c3a2b6251 100644 --- a/services/console/src/util/convert.ts +++ b/services/console/src/util/convert.ts @@ -1,4 +1,4 @@ -import { ModelTest, PlanLevel } from "../types/bencher"; +import { type JsonPriceTier, ModelTest, PlanLevel } from "../types/bencher"; export const dateTimeMillis = (date_str: undefined | string) => { if (date_str === undefined) { @@ -111,12 +111,6 @@ export const isBoolParam = (param: undefined | string): boolean => { return param === "false" || param === "true"; }; -// The flat monthly Pro base fee (USD), which includes an equal amount of usage credit. -export const PRO_BASE_USD = 20; -// The fungible usage credit included with Pro each month (USD). Drawn down across -// metrics and bare metal; expires at month end. -export const PRO_INCLUDED_CREDIT_USD = 20; - // The first billing period is the one containing the subscription's creation time, // i.e. created falls on/after the current period start. export const isFirstBillingPeriod = ( @@ -211,3 +205,84 @@ export const prettyPrintFloat = (float: number | undefined) => { maximumFractionDigits: 2, }); }; + +// Pro is billed on monthly-active series via a Stripe tiered price. The Console renders +// the ladder from `usage.plan.tiers` (the billed source of truth) instead of hardcoding +// it. A tier may carry a flat fee, a per-series fee, or both (additive); the unbounded top +// tier (no `up_to`) is the "Get in Touch" contact-sales band. + +const tierCentsToUsd = (cents: number | undefined): number | null => + cents === undefined ? null : cents / 100; + +// The flat monthly fee (USD) for a tier, or null if it has none. +export const tierFlatUsd = (tier: JsonPriceTier | undefined): number | null => + tierCentsToUsd(tier?.flat_amount); + +// The per-series fee (USD) for a tier, or null if it has none. +export const tierUnitUsd = (tier: JsonPriceTier | undefined): number | null => + tierCentsToUsd(tier?.unit_amount); + +// The unbounded top tier (no `up_to`) is presented as "Get in Touch" (enterprise/sales). +export const isContactTier = (tier: JsonPriceTier | undefined): boolean => + tier?.up_to === undefined; + +// The index in `tiers` of the band containing `series`: the first tier with no upper +// bound or whose `up_to` is >= series. Tiers are ordered ascending by `up_to`. -1 when +// tiers are absent or no band matches. +export const currentSeriesTierIndex = ( + tiers: JsonPriceTier[] | undefined, + series: number, +): number => + tiers?.findIndex( + (tier) => tier.up_to === undefined || series <= tier.up_to, + ) ?? -1; + +// The tier whose band contains `series` (see `currentSeriesTierIndex`). +export const currentSeriesTier = ( + tiers: JsonPriceTier[] | undefined, + series: number, +): JsonPriceTier | undefined => tiers?.[currentSeriesTierIndex(tiers, series)]; + +// The inclusive series range label for the tier at `index`, e.g. "0-250", "251-375", +// "501+". The lower bound is the previous tier's `up_to` + 1 (0 for the first tier). +export const seriesTierRange = ( + tiers: JsonPriceTier[], + index: number, +): string => { + const lower = index === 0 ? 0 : (tiers[index - 1]?.up_to ?? 0) + 1; + const upTo = tiers[index]?.up_to; + return upTo === undefined ? `${lower}+` : `${lower}-${upTo}`; +}; + +// A human label for a tier's price: "Get in Touch" for the contact tier, otherwise the +// present components joined, e.g. "$100.00 / month", "$0.05 / series", or +// "$100.00 / month + $0.05 / series". +export const fmtTierPrice = (tier: JsonPriceTier | undefined): string => { + if (isContactTier(tier)) { + return "Get in Touch"; + } + const parts: string[] = []; + const flat = tierFlatUsd(tier); + if (flat !== null) { + parts.push(`${fmtUsd(flat)} / month`); + } + const unit = tierUnitUsd(tier); + if (unit !== null) { + parts.push(`${fmtUsd(unit)} / series`); + } + return parts.length > 0 ? parts.join(" + ") : "Get in Touch"; +}; + +// The estimated monthly charge (USD) for being in `tier` with `series` active series: +// the flat fee plus the per-series fee times `series`. null for the contact tier (no +// self-serve price). The per-series component is forward-compatible; today only the flat +// fee is set. +export const tierEstimateUsd = ( + tier: JsonPriceTier | undefined, + series: number, +): number | null => { + if (isContactTier(tier)) { + return null; + } + return (tierFlatUsd(tier) ?? 0) + (tierUnitUsd(tier) ?? 0) * series; +};