#![cfg_attr(
not(test),
deny(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::unreachable,
clippy::todo,
clippy::unimplemented,
clippy::indexing_slicing,
clippy::string_slice,
clippy::arithmetic_side_effects,
)
)]
use std::marker::PhantomData;
use std::sync::Arc;
use autumn_web::session::Session;
use autumn_web::{AppState, AutumnError};
use axum_core_reexport::FromRequestParts;
use serde::{Deserialize, Serialize};
use crate::BillingService;
use crate::error::BillingError;
use crate::model::{Subscription, SubscriptionStatus};
use crate::plan::{Plan, PlanCatalog, PlanId};
mod axum_core_reexport {
pub use autumn_web::reexports::axum::extract::FromRequestParts;
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub enum PlanRule {
AnyActive,
Plan(PlanId),
Entitlement(String),
}
impl PlanRule {
#[must_use]
pub fn plan(id: impl Into<PlanId>) -> Self {
Self::Plan(id.into())
}
#[must_use]
pub fn entitlement(name: impl Into<String>) -> Self {
Self::Entitlement(name.into())
}
fn accepts(&self, plan: &Plan) -> bool {
match self {
Self::AnyActive => true,
Self::Plan(id) => &plan.id == id,
Self::Entitlement(name) => plan.grants(name),
}
}
fn describe(&self) -> String {
match self {
Self::AnyActive => "an active subscription".to_owned(),
Self::Plan(id) => format!("plan {id}"),
Self::Entitlement(name) => format!("entitlement {name}"),
}
}
}
pub trait PlanRequirement: Send + Sync + 'static {
fn rule() -> PlanRule;
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct SubscriptionView {
pub subscription: Subscription,
pub plan: Option<Plan>,
pub entitled: bool,
}
impl SubscriptionView {
#[must_use]
pub const fn new(subscription: Subscription, plan: Option<Plan>, entitled: bool) -> Self {
Self {
subscription,
plan,
entitled,
}
}
fn satisfies(&self, rule: &PlanRule) -> bool {
self.entitled && self.plan.as_ref().is_some_and(|plan| rule.accepts(plan))
}
}
#[derive(Clone)]
pub struct Billing {
service: Arc<BillingService>,
state: AppState,
}
impl std::fmt::Debug for Billing {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Billing").finish_non_exhaustive()
}
}
impl Billing {
#[must_use]
pub fn from_state(state: &AppState) -> Option<Self> {
BillingService::from_state(state).map(|service| Self {
service,
state: state.clone(),
})
}
#[must_use]
pub fn service(&self) -> &BillingService {
&self.service
}
#[must_use]
pub const fn state(&self) -> &AppState {
&self.state
}
pub async fn current_user(&self, session: &Session) -> Result<String, BillingError> {
user_id_in(session, &self.state).await
}
pub async fn current_subscription(
&self,
user_id: &str,
) -> Result<Option<SubscriptionView>, BillingError> {
let rank = |view: &SubscriptionView| {
(
view.entitled,
view.subscription.status.is_live(),
view.subscription.last_event_at,
)
};
Ok(self.views(user_id).await?.into_iter().max_by(|a, b| {
rank(a)
.cmp(&rank(b))
.then_with(|| b.subscription.id.cmp(&a.subscription.id))
}))
}
pub async fn is_entitled(&self, user_id: &str, rule: &PlanRule) -> Result<bool, BillingError> {
Ok(best_satisfying(self.views(user_id).await?, rule).is_some())
}
pub async fn require(
&self,
user_id: &str,
rule: &PlanRule,
) -> Result<SubscriptionView, BillingError> {
best_satisfying(self.views(user_id).await?, rule)
.ok_or_else(|| BillingError::Forbidden(rule.describe()))
}
async fn views(&self, user_id: &str) -> Result<Vec<SubscriptionView>, BillingError> {
let store = self.service.store();
let Some(customer) = store.customer_by_user(user_id).await? else {
return Ok(Vec::new());
};
let rows = store.subscriptions_for_customer(&customer.id).await?;
Ok(rows.into_iter().map(|row| self.view(row)).collect())
}
fn view(&self, subscription: Subscription) -> SubscriptionView {
let plan = resolve_plan(self.service.catalog(), &subscription).cloned();
let entitled =
plan.is_some() && self.status_ok(subscription.status) && self.in_period(&subscription);
SubscriptionView::new(subscription, plan, entitled)
}
fn status_ok(&self, status: SubscriptionStatus) -> bool {
match status {
SubscriptionStatus::Active | SubscriptionStatus::Trialing => true,
SubscriptionStatus::PastDue => self.service.config().allow_past_due,
_ => false,
}
}
fn in_period(&self, subscription: &Subscription) -> bool {
let end = subscription
.current_period_end
.unwrap_or(subscription.last_event_at);
let grace = chrono::Duration::from_std(self.service.config().grace_period)
.unwrap_or(chrono::Duration::MAX);
let now = self.state.clock().now();
end.checked_add_signed(grace)
.is_none_or(|deadline| deadline >= now)
}
}
fn best_satisfying(
views: impl IntoIterator<Item = SubscriptionView>,
rule: &PlanRule,
) -> Option<SubscriptionView> {
views
.into_iter()
.filter(|view| view.satisfies(rule))
.min_by(|a, b| {
b.subscription
.last_event_at
.cmp(&a.subscription.last_event_at)
.then_with(|| a.subscription.id.cmp(&b.subscription.id))
})
}
fn resolve_plan<'a>(catalog: &'a PlanCatalog, subscription: &Subscription) -> Option<&'a Plan> {
subscription
.provider_price_id
.as_ref()
.and_then(|price| catalog.by_price_id(price))
.or_else(|| subscription.plan_id.as_ref().and_then(|id| catalog.get(id)))
}
impl FromRequestParts<AppState> for Billing {
type Rejection = AutumnError;
async fn from_request_parts(
_parts: &mut autumn_web::reexports::http::request::Parts,
state: &AppState,
) -> Result<Self, Self::Rejection> {
Self::from_state(state)
.ok_or_else(|| AutumnError::service_unavailable_msg("billing plugin is not started"))
}
}
pub struct Entitled<R: PlanRequirement> {
pub view: SubscriptionView,
_rule: PhantomData<R>,
}
impl<R: PlanRequirement> std::fmt::Debug for Entitled<R> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Entitled")
.field("view", &self.view)
.finish()
}
}
impl<R: PlanRequirement> FromRequestParts<AppState> for Entitled<R> {
type Rejection = AutumnError;
async fn from_request_parts(
parts: &mut autumn_web::reexports::http::request::Parts,
state: &AppState,
) -> Result<Self, Self::Rejection> {
let user_id = session_user_id(parts, state)
.await
.map_err(BillingError::into_autumn)?;
let billing = Billing::from_request_parts(parts, state).await?;
let view = billing
.require(&user_id, &R::rule())
.await
.map_err(BillingError::into_autumn)?;
Ok(Self {
view,
_rule: PhantomData,
})
}
}
pub async fn session_user_id(
parts: &mut autumn_web::reexports::http::request::Parts,
state: &AppState,
) -> Result<String, BillingError> {
let session = match Session::from_request_parts(parts, state).await {
Ok(session) => session,
Err(never) => match never {},
};
user_id_in(&session, state).await
}
pub(crate) const TENANT_IDENTITY_MARKER: char = '\u{1}';
async fn user_id_in(session: &Session, state: &AppState) -> Result<String, BillingError> {
let user_id = session
.get(state.auth_session_key())
.await
.filter(|user_id| !user_id.is_empty())
.ok_or(BillingError::Unauthenticated)?;
Ok(scope_identity_to_tenant(user_id))
}
#[must_use]
pub fn scope_identity(tenant: &str, user_id: impl Into<String>) -> String {
encode_tenant_scope(tenant, &user_id.into())
}
fn encode_tenant_scope(tenant: &str, user_id: &str) -> String {
format!("{TENANT_IDENTITY_MARKER}{}:{tenant}{user_id}", tenant.len())
}
#[must_use]
pub fn strip_tenant_scope(user_id: &str) -> &str {
let Some(rest) = user_id.strip_prefix(TENANT_IDENTITY_MARKER) else {
return user_id;
};
let Some((len, rest)) = rest.split_once(':') else {
return user_id;
};
let Ok(tenant_len) = len.parse::<usize>() else {
return user_id;
};
rest.get(tenant_len..).unwrap_or(user_id)
}
fn scope_identity_to_tenant(user_id: String) -> String {
let tenant = autumn_web::tenancy::CURRENT_TENANT
.try_with(Clone::clone)
.ok()
.flatten();
match tenant {
Some(tenant) => encode_tenant_scope(&tenant, &user_id),
None => user_id,
}
}
#[cfg(test)]
mod tenant_scope_tests {
use super::{scope_identity, strip_tenant_scope};
#[test]
fn round_trips() {
let scoped = scope_identity("acme", "7");
assert_eq!(strip_tenant_scope(&scoped), "7");
}
#[test]
fn injective_even_when_a_component_contains_the_marker() {
let a = scope_identity("a", "b\u{1}c");
let b = scope_identity("a\u{1}b", "c");
assert_ne!(a, b, "distinct (tenant, user_id) pairs must not collide");
assert_eq!(strip_tenant_scope(&a), "b\u{1}c");
assert_eq!(strip_tenant_scope(&b), "c");
}
#[test]
fn tenant_containing_a_colon_still_round_trips() {
let scoped = scope_identity("ten:ant", "user:42");
assert_eq!(strip_tenant_scope(&scoped), "user:42");
}
#[test]
fn legacy_bare_id_is_unaffected() {
assert_eq!(strip_tenant_scope("42"), "42");
}
#[test]
fn malformed_scoped_looking_input_does_not_panic() {
assert_eq!(strip_tenant_scope("\u{1}"), "\u{1}");
assert_eq!(strip_tenant_scope("\u{1}abc:x"), "\u{1}abc:x");
assert_eq!(strip_tenant_scope("\u{1}999:short"), "\u{1}999:short");
assert_eq!(strip_tenant_scope("\u{1}3:ab"), "\u{1}3:ab");
}
#[test]
fn known_limitation_a_bare_id_shaped_like_the_encoding_is_misclassified() {
let coincidental_bare_id = "\u{1}1:a7";
assert_eq!(strip_tenant_scope(coincidental_bare_id), "7");
assert_eq!(scope_identity("a", "7"), coincidental_bare_id);
}
}
#[cfg(test)]
mod best_satisfying_tests {
use chrono::{DateTime, TimeZone, Utc};
use super::{PlanRule, SubscriptionView, best_satisfying};
use crate::model::{Subscription, SubscriptionStatus};
use crate::money::{Currency, Money};
use crate::plan::{BillingInterval, Plan};
fn at(hour: u32) -> DateTime<Utc> {
Utc.with_ymd_and_hms(2026, 9, 10, hour, 0, 0).unwrap()
}
fn view(id: &str, plan: &str, hour: u32, entitled: bool) -> SubscriptionView {
let row = Subscription::new(
id,
"cust",
format!("sub_{id}"),
SubscriptionStatus::Active,
at(hour),
at(12),
);
let plan = Plan::new(
plan,
plan,
format!("price_{plan}"),
Money::from_minor(100, Currency::USD),
BillingInterval::Month,
);
SubscriptionView::new(row, Some(plan), entitled)
}
fn permutations<T: Clone>(items: &[T]) -> Vec<Vec<T>> {
fn heap<T: Clone>(k: usize, items: &mut Vec<T>, out: &mut Vec<Vec<T>>) {
if k <= 1 {
out.push(items.clone());
return;
}
for i in 0..k {
heap(k - 1, items, out);
let j = if k.is_multiple_of(2) { i } else { 0 };
items.swap(j, k - 1);
}
}
let mut items = items.to_vec();
let mut out = Vec::new();
heap(items.len(), &mut items, &mut out);
out
}
fn pick(views: Vec<SubscriptionView>, rule: &PlanRule) -> Option<String> {
best_satisfying(views, rule).map(|view| view.subscription.id)
}
#[test]
fn choice_is_independent_of_row_order() {
let views = [
view("b", "pro", 9, true),
view("a", "pro", 9, true),
view("c", "pro", 9, true),
view("d", "pro", 8, true),
view("e", "pro", 10, false),
view("f", "team", 11, true),
];
for order in permutations(&views) {
assert_eq!(
pick(order.clone(), &PlanRule::plan("pro")).as_deref(),
Some("a")
);
assert_eq!(
pick(order.clone(), &PlanRule::AnyActive).as_deref(),
Some("f")
);
assert_eq!(pick(order, &PlanRule::plan("enterprise")), None);
}
}
#[test]
fn no_views_denies() {
assert_eq!(pick(Vec::new(), &PlanRule::AnyActive), None);
}
}