diff --git a/contracts/common_types/src/types.rs b/contracts/common_types/src/types.rs index 2fcd28c..56d9a60 100644 --- a/contracts/common_types/src/types.rs +++ b/contracts/common_types/src/types.rs @@ -175,6 +175,7 @@ pub enum UserRole { pub enum MembershipStatus { /// Active membership Active, + Paused, /// Expired membership Expired, /// Revoked membership @@ -507,11 +508,13 @@ mod tests { #[test] fn test_membership_status_variants() { let active = MembershipStatus::Active; + let paused = MembershipStatus::Paused; let expired = MembershipStatus::Expired; let revoked = MembershipStatus::Revoked; let inactive = MembershipStatus::Inactive; assert_eq!(active, MembershipStatus::Active); + assert_eq!(paused, MembershipStatus::Paused); assert_eq!(expired, MembershipStatus::Expired); assert_eq!(revoked, MembershipStatus::Revoked); assert_eq!(inactive, MembershipStatus::Inactive); diff --git a/contracts/manage_hub/src/errors.rs b/contracts/manage_hub/src/errors.rs index edce8ba..04398e5 100644 --- a/contracts/manage_hub/src/errors.rs +++ b/contracts/manage_hub/src/errors.rs @@ -25,26 +25,28 @@ pub enum Error { MetadataTextValueTooLong = 20, MetadataValidationFailed = 21, InvalidMetadataVersion = 22, - // Tier management errors (30-50) - TierNotFound = 30, - TierAlreadyExists = 31, - TierNotActive = 32, - InvalidTierPrice = 33, - InvalidTierLevel = 34, - TierHasActiveSubscriptions = 35, - InvalidTierChange = 36, - TierChangeNotFound = 37, - TierChangeAlreadyProcessed = 38, - CannotDowngradeToHigherTier = 39, - CannotUpgradeToLowerTier = 40, - PromoCodeInvalid = 41, - PromoCodeExpired = 42, - PromoCodeMaxRedemptions = 43, - PromotionNotFound = 44, - PromotionAlreadyExists = 45, - InvalidDiscountPercent = 46, - InvalidPromoDateRange = 47, - FeatureNotAvailable = 48, - UserLimitExceeded = 49, - StorageLimitExceeded = 50, + // Pause/Resume related errors + InvalidPauseConfig = 23, + SubscriptionPaused = 24, + SubscriptionNotActive = 25, + PauseCountExceeded = 26, + PauseTooEarly = 27, + SubscriptionNotPaused = 28, + // Tier and feature related errors + TierNotFound = 29, + FeatureNotAvailable = 30, + // Tier change related errors + TierChangeAlreadyProcessed = 31, + InvalidDiscountPercent = 32, + InvalidPromoDateRange = 33, + PromotionAlreadyExists = 34, + PromotionNotFound = 35, + PromoCodeExpired = 36, + PromoCodeMaxRedemptions = 37, + PromoCodeInvalid = 38, + // Tier management errors + InvalidTierPrice = 39, + TierAlreadyExists = 40, + TierNotActive = 41, + TierChangeNotFound = 42, } diff --git a/contracts/manage_hub/src/lib.rs b/contracts/manage_hub/src/lib.rs index b6629f0..0036bbe 100644 --- a/contracts/manage_hub/src/lib.rs +++ b/contracts/manage_hub/src/lib.rs @@ -13,9 +13,9 @@ use errors::Error; use membership_token::{MembershipToken, MembershipTokenContract}; use subscription::SubscriptionContract; use types::{ - AttendanceAction, BillingCycle, CreatePromotionParams, CreateTierParams, Subscription, - SubscriptionTier, TierAnalytics, TierFeature, TierPromotion, UpdateTierParams, - UserSubscriptionInfo, + AttendanceAction, BillingCycle, CreatePromotionParams, CreateTierParams, PauseConfig, + PauseHistoryEntry, PauseStats, Subscription, SubscriptionTier, TierAnalytics, TierFeature, + TierPromotion, UpdateTierParams, UserSubscriptionInfo, }; #[contract] @@ -98,6 +98,43 @@ impl Contract { SubscriptionContract::cancel_subscription(env, id) } + pub fn pause_subscription(env: Env, id: String, reason: Option) -> Result<(), Error> { + SubscriptionContract::pause_subscription(env, id, reason) + } + + pub fn resume_subscription(env: Env, id: String) -> Result<(), Error> { + SubscriptionContract::resume_subscription(env, id) + } + + pub fn pause_subscription_admin( + env: Env, + id: String, + admin: Address, + reason: Option, + ) -> Result<(), Error> { + SubscriptionContract::pause_subscription_admin(env, id, admin, reason) + } + + pub fn resume_subscription_admin(env: Env, id: String, admin: Address) -> Result<(), Error> { + SubscriptionContract::resume_subscription_admin(env, id, admin) + } + + pub fn set_pause_config(env: Env, admin: Address, config: PauseConfig) -> Result<(), Error> { + SubscriptionContract::set_pause_config(env, admin, config) + } + + pub fn get_pause_config(env: Env) -> PauseConfig { + SubscriptionContract::get_pause_config(env) + } + + pub fn get_pause_history(env: Env, id: String) -> Result, Error> { + SubscriptionContract::get_pause_history(env, id) + } + + pub fn get_pause_stats(env: Env, id: String) -> Result { + SubscriptionContract::get_pause_stats(env, id) + } + pub fn set_usdc_contract(env: Env, admin: Address, usdc_address: Address) -> Result<(), Error> { SubscriptionContract::set_usdc_contract(env, admin, usdc_address) } diff --git a/contracts/manage_hub/src/subscription.rs b/contracts/manage_hub/src/subscription.rs index 151e096..98a5419 100644 --- a/contracts/manage_hub/src/subscription.rs +++ b/contracts/manage_hub/src/subscription.rs @@ -5,16 +5,19 @@ use soroban_sdk::{contracttype, symbol_short, Address, BytesN, Env, Map, String, use crate::attendance_log::AttendanceLogModule; use crate::errors::Error; +use crate::membership_token::DataKey as MembershipTokenDataKey; use crate::types::{ AttendanceAction, BillingCycle, CreatePromotionParams, CreateTierParams, MembershipStatus, - Subscription, SubscriptionTier, TierAnalytics, TierChangeRequest, TierChangeStatus, - TierChangeType, TierFeature, TierLevel, TierPromotion, UpdateTierParams, UserSubscriptionInfo, + PauseAction, PauseConfig, PauseHistoryEntry, PauseStats, Subscription, SubscriptionTier, + TierAnalytics, TierChangeRequest, TierChangeStatus, TierChangeType, TierFeature, TierLevel, + TierPromotion, UpdateTierParams, UserSubscriptionInfo, }; #[contracttype] pub enum SubscriptionDataKey { Subscription(String), UsdcContract, + PauseConfig, // Tier storage keys Tier(String), TierList, @@ -29,6 +32,55 @@ pub enum SubscriptionDataKey { pub struct SubscriptionContract; impl SubscriptionContract { + fn require_admin(env: &Env, caller: &Address) -> Result<(), Error> { + let admin: Address = env + .storage() + .instance() + .get(&MembershipTokenDataKey::Admin) + .ok_or(Error::AdminNotSet)?; + + if caller != &admin { + return Err(Error::Unauthorized); + } + + caller.require_auth(); + Ok(()) + } + + fn get_pause_config_or_default(env: &Env) -> PauseConfig { + env.storage() + .instance() + .get(&SubscriptionDataKey::PauseConfig) + .unwrap_or(PauseConfig { + max_pause_duration: 2_592_000, + max_pause_count: 3, + min_active_time: 86_400, + }) + } + + fn validate_pause_config(config: &PauseConfig) -> Result<(), Error> { + if config.max_pause_duration == 0 { + return Err(Error::InvalidPauseConfig); + } + if config.max_pause_count == 0 { + return Err(Error::InvalidPauseConfig); + } + Ok(()) + } + + pub fn set_pause_config(env: Env, admin: Address, config: PauseConfig) -> Result<(), Error> { + Self::require_admin(&env, &admin)?; + Self::validate_pause_config(&config)?; + env.storage() + .instance() + .set(&SubscriptionDataKey::PauseConfig, &config); + Ok(()) + } + + pub fn get_pause_config(env: Env) -> PauseConfig { + Self::get_pause_config_or_default(&env) + } + fn validate_payment( env: &Env, payment_token: &Address, @@ -57,6 +109,7 @@ impl SubscriptionContract { Ok(true) } + #[allow(deprecated)] /// Creates a subscription without tier (legacy support). /// For new subscriptions, prefer `create_subscription_with_tier`. pub fn create_subscription( @@ -102,6 +155,11 @@ impl SubscriptionContract { status: MembershipStatus::Active, created_at: current_time, expires_at, + paused_at: None, + last_resumed_at: current_time, + pause_count: 0, + total_paused_duration: 0, + pause_history: Vec::new(&env), tier_id: String::from_str(&env, ""), billing_cycle: BillingCycle::Monthly, }; @@ -128,6 +186,226 @@ impl SubscriptionContract { Ok(()) } + pub fn pause_subscription(env: Env, id: String, reason: Option) -> Result<(), Error> { + let key = SubscriptionDataKey::Subscription(id.clone()); + let subscription: Subscription = env + .storage() + .persistent() + .get(&key) + .ok_or(Error::SubscriptionNotFound)?; + + subscription.user.require_auth(); + let actor = subscription.user.clone(); + Self::pause_subscription_internal(env, id, subscription, actor, false, reason) + } + + pub fn pause_subscription_admin( + env: Env, + id: String, + admin: Address, + reason: Option, + ) -> Result<(), Error> { + Self::require_admin(&env, &admin)?; + + let key = SubscriptionDataKey::Subscription(id.clone()); + let subscription: Subscription = env + .storage() + .persistent() + .get(&key) + .ok_or(Error::SubscriptionNotFound)?; + + Self::pause_subscription_internal(env, id, subscription, admin, true, reason) + } + + #[allow(deprecated)] + fn pause_subscription_internal( + env: Env, + id: String, + mut subscription: Subscription, + actor: Address, + is_admin: bool, + reason: Option, + ) -> Result<(), Error> { + let current_time = env.ledger().timestamp(); + + if subscription.status == MembershipStatus::Paused { + return Err(Error::SubscriptionPaused); + } + if subscription.status != MembershipStatus::Active { + return Err(Error::SubscriptionNotActive); + } + if current_time >= subscription.expires_at { + return Err(Error::SubscriptionNotActive); + } + + let config = Self::get_pause_config_or_default(&env); + if !is_admin { + if subscription.pause_count >= config.max_pause_count { + return Err(Error::PauseCountExceeded); + } + + let since_last_resume = current_time.saturating_sub(subscription.last_resumed_at); + if since_last_resume < config.min_active_time { + return Err(Error::PauseTooEarly); + } + } + + subscription.status = MembershipStatus::Paused; + subscription.paused_at = Some(current_time); + subscription.pause_count = subscription.pause_count.saturating_add(1); + + let entry = PauseHistoryEntry { + action: PauseAction::Pause, + timestamp: current_time, + actor: actor.clone(), + is_admin, + reason: reason.clone(), + paused_duration: None, + applied_extension: None, + }; + subscription.pause_history.push_back(entry.clone()); + + let key = SubscriptionDataKey::Subscription(id.clone()); + env.storage().persistent().set(&key, &subscription); + env.storage().persistent().extend_ttl(&key, 100, 1000); + + env.events().publish( + ( + symbol_short!("subscr"), + id.clone(), + subscription.user.clone(), + ), + entry, + ); + + Self::log_subscription_event( + &env, + &subscription.user, + String::from_str(&env, "subscription_paused"), + &id, + subscription.amount, + )?; + + Ok(()) + } + + pub fn resume_subscription(env: Env, id: String) -> Result<(), Error> { + let key = SubscriptionDataKey::Subscription(id.clone()); + let subscription: Subscription = env + .storage() + .persistent() + .get(&key) + .ok_or(Error::SubscriptionNotFound)?; + + subscription.user.require_auth(); + let actor = subscription.user.clone(); + Self::resume_subscription_internal(env, id, subscription, actor, false) + } + + pub fn resume_subscription_admin(env: Env, id: String, admin: Address) -> Result<(), Error> { + Self::require_admin(&env, &admin)?; + + let key = SubscriptionDataKey::Subscription(id.clone()); + let subscription: Subscription = env + .storage() + .persistent() + .get(&key) + .ok_or(Error::SubscriptionNotFound)?; + + Self::resume_subscription_internal(env, id, subscription, admin, true) + } + + #[allow(deprecated)] + fn resume_subscription_internal( + env: Env, + id: String, + mut subscription: Subscription, + actor: Address, + is_admin: bool, + ) -> Result<(), Error> { + if subscription.status != MembershipStatus::Paused { + return Err(Error::SubscriptionNotPaused); + } + + let paused_at = subscription.paused_at.ok_or(Error::SubscriptionNotPaused)?; + let current_time = env.ledger().timestamp(); + let paused_duration = current_time + .checked_sub(paused_at) + .ok_or(Error::TimestampOverflow)?; + + let config = Self::get_pause_config_or_default(&env); + let applied_extension = if is_admin { + paused_duration + } else if paused_duration > config.max_pause_duration { + config.max_pause_duration + } else { + paused_duration + }; + + subscription.expires_at = subscription + .expires_at + .checked_add(applied_extension) + .ok_or(Error::TimestampOverflow)?; + subscription.status = MembershipStatus::Active; + subscription.paused_at = None; + subscription.last_resumed_at = current_time; + subscription.total_paused_duration = subscription + .total_paused_duration + .checked_add(paused_duration) + .ok_or(Error::TimestampOverflow)?; + + let entry = PauseHistoryEntry { + action: PauseAction::Resume, + timestamp: current_time, + actor: actor.clone(), + is_admin, + reason: None, + paused_duration: Some(paused_duration), + applied_extension: Some(applied_extension), + }; + subscription.pause_history.push_back(entry.clone()); + + let key = SubscriptionDataKey::Subscription(id.clone()); + env.storage().persistent().set(&key, &subscription); + env.storage().persistent().extend_ttl(&key, 100, 1000); + + env.events().publish( + ( + symbol_short!("subscr"), + id.clone(), + subscription.user.clone(), + ), + entry, + ); + + Self::log_subscription_event( + &env, + &subscription.user, + String::from_str(&env, "subscription_resumed"), + &id, + subscription.amount, + )?; + + Ok(()) + } + + pub fn get_pause_history(env: Env, id: String) -> Result, Error> { + let subscription = Self::get_subscription(env, id)?; + Ok(subscription.pause_history) + } + + pub fn get_pause_stats(env: Env, id: String) -> Result { + let subscription = Self::get_subscription(env, id)?; + Ok(PauseStats { + pause_count: subscription.pause_count, + total_paused_duration: subscription.total_paused_duration, + is_paused: subscription.status == MembershipStatus::Paused, + paused_at: subscription.paused_at, + tier_id: subscription.tier_id, + billing_cycle: subscription.billing_cycle, + }) + } + pub fn get_subscription(env: Env, id: String) -> Result { env.storage() .persistent() @@ -135,6 +413,7 @@ impl SubscriptionContract { .ok_or(Error::SubscriptionNotFound) } + #[allow(deprecated)] pub fn set_usdc_contract(env: Env, admin: Address, usdc_address: Address) -> Result<(), Error> { admin.require_auth(); @@ -160,6 +439,7 @@ impl SubscriptionContract { .ok_or(Error::UsdcContractNotSet) } + #[allow(deprecated)] pub fn cancel_subscription(env: Env, id: String) -> Result<(), Error> { let key = SubscriptionDataKey::Subscription(id.clone()); let mut subscription: Subscription = env @@ -176,6 +456,7 @@ impl SubscriptionContract { // Update status to inactive subscription.status = MembershipStatus::Inactive; + subscription.paused_at = None; env.storage().persistent().set(&key, &subscription); // Emit subscription cancelled event @@ -195,6 +476,7 @@ impl SubscriptionContract { Ok(()) } + #[allow(deprecated)] /// Renews a subscription for additional duration. pub fn renew_subscription( env: Env, @@ -213,6 +495,10 @@ impl SubscriptionContract { // Require authorization from subscription owner subscription.user.require_auth(); + if subscription.status == MembershipStatus::Paused { + return Err(Error::SubscriptionPaused); + } + // Validate payment Self::validate_payment(&env, &payment_token, amount, &subscription.user)?; @@ -598,6 +884,11 @@ impl SubscriptionContract { expires_at, tier_id: tier_id.clone(), billing_cycle: billing_cycle.clone(), + paused_at: None, + last_resumed_at: current_time, + pause_count: 0, + total_paused_duration: 0, + pause_history: Vec::new(&env), }; // Store subscription diff --git a/contracts/manage_hub/src/test.rs b/contracts/manage_hub/src/test.rs index bfec4fa..369105d 100644 --- a/contracts/manage_hub/src/test.rs +++ b/contracts/manage_hub/src/test.rs @@ -932,3 +932,345 @@ fn test_multiple_events_emitted_in_sequence() { let sub_after_cancel = client.get_subscription(&subscription_id); assert_eq!(sub_after_cancel.status, MembershipStatus::Inactive); } + +// ==================== Pause/Resume Tests ==================== + +#[test] +fn test_pause_subscription_success() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register(Contract, ()); + let client = ContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let user = Address::generate(&env); + let payment_token = Address::generate(&env); + let subscription_id = String::from_str(&env, "sub_pause_001"); + let amount = 100_000i128; + let duration = 2_592_000u64; // 30 days + + // Setup admin and USDC contract + client.set_admin(&admin); + client.set_usdc_contract(&admin, &payment_token); + client.create_subscription(&subscription_id, &user, &payment_token, &amount, &duration); + + // Verify subscription is active + let subscription = client.get_subscription(&subscription_id); + assert_eq!(subscription.status, MembershipStatus::Active); + assert_eq!(subscription.pause_count, 0); + + // Advance time to meet min_active_time requirement (1 day default) + env.ledger().with_mut(|l| l.timestamp += 86_400); + + // Pause subscription + let reason = Some(String::from_str(&env, "vacation")); + client.pause_subscription(&subscription_id, &reason); + + // Verify subscription is paused + let paused_subscription = client.get_subscription(&subscription_id); + assert_eq!(paused_subscription.status, MembershipStatus::Paused); + assert_eq!(paused_subscription.pause_count, 1); + assert!(paused_subscription.paused_at.is_some()); + + // Verify pause history + let history = client.get_pause_history(&subscription_id); + assert_eq!(history.len(), 1); + let entry = history.get(0).unwrap(); + assert_eq!(entry.actor, user); + assert!(!entry.is_admin); + assert_eq!(entry.reason, reason); +} + +#[test] +fn test_resume_subscription_success() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register(Contract, ()); + let client = ContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let user = Address::generate(&env); + let payment_token = Address::generate(&env); + let subscription_id = String::from_str(&env, "sub_resume_001"); + let amount = 100_000i128; + let duration = 2_592_000u64; + + // Setup admin and create subscription + client.set_admin(&admin); + client.set_usdc_contract(&admin, &payment_token); + client.create_subscription(&subscription_id, &user, &payment_token, &amount, &duration); + + let original_subscription = client.get_subscription(&subscription_id); + let original_expires_at = original_subscription.expires_at; + + // Advance time to meet min_active_time, then pause + env.ledger().with_mut(|l| l.timestamp += 86_400); + client.pause_subscription(&subscription_id, &None); + + // Advance time while paused + env.ledger().with_mut(|l| l.timestamp += 86400); // 1 day + + // Resume subscription + client.resume_subscription(&subscription_id); + + // Verify subscription is active again + let resumed_subscription = client.get_subscription(&subscription_id); + assert_eq!(resumed_subscription.status, MembershipStatus::Active); + assert!(resumed_subscription.paused_at.is_none()); + assert!(resumed_subscription.expires_at > original_expires_at); // Extended due to pause + + // Verify pause history shows both pause and resume + let history = client.get_pause_history(&subscription_id); + assert_eq!(history.len(), 2); + + let pause_entry = history.get(0).unwrap(); + let resume_entry = history.get(1).unwrap(); + + assert_eq!(pause_entry.action, types::PauseAction::Pause); + assert_eq!(resume_entry.action, types::PauseAction::Resume); + assert!(resume_entry.paused_duration.is_some()); + assert!(resume_entry.applied_extension.is_some()); +} + +#[test] +fn test_admin_pause_subscription() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register(Contract, ()); + let client = ContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let user = Address::generate(&env); + let payment_token = Address::generate(&env); + let subscription_id = String::from_str(&env, "sub_admin_pause"); + let amount = 100_000i128; + let duration = 2_592_000u64; + + // Setup admin and create subscription + client.set_admin(&admin); + client.set_usdc_contract(&admin, &payment_token); + client.create_subscription(&subscription_id, &user, &payment_token, &amount, &duration); + + // Admin pauses subscription (no time restrictions for admin) + let reason = Some(String::from_str(&env, "policy violation")); + client.pause_subscription_admin(&subscription_id, &admin, &reason); + + // Verify subscription is paused + let paused_subscription = client.get_subscription(&subscription_id); + assert_eq!(paused_subscription.status, MembershipStatus::Paused); + + // Verify pause history shows admin action + let history = client.get_pause_history(&subscription_id); + assert_eq!(history.len(), 1); + let entry = history.get(0).unwrap(); + assert_eq!(entry.actor, admin); + assert!(entry.is_admin); + assert_eq!(entry.reason, reason); +} + +#[test] +fn test_admin_resume_subscription() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register(Contract, ()); + let client = ContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let user = Address::generate(&env); + let payment_token = Address::generate(&env); + let subscription_id = String::from_str(&env, "sub_admin_resume"); + let amount = 100_000i128; + let duration = 2_592_000u64; + + // Setup admin and create subscription + client.set_admin(&admin); + client.set_usdc_contract(&admin, &payment_token); + client.create_subscription(&subscription_id, &user, &payment_token, &amount, &duration); + + // Advance time and pause subscription + env.ledger().with_mut(|l| l.timestamp += 86_400); + client.pause_subscription(&subscription_id, &None); + + // Admin resumes subscription + client.resume_subscription_admin(&subscription_id, &admin); + + // Verify subscription is active + let resumed_subscription = client.get_subscription(&subscription_id); + assert_eq!(resumed_subscription.status, MembershipStatus::Active); + + // Verify pause history shows admin resume + let history = client.get_pause_history(&subscription_id); + assert_eq!(history.len(), 2); + let resume_entry = history.get(1).unwrap(); + assert_eq!(resume_entry.actor, admin); + assert!(resume_entry.is_admin); +} + +#[test] +fn test_pause_config_management() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register(Contract, ()); + let client = ContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + + // Set admin first + client.set_admin(&admin); + + // Get default config + let default_config = client.get_pause_config(); + assert_eq!(default_config.max_pause_duration, 2_592_000); // 30 days + assert_eq!(default_config.max_pause_count, 3); + assert_eq!(default_config.min_active_time, 86_400); // 1 day + + // Set custom config + let custom_config = types::PauseConfig { + max_pause_duration: 1_296_000, // 15 days + max_pause_count: 2, + min_active_time: 172_800, // 2 days + }; + + client.set_pause_config(&admin, &custom_config); + + // Verify config was updated + let updated_config = client.get_pause_config(); + assert_eq!(updated_config.max_pause_duration, 1_296_000); + assert_eq!(updated_config.max_pause_count, 2); + assert_eq!(updated_config.min_active_time, 172_800); +} + +#[test] +fn test_pause_stats() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register(Contract, ()); + let client = ContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let user = Address::generate(&env); + let payment_token = Address::generate(&env); + let subscription_id = String::from_str(&env, "sub_stats"); + let amount = 100_000i128; + let duration = 2_592_000u64; + + // Setup admin and create subscription + client.set_admin(&admin); + client.set_usdc_contract(&admin, &payment_token); + client.create_subscription(&subscription_id, &user, &payment_token, &amount, &duration); + + // Check initial stats + let initial_stats = client.get_pause_stats(&subscription_id); + assert_eq!(initial_stats.pause_count, 0); + assert_eq!(initial_stats.total_paused_duration, 0); + assert!(!initial_stats.is_paused); + assert!(initial_stats.paused_at.is_none()); + + // Advance time and pause + env.ledger().with_mut(|l| l.timestamp += 86_400); + client.pause_subscription(&subscription_id, &None); + + let paused_stats = client.get_pause_stats(&subscription_id); + assert_eq!(paused_stats.pause_count, 1); + assert!(paused_stats.is_paused); + assert!(paused_stats.paused_at.is_some()); + + // Advance time and resume + env.ledger().with_mut(|l| l.timestamp += 86400); // 1 day + client.resume_subscription(&subscription_id); + + // Check final stats + let final_stats = client.get_pause_stats(&subscription_id); + assert_eq!(final_stats.pause_count, 1); + assert_eq!(final_stats.total_paused_duration, 86400); + assert!(!final_stats.is_paused); + assert!(final_stats.paused_at.is_none()); +} + +#[test] +#[should_panic(expected = "HostError: Error(Contract, #24)")] +fn test_pause_already_paused_subscription() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register(Contract, ()); + let client = ContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let user = Address::generate(&env); + let payment_token = Address::generate(&env); + let subscription_id = String::from_str(&env, "sub_double_pause"); + let amount = 100_000i128; + let duration = 2_592_000u64; + + // Setup admin and create subscription + client.set_admin(&admin); + client.set_usdc_contract(&admin, &payment_token); + client.create_subscription(&subscription_id, &user, &payment_token, &amount, &duration); + + // Advance time and pause subscription + env.ledger().with_mut(|l| l.timestamp += 86_400); + client.pause_subscription(&subscription_id, &None); + + // Try to pause again - should fail + client.pause_subscription(&subscription_id, &None); +} + +#[test] +#[should_panic(expected = "HostError: Error(Contract, #28)")] +fn test_resume_not_paused_subscription() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register(Contract, ()); + let client = ContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let user = Address::generate(&env); + let payment_token = Address::generate(&env); + let subscription_id = String::from_str(&env, "sub_resume_active"); + let amount = 100_000i128; + let duration = 2_592_000u64; + + // Setup and create subscription (but don't pause) + client.set_usdc_contract(&admin, &payment_token); + client.create_subscription(&subscription_id, &user, &payment_token, &amount, &duration); + + // Try to resume active subscription - should fail + client.resume_subscription(&subscription_id); +} + +#[test] +#[should_panic(expected = "HostError: Error(Contract, #24)")] +fn test_renew_paused_subscription() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register(Contract, ()); + let client = ContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let user = Address::generate(&env); + let payment_token = Address::generate(&env); + let subscription_id = String::from_str(&env, "sub_renew_paused"); + let amount = 100_000i128; + let duration = 2_592_000u64; + + // Setup admin and create subscription + client.set_admin(&admin); + client.set_usdc_contract(&admin, &payment_token); + client.create_subscription(&subscription_id, &user, &payment_token, &amount, &duration); + + // Advance time and pause subscription + env.ledger().with_mut(|l| l.timestamp += 86_400); + client.pause_subscription(&subscription_id, &None); + + // Try to renew paused subscription - should fail + client.renew_subscription(&subscription_id, &payment_token, &amount, &duration); +} diff --git a/contracts/manage_hub/src/types.rs b/contracts/manage_hub/src/types.rs index f334465..25d591b 100644 --- a/contracts/manage_hub/src/types.rs +++ b/contracts/manage_hub/src/types.rs @@ -1,4 +1,4 @@ -use soroban_sdk::{contracttype, Address, String}; +use soroban_sdk::{contracttype, Address, String, Vec}; // Re-export types from common_types for consistency pub use common_types::MembershipStatus; @@ -34,6 +34,49 @@ pub struct Subscription { pub status: MembershipStatus, pub created_at: u64, pub expires_at: u64, + pub tier_id: String, + pub billing_cycle: BillingCycle, + pub paused_at: Option, + pub last_resumed_at: u64, + pub pause_count: u32, + pub total_paused_duration: u64, + pub pause_history: Vec, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub enum PauseAction { + Pause, + Resume, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct PauseHistoryEntry { + pub action: PauseAction, + pub timestamp: u64, + pub actor: Address, + pub is_admin: bool, + pub reason: Option, + pub paused_duration: Option, + pub applied_extension: Option, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct PauseConfig { + pub max_pause_duration: u64, + pub max_pause_count: u32, + pub min_active_time: u64, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct PauseStats { + pub pause_count: u32, + pub total_paused_duration: u64, + pub is_paused: bool, + pub paused_at: Option, /// The tier ID this subscription belongs to pub tier_id: String, /// Billing cycle (monthly or annual)