Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
195 changes: 114 additions & 81 deletions lib/api_organizations/src/usage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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},
},
Expand Down Expand Up @@ -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::<QueryPlan>(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
Expand All @@ -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
Expand All @@ -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<JsonUsage, HttpError> {
let licensor = &context.licensor;

let Ok(query_plan) = QueryPlan::belonging_to(query_organization)
.first::<QueryPlan>(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,
Expand Down Expand Up @@ -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<u32>,
active_series: Option<u32>,
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<MeteredUsage, HttpError> {
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,
})
}
2 changes: 1 addition & 1 deletion lib/bencher_json/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down
23 changes: 23 additions & 0 deletions lib/bencher_json/src/organization/plan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Vec<JsonPriceTier>>,
/// When the metered subscription was created. The first (free-trial) billing
/// period is the one that contains this timestamp.
pub created: DateTime,
Expand Down Expand Up @@ -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<u32>,
/// Per-series price (cents) charged within this tier. May coexist with `flat_amount`.
pub unit_amount: Option<BigInt>,
/// Flat price (cents) for the whole tier. May coexist with `unit_amount`.
pub flat_amount: Option<BigInt>,
}

#[cfg(test)]
mod tests {
use bencher_valid::{ExpirationMonth, ExpirationYear};
Expand Down
6 changes: 5 additions & 1 deletion lib/bencher_json/src/organization/usage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u32>,
/// 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<u32>,
/// The runner minutes usage amount.
pub runner_minutes: Option<u32>,
}
Expand Down
34 changes: 1 addition & 33 deletions lib/bencher_schema/src/model/organization/plan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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() {
Expand Down
Loading