#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SubscriptionStatus {
Trialing,
Active,
Incomplete,
IncompleteExpired,
PastDue,
Canceled,
Unpaid,
Paused,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct SubscriptionInfo {
pub stripe_subscription_id: String,
pub plan: String,
pub status: SubscriptionStatus,
pub trial_ends_at: Option<chrono::DateTime<chrono::Utc>>,
pub cancel_at_period_end: bool,
pub current_period_end: chrono::DateTime<chrono::Utc>,
pub stripe_connect_account_id: Option<String>,
}
impl SubscriptionInfo {
pub fn on_trial(&self) -> bool {
self.status == SubscriptionStatus::Trialing
}
pub fn subscribed(&self) -> bool {
matches!(
self.status,
SubscriptionStatus::Active | SubscriptionStatus::Trialing
)
}
pub fn on_grace_period(&self) -> bool {
self.cancel_at_period_end && self.subscribed()
}
}
pub fn plan_satisfies(tenant_plan: &str, required_plan: &str) -> bool {
const TIERS: &[&str] = &["free", "pro", "enterprise"];
let tenant_rank = TIERS.iter().position(|&p| p == tenant_plan);
let required_rank = TIERS.iter().position(|&p| p == required_plan);
match (tenant_rank, required_rank) {
(Some(t), Some(r)) => t >= r,
_ => tenant_plan == required_plan,
}
}
pub mod checkout;
pub mod sync;
#[cfg(test)]
mod tests {
use super::*;
fn make_info(status: SubscriptionStatus, cancel_at_period_end: bool) -> SubscriptionInfo {
SubscriptionInfo {
stripe_subscription_id: "sub_test".to_string(),
plan: "pro".to_string(),
status,
trial_ends_at: None,
cancel_at_period_end,
current_period_end: chrono::Utc::now(),
stripe_connect_account_id: None,
}
}
#[test]
fn on_trial_returns_true_only_for_trialing() {
assert!(make_info(SubscriptionStatus::Trialing, false).on_trial());
assert!(!make_info(SubscriptionStatus::Active, false).on_trial());
assert!(!make_info(SubscriptionStatus::Incomplete, false).on_trial());
assert!(!make_info(SubscriptionStatus::IncompleteExpired, false).on_trial());
assert!(!make_info(SubscriptionStatus::PastDue, false).on_trial());
assert!(!make_info(SubscriptionStatus::Canceled, false).on_trial());
assert!(!make_info(SubscriptionStatus::Unpaid, false).on_trial());
assert!(!make_info(SubscriptionStatus::Paused, false).on_trial());
}
#[test]
fn subscribed_returns_true_for_active_and_trialing() {
assert!(make_info(SubscriptionStatus::Active, false).subscribed());
assert!(make_info(SubscriptionStatus::Trialing, false).subscribed());
assert!(!make_info(SubscriptionStatus::Incomplete, false).subscribed());
assert!(!make_info(SubscriptionStatus::IncompleteExpired, false).subscribed());
assert!(!make_info(SubscriptionStatus::PastDue, false).subscribed());
assert!(!make_info(SubscriptionStatus::Canceled, false).subscribed());
assert!(!make_info(SubscriptionStatus::Unpaid, false).subscribed());
assert!(!make_info(SubscriptionStatus::Paused, false).subscribed());
}
#[test]
fn on_grace_period_requires_cancel_at_period_end_and_subscribed() {
assert!(make_info(SubscriptionStatus::Active, true).on_grace_period());
assert!(make_info(SubscriptionStatus::Trialing, true).on_grace_period());
assert!(!make_info(SubscriptionStatus::Active, false).on_grace_period());
assert!(!make_info(SubscriptionStatus::Trialing, false).on_grace_period());
assert!(!make_info(SubscriptionStatus::Canceled, true).on_grace_period());
assert!(!make_info(SubscriptionStatus::PastDue, true).on_grace_period());
assert!(!make_info(SubscriptionStatus::Unpaid, true).on_grace_period());
assert!(!make_info(SubscriptionStatus::Paused, true).on_grace_period());
}
#[test]
fn plan_satisfies_enterprise_satisfies_pro_and_free() {
assert!(plan_satisfies("enterprise", "enterprise"));
assert!(plan_satisfies("enterprise", "pro"));
assert!(plan_satisfies("enterprise", "free"));
}
#[test]
fn plan_satisfies_pro_satisfies_free_but_not_enterprise() {
assert!(plan_satisfies("pro", "pro"));
assert!(plan_satisfies("pro", "free"));
assert!(!plan_satisfies("pro", "enterprise"));
}
#[test]
fn plan_satisfies_free_satisfies_only_itself() {
assert!(plan_satisfies("free", "free"));
assert!(!plan_satisfies("free", "pro"));
assert!(!plan_satisfies("free", "enterprise"));
}
#[test]
fn plan_satisfies_unknown_plans_match_exact() {
assert!(plan_satisfies("custom", "custom"));
assert!(!plan_satisfies("custom", "pro"));
assert!(!plan_satisfies("pro", "custom"));
}
#[test]
fn subscription_status_serializes_to_snake_case() {
assert_eq!(
serde_json::to_string(&SubscriptionStatus::PastDue).unwrap(),
"\"past_due\""
);
assert_eq!(
serde_json::to_string(&SubscriptionStatus::IncompleteExpired).unwrap(),
"\"incomplete_expired\""
);
assert_eq!(
serde_json::to_string(&SubscriptionStatus::Trialing).unwrap(),
"\"trialing\""
);
}
#[test]
fn subscription_info_serializes_to_json_with_all_fields() {
let info = make_info(SubscriptionStatus::Active, false);
let json = serde_json::to_value(&info).unwrap();
assert!(json["stripe_subscription_id"].is_string());
assert!(json["plan"].is_string());
assert!(json["status"].is_string());
assert!(json["cancel_at_period_end"].is_boolean());
assert!(json["current_period_end"].is_string());
}
}