use super::plans::Plans;
use super::storage::{BillingStore, StoredSubscription, SubscriptionStatus};
use crate::error::Result;
pub struct SubscriptionManager<S: BillingStore, C: StripeSubscriptionClient> {
store: S,
client: C,
plans: Plans,
}
impl<S: BillingStore, C: StripeSubscriptionClient> SubscriptionManager<S, C> {
#[must_use]
pub fn new(store: S, client: C, plans: Plans) -> Self {
Self {
store,
client,
plans,
}
}
pub async fn get_subscription(&self, billable_id: &str) -> Result<Option<Subscription>> {
let stored = self.store.get_subscription(billable_id).await?;
match stored {
Some(sub) => {
let plan = self.plans.get(&sub.plan_id).cloned();
Ok(Some(Subscription::from_stored(sub, plan)))
}
None => Ok(None),
}
}
pub async fn has_active_subscription(&self, billable_id: &str) -> Result<bool> {
match self.store.get_subscription(billable_id).await? {
Some(sub) => Ok(sub.is_active()),
None => Ok(false),
}
}
pub async fn cancel_subscription(&self, billable_id: &str, immediate: bool) -> Result<()> {
let sub = self
.store
.get_subscription(billable_id)
.await?
.ok_or_else(|| {
crate::error::TidewayError::NotFound("No active subscription".to_string())
})?;
if immediate {
self.client
.cancel_subscription(&sub.stripe_subscription_id)
.await?;
} else {
self.client
.cancel_subscription_at_period_end(&sub.stripe_subscription_id)
.await?;
}
let mut updated = sub.clone();
if immediate {
updated.status = SubscriptionStatus::Canceled;
} else {
updated.cancel_at_period_end = true;
}
self.store.save_subscription(billable_id, &updated).await?;
Ok(())
}
pub async fn resume_subscription(&self, billable_id: &str) -> Result<()> {
let sub = self
.store
.get_subscription(billable_id)
.await?
.ok_or_else(|| {
crate::error::TidewayError::NotFound("No subscription found".to_string())
})?;
if !sub.cancel_at_period_end {
return Err(crate::error::TidewayError::BadRequest(
"Subscription is not scheduled for cancellation".to_string(),
));
}
self.client
.resume_subscription(&sub.stripe_subscription_id)
.await?;
let mut updated = sub;
updated.cancel_at_period_end = false;
self.store.save_subscription(billable_id, &updated).await?;
Ok(())
}
pub async fn extend_trial(
&self,
billable_id: &str,
additional_days: u32,
) -> Result<Subscription> {
if additional_days == 0 {
return Err(crate::error::TidewayError::BadRequest(
"Trial extension must be at least 1 day".to_string(),
));
}
let sub = self
.store
.get_subscription(billable_id)
.await?
.ok_or_else(|| super::error::BillingError::NoSubscription {
billable_id: billable_id.to_string(),
})?;
if sub.status != SubscriptionStatus::Trialing {
return Err(super::error::BillingError::SubscriptionNotTrialing {
billable_id: billable_id.to_string(),
}
.into());
}
let current_trial_end = sub.trial_end.unwrap_or_else(|| {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
});
let additional_seconds = u64::from(additional_days) * 86400;
let new_trial_end = current_trial_end.saturating_add(additional_seconds);
let stripe_data = self
.client
.extend_trial(&sub.stripe_subscription_id, new_trial_end)
.await?;
self.sync_from_stripe(stripe_data).await?;
self.get_subscription(billable_id).await?.ok_or_else(|| {
crate::error::TidewayError::Internal(
"Subscription disappeared after extension".to_string(),
)
})
}
pub async fn is_paused(&self, billable_id: &str) -> Result<bool> {
match self.store.get_subscription(billable_id).await? {
Some(sub) => Ok(sub.status == SubscriptionStatus::Paused),
None => Ok(false),
}
}
pub async fn pause_subscription(&self, billable_id: &str) -> Result<()> {
let sub = self
.store
.get_subscription(billable_id)
.await?
.ok_or_else(|| super::error::BillingError::NoSubscription {
billable_id: billable_id.to_string(),
})?;
if sub.status == SubscriptionStatus::Paused {
return Err(super::error::BillingError::SubscriptionAlreadyPaused {
billable_id: billable_id.to_string(),
}
.into());
}
if !sub.is_active() {
return Err(super::error::BillingError::SubscriptionInactive {
billable_id: billable_id.to_string(),
}
.into());
}
self.client
.pause_subscription(&sub.stripe_subscription_id)
.await?;
let mut updated = sub;
updated.status = SubscriptionStatus::Paused;
self.store.save_subscription(billable_id, &updated).await?;
Ok(())
}
pub async fn resume_paused_subscription(&self, billable_id: &str) -> Result<Subscription> {
let sub = self
.store
.get_subscription(billable_id)
.await?
.ok_or_else(|| super::error::BillingError::NoSubscription {
billable_id: billable_id.to_string(),
})?;
if sub.status != SubscriptionStatus::Paused {
return Err(super::error::BillingError::SubscriptionNotPaused {
billable_id: billable_id.to_string(),
}
.into());
}
let stripe_data = self
.client
.resume_paused_subscription(&sub.stripe_subscription_id)
.await?;
self.sync_from_stripe(stripe_data).await?;
self.get_subscription(billable_id).await?.ok_or_else(|| {
crate::error::TidewayError::Internal(
"Subscription disappeared after resuming".to_string(),
)
})
}
pub async fn sync_from_stripe(
&self,
stripe_subscription: StripeSubscriptionData,
) -> Result<()> {
let billable_id = match self
.store
.get_subscription_by_stripe_id(&stripe_subscription.id)
.await?
{
Some((id, _)) => id,
None => {
stripe_subscription
.metadata
.billable_id
.clone()
.ok_or_else(|| {
crate::error::TidewayError::BadRequest(
"Subscription missing billable_id metadata".to_string(),
)
})?
}
};
let stored = StoredSubscription {
stripe_subscription_id: stripe_subscription.id,
stripe_customer_id: stripe_subscription.customer_id,
plan_id: stripe_subscription.plan_id,
status: SubscriptionStatus::from_stripe(&stripe_subscription.status),
current_period_start: stripe_subscription.current_period_start,
current_period_end: stripe_subscription.current_period_end,
extra_seats: stripe_subscription.extra_seats,
trial_end: stripe_subscription.trial_end,
cancel_at_period_end: stripe_subscription.cancel_at_period_end,
base_item_id: stripe_subscription.base_item_id,
seat_item_id: stripe_subscription.seat_item_id,
updated_at: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0),
};
self.store.save_subscription(&billable_id, &stored).await?;
Ok(())
}
pub async fn delete_subscription(&self, stripe_subscription_id: &str) -> Result<()> {
if let Some((billable_id, _)) = self
.store
.get_subscription_by_stripe_id(stripe_subscription_id)
.await?
{
self.store.delete_subscription(&billable_id).await?;
}
Ok(())
}
pub async fn get_plan(&self, billable_id: &str) -> Result<Option<super::plans::PlanConfig>> {
let sub = self.store.get_subscription(billable_id).await?;
match sub {
Some(s) => Ok(self.plans.get(&s.plan_id).cloned()),
None => Ok(None),
}
}
pub async fn refresh_from_stripe(&self, billable_id: &str) -> Result<Option<Subscription>> {
let stored = match self.store.get_subscription(billable_id).await? {
Some(sub) => sub,
None => return Ok(None),
};
let stripe_data = self
.client
.get_subscription(&stored.stripe_subscription_id)
.await?;
self.sync_from_stripe(stripe_data).await?;
self.get_subscription(billable_id).await
}
pub async fn reconcile(
&self,
billable_id: &str,
update_local: bool,
) -> Result<ReconcileResult> {
let stored = match self.store.get_subscription(billable_id).await? {
Some(sub) => sub,
None => return Ok(ReconcileResult::NoLocalSubscription),
};
let stripe_data = match self
.client
.get_subscription(&stored.stripe_subscription_id)
.await
{
Ok(data) => data,
Err(_) => return Ok(ReconcileResult::NotFoundInStripe),
};
let status_matches = stored.status == SubscriptionStatus::from_stripe(&stripe_data.status);
let seats_match = stored.extra_seats == stripe_data.extra_seats;
let plan_matches = stored.plan_id == stripe_data.plan_id;
let period_matches = stored.current_period_end == stripe_data.current_period_end;
let cancel_matches = stored.cancel_at_period_end == stripe_data.cancel_at_period_end;
if status_matches && seats_match && plan_matches && period_matches && cancel_matches {
return Ok(ReconcileResult::InSync);
}
let mut differences = Vec::new();
if !status_matches {
differences.push(ReconcileDifference::Status {
local: stored.status.as_str().to_string(),
remote: stripe_data.status.clone(),
});
}
if !seats_match {
differences.push(ReconcileDifference::Seats {
local: stored.extra_seats,
remote: stripe_data.extra_seats,
});
}
if !plan_matches {
differences.push(ReconcileDifference::Plan {
local: stored.plan_id.clone(),
remote: stripe_data.plan_id.clone(),
});
}
if !period_matches {
differences.push(ReconcileDifference::PeriodEnd {
local: stored.current_period_end,
remote: stripe_data.current_period_end,
});
}
if !cancel_matches {
differences.push(ReconcileDifference::CancelAtPeriodEnd {
local: stored.cancel_at_period_end,
remote: stripe_data.cancel_at_period_end,
});
}
if update_local {
self.sync_from_stripe(stripe_data).await?;
}
Ok(ReconcileResult::Diverged {
differences,
updated_local: update_local,
})
}
}
#[derive(Debug, Clone, PartialEq)]
#[must_use]
pub enum ReconcileResult {
NoLocalSubscription,
NotFoundInStripe,
InSync,
Diverged {
differences: Vec<ReconcileDifference>,
updated_local: bool,
},
}
#[derive(Debug, Clone, PartialEq)]
pub enum ReconcileDifference {
Status { local: String, remote: String },
Seats { local: u32, remote: u32 },
Plan { local: String, remote: String },
PeriodEnd { local: u64, remote: u64 },
CancelAtPeriodEnd { local: bool, remote: bool },
}
#[derive(Debug, Clone)]
#[must_use]
pub struct Subscription {
pub id: String,
pub customer_id: String,
pub plan_id: String,
pub status: SubscriptionStatus,
pub current_period_start: u64,
pub current_period_end: u64,
pub extra_seats: u32,
pub trial_end: Option<u64>,
pub cancel_at_period_end: bool,
pub plan: Option<super::plans::PlanConfig>,
}
impl Subscription {
pub fn from_stored(stored: StoredSubscription, plan: Option<super::plans::PlanConfig>) -> Self {
Self {
id: stored.stripe_subscription_id,
customer_id: stored.stripe_customer_id,
plan_id: stored.plan_id,
status: stored.status,
current_period_start: stored.current_period_start,
current_period_end: stored.current_period_end,
extra_seats: stored.extra_seats,
trial_end: stored.trial_end,
cancel_at_period_end: stored.cancel_at_period_end,
plan,
}
}
#[must_use]
pub fn is_active(&self) -> bool {
matches!(
self.status,
SubscriptionStatus::Active | SubscriptionStatus::Trialing
)
}
#[must_use]
pub fn is_trialing(&self) -> bool {
self.status == SubscriptionStatus::Trialing
}
#[must_use]
pub fn total_seats(&self) -> u32 {
let included = self.plan.as_ref().map(|p| p.included_seats).unwrap_or(0);
included + self.extra_seats
}
#[must_use]
pub fn has_feature(&self, feature: &str) -> bool {
self.plan
.as_ref()
.map(|p| p.has_feature(feature))
.unwrap_or(false)
}
#[must_use]
pub fn trial_days_remaining(&self) -> Option<u32> {
self.trial_end.and_then(|end| {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
if end > now {
Some(((end - now) / 86400) as u32)
} else {
None
}
})
}
#[must_use]
pub fn days_until_renewal(&self) -> u32 {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
if self.current_period_end > now {
((self.current_period_end - now) / 86400) as u32
} else {
0
}
}
}
#[derive(Debug, Clone)]
pub struct StripeSubscriptionData {
pub id: String,
pub customer_id: String,
pub plan_id: String,
pub status: String,
pub current_period_start: u64,
pub current_period_end: u64,
pub extra_seats: u32,
pub trial_end: Option<u64>,
pub cancel_at_period_end: bool,
pub base_item_id: Option<String>,
pub seat_item_id: Option<String>,
pub metadata: SubscriptionMetadata,
}
#[derive(Debug, Clone, Default)]
pub struct SubscriptionMetadata {
pub billable_id: Option<String>,
pub billable_type: Option<String>,
}
#[allow(async_fn_in_trait)]
pub trait StripeSubscriptionClient: Send + Sync {
async fn cancel_subscription(&self, subscription_id: &str) -> Result<()>;
async fn cancel_subscription_at_period_end(&self, subscription_id: &str) -> Result<()>;
async fn resume_subscription(&self, subscription_id: &str) -> Result<()>;
async fn get_subscription(&self, subscription_id: &str) -> Result<StripeSubscriptionData>;
async fn update_subscription(
&self,
subscription_id: &str,
update: UpdateSubscriptionRequest,
) -> Result<StripeSubscriptionData>;
async fn extend_trial(
&self,
subscription_id: &str,
new_trial_end: u64,
) -> Result<StripeSubscriptionData>;
async fn pause_subscription(&self, subscription_id: &str) -> Result<()>;
async fn resume_paused_subscription(
&self,
subscription_id: &str,
) -> Result<StripeSubscriptionData>;
}
#[derive(Debug, Clone, Default)]
pub struct UpdateSubscriptionRequest {
pub price_id: Option<String>,
pub seat_quantity: Option<u32>,
pub proration_behavior: Option<ProrationBehavior>,
pub base_item_id: Option<String>,
pub seat_item_id: Option<String>,
}
impl UpdateSubscriptionRequest {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn price_id(mut self, price_id: impl Into<String>) -> Self {
self.price_id = Some(price_id.into());
self
}
#[must_use]
pub fn seat_quantity(mut self, quantity: u32) -> Self {
self.seat_quantity = Some(quantity);
self
}
#[must_use]
pub fn proration_behavior(mut self, behavior: ProrationBehavior) -> Self {
self.proration_behavior = Some(behavior);
self
}
#[must_use]
pub fn base_item_id(mut self, id: impl Into<String>) -> Self {
self.base_item_id = Some(id.into());
self
}
#[must_use]
pub fn seat_item_id(mut self, id: impl Into<String>) -> Self {
self.seat_item_id = Some(id.into());
self
}
}
#[derive(Debug, Clone, Copy, Default)]
pub enum ProrationBehavior {
#[default]
CreateProrations,
None,
AlwaysInvoice,
}
impl ProrationBehavior {
#[must_use]
pub fn as_str(&self) -> &'static str {
match self {
Self::CreateProrations => "create_prorations",
Self::None => "none",
Self::AlwaysInvoice => "always_invoice",
}
}
}
#[cfg(any(test, feature = "test-billing"))]
pub mod test {
use super::*;
use std::collections::HashMap;
use std::sync::RwLock;
#[derive(Default, Clone)]
pub struct MockStripeSubscriptionClient {
subscriptions: std::sync::Arc<RwLock<HashMap<String, MockSubscription>>>,
}
#[derive(Clone)]
struct MockSubscription {
data: StripeSubscriptionData,
}
impl MockStripeSubscriptionClient {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn add_subscription(&self, data: StripeSubscriptionData) {
self.subscriptions
.write()
.unwrap()
.insert(data.id.clone(), MockSubscription { data });
}
}
impl StripeSubscriptionClient for MockStripeSubscriptionClient {
async fn cancel_subscription(&self, subscription_id: &str) -> Result<()> {
let mut subs = self.subscriptions.write().unwrap();
if let Some(sub) = subs.get_mut(subscription_id) {
sub.data.status = "canceled".to_string();
Ok(())
} else {
Err(crate::error::TidewayError::NotFound(format!(
"Subscription not found: {}",
subscription_id
)))
}
}
async fn cancel_subscription_at_period_end(&self, subscription_id: &str) -> Result<()> {
let mut subs = self.subscriptions.write().unwrap();
if let Some(sub) = subs.get_mut(subscription_id) {
sub.data.cancel_at_period_end = true;
Ok(())
} else {
Err(crate::error::TidewayError::NotFound(format!(
"Subscription not found: {}",
subscription_id
)))
}
}
async fn resume_subscription(&self, subscription_id: &str) -> Result<()> {
let mut subs = self.subscriptions.write().unwrap();
if let Some(sub) = subs.get_mut(subscription_id) {
sub.data.cancel_at_period_end = false;
Ok(())
} else {
Err(crate::error::TidewayError::NotFound(format!(
"Subscription not found: {}",
subscription_id
)))
}
}
async fn get_subscription(&self, subscription_id: &str) -> Result<StripeSubscriptionData> {
let subs = self.subscriptions.read().unwrap();
subs.get(subscription_id)
.map(|s| s.data.clone())
.ok_or_else(|| {
crate::error::TidewayError::NotFound(format!(
"Subscription not found: {}",
subscription_id
))
})
}
async fn update_subscription(
&self,
subscription_id: &str,
update: UpdateSubscriptionRequest,
) -> Result<StripeSubscriptionData> {
let mut subs = self.subscriptions.write().unwrap();
if let Some(sub) = subs.get_mut(subscription_id) {
if let Some(seat_qty) = update.seat_quantity {
sub.data.extra_seats = seat_qty;
}
Ok(sub.data.clone())
} else {
Err(crate::error::TidewayError::NotFound(format!(
"Subscription not found: {}",
subscription_id
)))
}
}
async fn extend_trial(
&self,
subscription_id: &str,
new_trial_end: u64,
) -> Result<StripeSubscriptionData> {
let mut subs = self.subscriptions.write().unwrap();
if let Some(sub) = subs.get_mut(subscription_id) {
sub.data.trial_end = Some(new_trial_end);
Ok(sub.data.clone())
} else {
Err(crate::error::TidewayError::NotFound(format!(
"Subscription not found: {}",
subscription_id
)))
}
}
async fn pause_subscription(&self, subscription_id: &str) -> Result<()> {
let mut subs = self.subscriptions.write().unwrap();
if let Some(sub) = subs.get_mut(subscription_id) {
sub.data.status = "paused".to_string();
Ok(())
} else {
Err(crate::error::TidewayError::NotFound(format!(
"Subscription not found: {}",
subscription_id
)))
}
}
async fn resume_paused_subscription(
&self,
subscription_id: &str,
) -> Result<StripeSubscriptionData> {
let mut subs = self.subscriptions.write().unwrap();
if let Some(sub) = subs.get_mut(subscription_id) {
sub.data.status = "active".to_string();
Ok(sub.data.clone())
} else {
Err(crate::error::TidewayError::NotFound(format!(
"Subscription not found: {}",
subscription_id
)))
}
}
}
}
#[cfg(test)]
mod tests {
use super::test::MockStripeSubscriptionClient;
use super::*;
use crate::billing::plans::Plans;
use crate::billing::storage::test::InMemoryBillingStore;
fn create_test_plans() -> Plans {
Plans::builder()
.plan("starter")
.stripe_price("price_starter")
.included_seats(3)
.features(["reports"])
.done()
.unwrap()
.plan("pro")
.stripe_price("price_pro")
.included_seats(5)
.features(["reports", "api"])
.done()
.unwrap()
.build()
.unwrap()
}
fn create_test_subscription_data(billable_id: &str) -> StripeSubscriptionData {
StripeSubscriptionData {
id: "sub_123".to_string(),
customer_id: "cus_123".to_string(),
plan_id: "starter".to_string(),
status: "active".to_string(),
current_period_start: 1700000000,
current_period_end: 1702592000,
extra_seats: 2,
trial_end: None,
cancel_at_period_end: false,
base_item_id: Some("si_base".to_string()),
seat_item_id: Some("si_seat".to_string()),
metadata: SubscriptionMetadata {
billable_id: Some(billable_id.to_string()),
billable_type: Some("org".to_string()),
},
}
}
#[tokio::test]
async fn test_sync_from_stripe() {
let store = InMemoryBillingStore::new();
let client = MockStripeSubscriptionClient::new();
let plans = create_test_plans();
let manager = SubscriptionManager::new(store, client, plans);
let stripe_data = create_test_subscription_data("org_123");
manager.sync_from_stripe(stripe_data).await.unwrap();
let sub = manager.get_subscription("org_123").await.unwrap().unwrap();
assert_eq!(sub.plan_id, "starter");
assert_eq!(sub.extra_seats, 2);
assert!(sub.is_active());
}
#[tokio::test]
async fn test_get_subscription_with_plan() {
let store = InMemoryBillingStore::new();
let client = MockStripeSubscriptionClient::new();
let plans = create_test_plans();
let manager = SubscriptionManager::new(store, client, plans);
let stripe_data = create_test_subscription_data("org_123");
manager.sync_from_stripe(stripe_data).await.unwrap();
let sub = manager.get_subscription("org_123").await.unwrap().unwrap();
assert!(sub.plan.is_some());
assert_eq!(sub.total_seats(), 5); assert!(sub.has_feature("reports"));
assert!(!sub.has_feature("api"));
}
#[tokio::test]
async fn test_cancel_subscription() {
let store = InMemoryBillingStore::new();
let client = MockStripeSubscriptionClient::new();
let plans = create_test_plans();
let stripe_data = create_test_subscription_data("org_123");
client.add_subscription(stripe_data.clone());
let manager = SubscriptionManager::new(store, client, plans);
manager.sync_from_stripe(stripe_data).await.unwrap();
manager.cancel_subscription("org_123", false).await.unwrap();
let sub = manager.get_subscription("org_123").await.unwrap().unwrap();
assert!(sub.cancel_at_period_end);
assert!(sub.is_active()); }
#[tokio::test]
async fn test_cancel_subscription_immediate() {
let store = InMemoryBillingStore::new();
let client = MockStripeSubscriptionClient::new();
let plans = create_test_plans();
let stripe_data = create_test_subscription_data("org_123");
client.add_subscription(stripe_data.clone());
let manager = SubscriptionManager::new(store, client, plans);
manager.sync_from_stripe(stripe_data).await.unwrap();
manager.cancel_subscription("org_123", true).await.unwrap();
let sub = manager.get_subscription("org_123").await.unwrap().unwrap();
assert_eq!(sub.status, SubscriptionStatus::Canceled);
}
#[tokio::test]
async fn test_resume_subscription() {
let store = InMemoryBillingStore::new();
let client = MockStripeSubscriptionClient::new();
let plans = create_test_plans();
let stripe_data = create_test_subscription_data("org_123");
client.add_subscription(stripe_data.clone());
let manager = SubscriptionManager::new(store, client, plans);
manager.sync_from_stripe(stripe_data).await.unwrap();
manager.cancel_subscription("org_123", false).await.unwrap();
manager.resume_subscription("org_123").await.unwrap();
let sub = manager.get_subscription("org_123").await.unwrap().unwrap();
assert!(!sub.cancel_at_period_end);
}
#[tokio::test]
async fn test_has_active_subscription() {
let store = InMemoryBillingStore::new();
let client = MockStripeSubscriptionClient::new();
let plans = create_test_plans();
let manager = SubscriptionManager::new(store, client, plans);
assert!(!manager.has_active_subscription("org_123").await.unwrap());
let stripe_data = create_test_subscription_data("org_123");
manager.sync_from_stripe(stripe_data).await.unwrap();
assert!(manager.has_active_subscription("org_123").await.unwrap());
}
#[tokio::test]
async fn test_delete_subscription() {
let store = InMemoryBillingStore::new();
let client = MockStripeSubscriptionClient::new();
let plans = create_test_plans();
let manager = SubscriptionManager::new(store, client, plans);
let stripe_data = create_test_subscription_data("org_123");
manager.sync_from_stripe(stripe_data).await.unwrap();
manager.delete_subscription("sub_123").await.unwrap();
assert!(manager.get_subscription("org_123").await.unwrap().is_none());
}
#[tokio::test]
async fn test_refresh_from_stripe() {
let store = InMemoryBillingStore::new();
let client = MockStripeSubscriptionClient::new();
let plans = create_test_plans();
let stripe_data = create_test_subscription_data("org_123");
client.add_subscription(stripe_data.clone());
let manager = SubscriptionManager::new(store, client.clone(), plans);
manager.sync_from_stripe(stripe_data).await.unwrap();
let mut updated_stripe = create_test_subscription_data("org_123");
updated_stripe.extra_seats = 10;
updated_stripe.status = "past_due".to_string();
client.add_subscription(updated_stripe);
let sub = manager
.refresh_from_stripe("org_123")
.await
.unwrap()
.unwrap();
assert_eq!(sub.extra_seats, 10);
assert_eq!(sub.status, SubscriptionStatus::PastDue);
}
#[tokio::test]
async fn test_reconcile_in_sync() {
let store = InMemoryBillingStore::new();
let client = MockStripeSubscriptionClient::new();
let plans = create_test_plans();
let stripe_data = create_test_subscription_data("org_123");
client.add_subscription(stripe_data.clone());
let manager = SubscriptionManager::new(store, client, plans);
manager.sync_from_stripe(stripe_data).await.unwrap();
let result = manager.reconcile("org_123", false).await.unwrap();
assert_eq!(result, ReconcileResult::InSync);
}
#[tokio::test]
async fn test_reconcile_diverged() {
let store = InMemoryBillingStore::new();
let client = MockStripeSubscriptionClient::new();
let plans = create_test_plans();
let stripe_data = create_test_subscription_data("org_123");
client.add_subscription(stripe_data.clone());
let manager = SubscriptionManager::new(store, client.clone(), plans);
manager.sync_from_stripe(stripe_data).await.unwrap();
let mut updated_stripe = create_test_subscription_data("org_123");
updated_stripe.extra_seats = 5; updated_stripe.cancel_at_period_end = true; client.add_subscription(updated_stripe);
let result = manager.reconcile("org_123", false).await.unwrap();
match result {
ReconcileResult::Diverged {
differences,
updated_local,
} => {
assert!(!updated_local);
assert!(differences.iter().any(|d| matches!(
d,
ReconcileDifference::Seats {
local: 2,
remote: 5
}
)));
assert!(differences.iter().any(|d| matches!(
d,
ReconcileDifference::CancelAtPeriodEnd {
local: false,
remote: true
}
)));
}
_ => panic!("Expected Diverged result"),
}
let sub = manager.get_subscription("org_123").await.unwrap().unwrap();
assert_eq!(sub.extra_seats, 2);
}
#[tokio::test]
async fn test_reconcile_with_update() {
let store = InMemoryBillingStore::new();
let client = MockStripeSubscriptionClient::new();
let plans = create_test_plans();
let stripe_data = create_test_subscription_data("org_123");
client.add_subscription(stripe_data.clone());
let manager = SubscriptionManager::new(store, client.clone(), plans);
manager.sync_from_stripe(stripe_data).await.unwrap();
let mut updated_stripe = create_test_subscription_data("org_123");
updated_stripe.extra_seats = 8;
client.add_subscription(updated_stripe);
let result = manager.reconcile("org_123", true).await.unwrap();
match result {
ReconcileResult::Diverged { updated_local, .. } => {
assert!(updated_local);
}
_ => panic!("Expected Diverged result"),
}
let sub = manager.get_subscription("org_123").await.unwrap().unwrap();
assert_eq!(sub.extra_seats, 8);
}
#[tokio::test]
async fn test_reconcile_no_local_subscription() {
let store = InMemoryBillingStore::new();
let client = MockStripeSubscriptionClient::new();
let plans = create_test_plans();
let manager = SubscriptionManager::new(store, client, plans);
let result = manager.reconcile("nonexistent", false).await.unwrap();
assert_eq!(result, ReconcileResult::NoLocalSubscription);
}
#[tokio::test]
async fn test_reconcile_not_found_in_stripe() {
let store = InMemoryBillingStore::new();
let client = MockStripeSubscriptionClient::new();
let plans = create_test_plans();
let stripe_data = create_test_subscription_data("org_123");
let manager = SubscriptionManager::new(store, client, plans);
manager.sync_from_stripe(stripe_data).await.unwrap();
let result = manager.reconcile("org_123", false).await.unwrap();
assert_eq!(result, ReconcileResult::NotFoundInStripe);
}
#[tokio::test]
async fn test_extend_trial() {
let store = InMemoryBillingStore::new();
let client = MockStripeSubscriptionClient::new();
let plans = create_test_plans();
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
let mut stripe_data = create_test_subscription_data("org_123");
stripe_data.status = "trialing".to_string();
stripe_data.trial_end = Some(now + 7 * 86400); client.add_subscription(stripe_data.clone());
let manager = SubscriptionManager::new(store, client, plans);
manager.sync_from_stripe(stripe_data).await.unwrap();
let sub = manager.extend_trial("org_123", 14).await.unwrap();
assert!(sub.trial_end.is_some());
let new_trial_end = sub.trial_end.unwrap();
assert!(new_trial_end > now + 20 * 86400);
}
#[tokio::test]
async fn test_extend_trial_not_trialing() {
let store = InMemoryBillingStore::new();
let client = MockStripeSubscriptionClient::new();
let plans = create_test_plans();
let stripe_data = create_test_subscription_data("org_123");
client.add_subscription(stripe_data.clone());
let manager = SubscriptionManager::new(store, client, plans);
manager.sync_from_stripe(stripe_data).await.unwrap();
let result = manager.extend_trial("org_123", 14).await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_extend_trial_zero_days() {
let store = InMemoryBillingStore::new();
let client = MockStripeSubscriptionClient::new();
let plans = create_test_plans();
let manager = SubscriptionManager::new(store, client, plans);
let result = manager.extend_trial("org_123", 0).await;
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("at least 1 day"));
}
#[tokio::test]
async fn test_pause_subscription() {
let store = InMemoryBillingStore::new();
let client = MockStripeSubscriptionClient::new();
let plans = create_test_plans();
let stripe_data = create_test_subscription_data("org_123");
client.add_subscription(stripe_data.clone());
let manager = SubscriptionManager::new(store, client, plans);
manager.sync_from_stripe(stripe_data).await.unwrap();
manager.pause_subscription("org_123").await.unwrap();
assert!(manager.is_paused("org_123").await.unwrap());
}
#[tokio::test]
async fn test_pause_already_paused() {
let store = InMemoryBillingStore::new();
let client = MockStripeSubscriptionClient::new();
let plans = create_test_plans();
let mut stripe_data = create_test_subscription_data("org_123");
stripe_data.status = "paused".to_string();
client.add_subscription(stripe_data.clone());
let manager = SubscriptionManager::new(store, client, plans);
manager.sync_from_stripe(stripe_data).await.unwrap();
let result = manager.pause_subscription("org_123").await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_resume_paused_subscription() {
let store = InMemoryBillingStore::new();
let client = MockStripeSubscriptionClient::new();
let plans = create_test_plans();
let mut stripe_data = create_test_subscription_data("org_123");
stripe_data.status = "paused".to_string();
client.add_subscription(stripe_data.clone());
let manager = SubscriptionManager::new(store, client, plans);
manager.sync_from_stripe(stripe_data).await.unwrap();
let sub = manager.resume_paused_subscription("org_123").await.unwrap();
assert!(sub.is_active());
assert!(!manager.is_paused("org_123").await.unwrap());
}
#[tokio::test]
async fn test_resume_not_paused() {
let store = InMemoryBillingStore::new();
let client = MockStripeSubscriptionClient::new();
let plans = create_test_plans();
let stripe_data = create_test_subscription_data("org_123");
client.add_subscription(stripe_data.clone());
let manager = SubscriptionManager::new(store, client, plans);
manager.sync_from_stripe(stripe_data).await.unwrap();
let result = manager.resume_paused_subscription("org_123").await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_is_paused_no_subscription() {
let store = InMemoryBillingStore::new();
let client = MockStripeSubscriptionClient::new();
let plans = create_test_plans();
let manager = SubscriptionManager::new(store, client, plans);
assert!(!manager.is_paused("nonexistent").await.unwrap());
}
}