use chrono::{DateTime, Utc};
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use stateset_primitives::{CurrencyCode, CustomerId, OrderId, ProductId, SubscriptionId};
use strum::{Display, EnumString};
use uuid::Uuid;
use super::Address;
#[derive(
Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, Display, EnumString,
)]
#[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
#[non_exhaustive]
pub enum BillingInterval {
Weekly,
#[strum(serialize = "biweekly", serialize = "bi_weekly", serialize = "bi-weekly")]
Biweekly,
#[default]
Monthly,
#[strum(serialize = "bimonthly", serialize = "bi_monthly", serialize = "bi-monthly")]
Bimonthly,
Quarterly,
#[strum(serialize = "semiannual", serialize = "semi_annual", serialize = "semi-annual")]
Semiannual,
#[strum(serialize = "annual", serialize = "yearly")]
Annual,
Custom,
}
impl BillingInterval {
#[must_use]
pub const fn days(&self) -> i64 {
match self {
Self::Weekly => 7,
Self::Biweekly => 14,
Self::Monthly => 30,
Self::Bimonthly => 60,
Self::Quarterly => 90,
Self::Semiannual => 180,
Self::Annual => 365,
Self::Custom => 30, }
}
}
#[derive(
Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, Display, EnumString,
)]
#[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
#[non_exhaustive]
pub enum SubscriptionStatus {
Trial,
#[default]
Active,
Paused,
#[strum(serialize = "past_due", serialize = "pastdue")]
PastDue,
#[strum(serialize = "cancelled", serialize = "canceled")]
Cancelled,
Expired,
Pending,
}
impl SubscriptionStatus {
#[must_use]
pub fn can_transition_to(self, next: Self) -> bool {
if self == next {
return true;
}
match self {
Self::Pending => matches!(next, Self::Trial | Self::Active | Self::Cancelled),
Self::Trial => matches!(next, Self::Active | Self::Cancelled | Self::Expired),
Self::Active => {
matches!(next, Self::Paused | Self::PastDue | Self::Cancelled | Self::Expired)
}
Self::Paused => matches!(next, Self::Active | Self::Cancelled),
Self::PastDue => matches!(next, Self::Active | Self::Cancelled | Self::Expired),
Self::Cancelled | Self::Expired => false,
}
}
#[must_use]
pub const fn is_terminal(self) -> bool {
matches!(self, Self::Cancelled | Self::Expired)
}
}
#[derive(
Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, Display, EnumString,
)]
#[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
#[non_exhaustive]
pub enum PlanStatus {
Draft,
#[default]
Active,
Archived,
}
#[derive(
Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, Display, EnumString,
)]
#[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
#[non_exhaustive]
pub enum BillingCycleStatus {
#[default]
Scheduled,
Processing,
Paid,
Failed,
Skipped,
Refunded,
Voided,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Display, EnumString)]
#[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
#[non_exhaustive]
pub enum SubscriptionEventType {
Created,
Activated,
#[strum(serialize = "trial_started", serialize = "trialstarted")]
TrialStarted,
#[strum(serialize = "trial_ended", serialize = "trialended")]
TrialEnded,
Renewed,
#[strum(serialize = "payment_failed", serialize = "paymentfailed")]
PaymentFailed,
#[strum(serialize = "payment_retry_succeeded", serialize = "paymentretrysucceeded")]
PaymentRetrySucceeded,
Paused,
Resumed,
Skipped,
#[strum(serialize = "cancelled", serialize = "canceled")]
Cancelled,
Expired,
#[strum(serialize = "plan_changed", serialize = "planchanged")]
PlanChanged,
#[strum(serialize = "items_modified", serialize = "itemsmodified")]
ItemsModified,
#[strum(serialize = "quantity_changed", serialize = "quantitychanged")]
QuantityChanged,
#[strum(serialize = "address_updated", serialize = "addressupdated")]
AddressUpdated,
#[strum(serialize = "payment_method_updated", serialize = "paymentmethodupdated")]
PaymentMethodUpdated,
#[strum(serialize = "discount_applied", serialize = "discountapplied")]
DiscountApplied,
#[strum(serialize = "discount_removed", serialize = "discountremoved")]
DiscountRemoved,
Refunded,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SubscriptionPlan {
pub id: Uuid,
pub code: String,
pub name: String,
pub description: Option<String>,
pub status: PlanStatus,
pub billing_interval: BillingInterval,
pub custom_interval_days: Option<i32>,
pub price: Decimal,
pub setup_fee: Option<Decimal>,
pub currency: CurrencyCode,
pub trial_days: i32,
pub trial_requires_payment_method: bool,
pub min_cycles: Option<i32>,
pub max_cycles: Option<i32>,
pub items: Vec<SubscriptionPlanItem>,
pub discount_percent: Option<Decimal>,
pub discount_amount: Option<Decimal>,
pub metadata: Option<serde_json::Value>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SubscriptionPlanItem {
pub id: Uuid,
pub plan_id: Uuid,
pub product_id: ProductId,
pub variant_id: Option<Uuid>,
pub sku: String,
pub name: String,
pub quantity: i32,
pub min_quantity: Option<i32>,
pub max_quantity: Option<i32>,
pub is_required: bool,
pub unit_price: Option<Decimal>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Subscription {
pub id: SubscriptionId,
pub subscription_number: String,
pub customer_id: CustomerId,
pub plan_id: Uuid,
pub plan_name: String,
pub status: SubscriptionStatus,
pub billing_interval: BillingInterval,
pub custom_interval_days: Option<i32>,
pub price: Decimal,
pub currency: CurrencyCode,
pub payment_method_id: Option<String>,
pub started_at: DateTime<Utc>,
pub current_period_start: DateTime<Utc>,
pub current_period_end: DateTime<Utc>,
pub next_billing_date: Option<DateTime<Utc>>,
pub trial_ends_at: Option<DateTime<Utc>>,
pub cancelled_at: Option<DateTime<Utc>>,
pub ends_at: Option<DateTime<Utc>>,
pub paused_at: Option<DateTime<Utc>>,
pub resume_at: Option<DateTime<Utc>>,
pub billing_cycle_count: i32,
pub failed_payment_attempts: i32,
pub items: Vec<SubscriptionItem>,
pub shipping_address: Option<Address>,
pub billing_address: Option<Address>,
pub discount_percent: Option<Decimal>,
pub discount_amount: Option<Decimal>,
pub coupon_code: Option<String>,
pub metadata: Option<serde_json::Value>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SubscriptionItem {
pub id: Uuid,
pub subscription_id: SubscriptionId,
pub product_id: ProductId,
pub variant_id: Option<Uuid>,
pub sku: String,
pub name: String,
pub quantity: i32,
pub unit_price: Decimal,
pub line_total: Decimal,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BillingCycle {
pub id: Uuid,
pub subscription_id: SubscriptionId,
pub cycle_number: i32,
pub status: BillingCycleStatus,
pub period_start: DateTime<Utc>,
pub period_end: DateTime<Utc>,
pub billed_at: Option<DateTime<Utc>>,
pub subtotal: Decimal,
pub discount: Decimal,
pub tax: Decimal,
pub total: Decimal,
pub currency: CurrencyCode,
pub payment_id: Option<String>,
pub order_id: Option<OrderId>,
pub invoice_id: Option<Uuid>,
pub failure_reason: Option<String>,
pub retry_count: i32,
pub next_retry_at: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SubscriptionEvent {
pub id: Uuid,
pub subscription_id: SubscriptionId,
pub event_type: SubscriptionEventType,
pub description: String,
pub data: Option<serde_json::Value>,
pub triggered_by: Option<String>,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct CreateSubscriptionPlan {
pub code: Option<String>,
pub name: String,
pub description: Option<String>,
pub billing_interval: BillingInterval,
pub custom_interval_days: Option<i32>,
pub price: Decimal,
pub setup_fee: Option<Decimal>,
pub currency: Option<CurrencyCode>,
pub trial_days: Option<i32>,
pub trial_requires_payment_method: Option<bool>,
pub min_cycles: Option<i32>,
pub max_cycles: Option<i32>,
pub items: Option<Vec<CreateSubscriptionPlanItem>>,
pub discount_percent: Option<Decimal>,
pub discount_amount: Option<Decimal>,
pub metadata: Option<serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateSubscriptionPlanItem {
pub product_id: ProductId,
pub variant_id: Option<Uuid>,
pub sku: String,
pub name: String,
pub quantity: i32,
pub min_quantity: Option<i32>,
pub max_quantity: Option<i32>,
pub is_required: Option<bool>,
pub unit_price: Option<Decimal>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct UpdateSubscriptionPlan {
pub name: Option<String>,
pub description: Option<String>,
pub status: Option<PlanStatus>,
pub price: Option<Decimal>,
pub setup_fee: Option<Decimal>,
pub trial_days: Option<i32>,
pub trial_requires_payment_method: Option<bool>,
pub min_cycles: Option<i32>,
pub max_cycles: Option<i32>,
pub discount_percent: Option<Decimal>,
pub discount_amount: Option<Decimal>,
pub metadata: Option<serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateSubscription {
pub customer_id: CustomerId,
pub plan_id: Uuid,
pub items: Option<Vec<CreateSubscriptionItem>>,
pub price: Option<Decimal>,
pub payment_method_id: Option<String>,
pub shipping_address: Option<Address>,
pub billing_address: Option<Address>,
pub skip_trial: Option<bool>,
pub start_date: Option<DateTime<Utc>>,
pub coupon_code: Option<String>,
pub metadata: Option<serde_json::Value>,
}
impl Default for CreateSubscription {
fn default() -> Self {
Self {
customer_id: CustomerId::nil(),
plan_id: Uuid::nil(),
items: None,
price: None,
payment_method_id: None,
shipping_address: None,
billing_address: None,
skip_trial: None,
start_date: None,
coupon_code: None,
metadata: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateSubscriptionItem {
pub product_id: ProductId,
pub variant_id: Option<Uuid>,
pub sku: String,
pub name: String,
pub quantity: i32,
pub unit_price: Option<Decimal>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct UpdateSubscription {
pub status: Option<SubscriptionStatus>,
pub price: Option<Decimal>,
pub payment_method_id: Option<String>,
pub shipping_address: Option<Address>,
pub billing_address: Option<Address>,
pub next_billing_date: Option<DateTime<Utc>>,
pub discount_percent: Option<Decimal>,
pub discount_amount: Option<Decimal>,
pub coupon_code: Option<String>,
pub metadata: Option<serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct PauseSubscription {
pub resume_at: Option<DateTime<Utc>>,
pub reason: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct CancelSubscription {
pub immediate: Option<bool>,
pub reason: Option<String>,
pub feedback: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct SkipBillingCycle {
pub reason: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateBillingCycle {
pub subscription_id: SubscriptionId,
pub cycle_number: i32,
pub period_start: DateTime<Utc>,
pub period_end: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChangeSubscriptionPlan {
pub new_plan_id: Uuid,
pub prorate: Option<bool>,
pub immediate: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ModifySubscriptionItems {
pub add: Option<Vec<CreateSubscriptionItem>>,
pub remove: Option<Vec<Uuid>>,
pub update_quantities: Option<Vec<UpdateItemQuantity>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UpdateItemQuantity {
pub item_id: Uuid,
pub quantity: i32,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct SubscriptionPlanFilter {
pub status: Option<PlanStatus>,
pub billing_interval: Option<BillingInterval>,
pub search: Option<String>,
pub limit: Option<u32>,
pub offset: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct SubscriptionFilter {
pub customer_id: Option<CustomerId>,
pub plan_id: Option<Uuid>,
pub status: Option<SubscriptionStatus>,
pub from_date: Option<DateTime<Utc>>,
pub to_date: Option<DateTime<Utc>>,
pub search: Option<String>,
pub limit: Option<u32>,
pub offset: Option<u32>,
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashSet;
use std::str::FromStr;
#[test]
fn test_billing_interval_from_str() {
assert_eq!(BillingInterval::from_str("biweekly").unwrap(), BillingInterval::Biweekly);
assert_eq!(BillingInterval::from_str("semi-annual").unwrap(), BillingInterval::Semiannual);
}
#[test]
fn test_subscription_status_from_str() {
assert_eq!(SubscriptionStatus::from_str("past_due").unwrap(), SubscriptionStatus::PastDue);
assert_eq!(SubscriptionStatus::from_str("pastdue").unwrap(), SubscriptionStatus::PastDue);
assert_eq!(
SubscriptionStatus::from_str("canceled").unwrap(),
SubscriptionStatus::Cancelled
);
}
#[test]
fn test_plan_status_from_str() {
assert_eq!(PlanStatus::from_str("draft").unwrap(), PlanStatus::Draft);
assert_eq!(PlanStatus::from_str("archived").unwrap(), PlanStatus::Archived);
}
#[test]
fn test_billing_cycle_status_from_str() {
assert_eq!(
BillingCycleStatus::from_str("processing").unwrap(),
BillingCycleStatus::Processing
);
assert_eq!(BillingCycleStatus::from_str("voided").unwrap(), BillingCycleStatus::Voided);
}
#[test]
fn test_subscription_event_type_from_str() {
assert_eq!(
SubscriptionEventType::from_str("trial_started").unwrap(),
SubscriptionEventType::TrialStarted
);
assert_eq!(
SubscriptionEventType::from_str("trialstarted").unwrap(),
SubscriptionEventType::TrialStarted
);
assert_eq!(
SubscriptionEventType::from_str("payment_retry_succeeded").unwrap(),
SubscriptionEventType::PaymentRetrySucceeded
);
}
#[test]
fn test_generate_subscription_number_prefix_and_shape() {
let generated = generate_subscription_number();
assert!(generated.starts_with("SUB-"));
assert!(generated.len() > 12);
}
#[test]
fn test_generate_subscription_number_sample_uniqueness() {
let mut generated = HashSet::new();
for _ in 0..1000 {
generated.insert(generate_subscription_number());
}
assert_eq!(generated.len(), 1000);
}
#[test]
fn subscription_status_valid_transitions() {
use SubscriptionStatus::*;
assert!(Pending.can_transition_to(Trial));
assert!(Pending.can_transition_to(Active));
assert!(Pending.can_transition_to(Cancelled));
assert!(Trial.can_transition_to(Active));
assert!(Trial.can_transition_to(Cancelled));
assert!(Trial.can_transition_to(Expired));
assert!(Active.can_transition_to(Paused));
assert!(Active.can_transition_to(PastDue));
assert!(Active.can_transition_to(Cancelled));
assert!(Active.can_transition_to(Expired));
assert!(Paused.can_transition_to(Active));
assert!(Paused.can_transition_to(Cancelled));
assert!(PastDue.can_transition_to(Active));
assert!(PastDue.can_transition_to(Cancelled));
assert!(PastDue.can_transition_to(Expired));
}
#[test]
fn subscription_status_invalid_transitions() {
use SubscriptionStatus::*;
assert!(!Pending.can_transition_to(Paused));
assert!(!Pending.can_transition_to(PastDue));
assert!(!Trial.can_transition_to(Paused));
assert!(!Paused.can_transition_to(Trial));
assert!(!Cancelled.can_transition_to(Active));
assert!(!Expired.can_transition_to(Active));
}
#[test]
fn subscription_status_terminal_states() {
use SubscriptionStatus::*;
assert!(Cancelled.is_terminal());
assert!(Expired.is_terminal());
assert!(!Pending.is_terminal());
assert!(!Trial.is_terminal());
assert!(!Active.is_terminal());
assert!(!Paused.is_terminal());
assert!(!PastDue.is_terminal());
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct BillingCycleFilter {
pub subscription_id: Option<SubscriptionId>,
pub status: Option<BillingCycleStatus>,
pub from_date: Option<DateTime<Utc>>,
pub to_date: Option<DateTime<Utc>>,
pub limit: Option<u32>,
pub offset: Option<u32>,
}
#[must_use]
pub fn generate_subscription_number() -> String {
let timestamp_ms = Utc::now().timestamp_millis();
let id = Uuid::new_v4();
let bytes = id.as_bytes();
let random = u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]);
format!("SUB-{timestamp_ms}-{random:08X}")
}
#[must_use]
pub fn generate_plan_code(name: &str) -> String {
let slug: String = name
.to_uppercase()
.chars()
.filter(|c| c.is_alphanumeric() || *c == ' ')
.take(20)
.collect::<String>()
.trim()
.replace(' ', "-");
let id = Uuid::new_v4();
let bytes = id.as_bytes();
let random = u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]) % 10000;
if slug.is_empty() { format!("PLAN-{random:04}") } else { format!("{slug}-{random:04}") }
}
impl Subscription {
#[must_use]
pub const fn is_active(&self) -> bool {
matches!(self.status, SubscriptionStatus::Active | SubscriptionStatus::Trial)
}
#[must_use]
pub const fn can_pause(&self) -> bool {
matches!(self.status, SubscriptionStatus::Active | SubscriptionStatus::Trial)
}
#[must_use]
pub fn can_resume(&self) -> bool {
self.status == SubscriptionStatus::Paused
}
#[must_use]
pub const fn can_cancel(&self) -> bool {
!matches!(self.status, SubscriptionStatus::Cancelled | SubscriptionStatus::Expired)
}
#[must_use]
pub fn is_in_trial(&self) -> bool {
if self.status != SubscriptionStatus::Trial {
return false;
}
if let Some(trial_ends) = self.trial_ends_at {
return Utc::now() < trial_ends;
}
false
}
#[must_use]
pub fn trial_days_remaining(&self) -> Option<i64> {
if !self.is_in_trial() {
return None;
}
self.trial_ends_at.map(|ends| {
let now = Utc::now();
if ends > now { (ends - now).num_days() } else { 0 }
})
}
#[must_use]
pub fn calculate_total(&self) -> Decimal {
self.items.iter().map(|item| item.line_total).sum()
}
#[must_use]
pub fn next_billing_amount(&self) -> Decimal {
let subtotal = self.calculate_total();
let mut total = subtotal;
if let Some(pct) = self.discount_percent {
total -= subtotal * pct;
}
if let Some(amt) = self.discount_amount {
total -= amt;
}
if total < Decimal::ZERO { Decimal::ZERO } else { total }
}
}
impl SubscriptionItem {
#[must_use]
pub fn calculate_total(quantity: i32, unit_price: Decimal) -> Decimal {
unit_price * Decimal::from(quantity)
}
}
impl BillingCycle {
#[must_use]
pub fn can_refund(&self) -> bool {
self.status == BillingCycleStatus::Paid
}
#[must_use]
pub fn can_retry(&self) -> bool {
self.status == BillingCycleStatus::Failed
}
}
use crate::validation::{Validate, ValidationBuilder};
fn pricing_checks(
builder: ValidationBuilder,
price: Option<Decimal>,
setup_fee: Option<Decimal>,
discount_percent: Option<Decimal>,
discount_amount: Option<Decimal>,
) -> ValidationBuilder {
builder
.check("price", price.is_none_or(|p| p >= Decimal::ZERO), "cannot be negative")
.check("setup_fee", setup_fee.is_none_or(|f| f >= Decimal::ZERO), "cannot be negative")
.check(
"discount_percent",
discount_percent.is_none_or(|p| p >= Decimal::ZERO && p <= Decimal::ONE),
"must be a fraction between 0 and 1",
)
.check(
"discount_amount",
discount_amount.is_none_or(|a| a >= Decimal::ZERO),
"cannot be negative",
)
}
impl Validate for CreateSubscriptionPlan {
fn validate(&self) -> crate::Result<()> {
pricing_checks(
ValidationBuilder::new(),
Some(self.price),
self.setup_fee,
self.discount_percent,
self.discount_amount,
)
.build()
}
}
impl Validate for UpdateSubscriptionPlan {
fn validate(&self) -> crate::Result<()> {
pricing_checks(
ValidationBuilder::new(),
self.price,
self.setup_fee,
self.discount_percent,
self.discount_amount,
)
.build()
}
}
impl Validate for CreateSubscription {
fn validate(&self) -> crate::Result<()> {
pricing_checks(ValidationBuilder::new(), self.price, None, None, None).build()
}
}
impl Validate for UpdateSubscription {
fn validate(&self) -> crate::Result<()> {
pricing_checks(
ValidationBuilder::new(),
self.price,
None,
self.discount_percent,
self.discount_amount,
)
.build()
}
}