diff --git a/contracts/common_types/src/types.rs b/contracts/common_types/src/types.rs index 507285b..796065c 100644 --- a/contracts/common_types/src/types.rs +++ b/contracts/common_types/src/types.rs @@ -167,6 +167,8 @@ pub enum UserRole { /// /// # Variants /// * `Active` - Membership is currently active +/// * `Paused` - Membership is temporarily paused +/// * `GracePeriod` - Membership expired but within grace period (usable with restrictions) /// * `Expired` - Membership has expired /// * `Revoked` - Membership has been revoked /// * `Inactive` - Membership is inactive @@ -175,7 +177,10 @@ pub enum UserRole { pub enum MembershipStatus { /// Active membership Active, + /// Temporarily paused membership Paused, + /// Expired but within grace period (restricted access) + GracePeriod, /// Expired membership Expired, /// Revoked membership diff --git a/contracts/manage_hub/src/errors.rs b/contracts/manage_hub/src/errors.rs index a942265..59f1e95 100644 --- a/contracts/manage_hub/src/errors.rs +++ b/contracts/manage_hub/src/errors.rs @@ -53,4 +53,9 @@ pub enum Error { TierAlreadyExists = 43, TierNotActive = 44, TierChangeNotFound = 45, + // Token renewal errors (reusing codes where applicable) + RenewalNotAllowed = 46, + TransferNotAllowedInGracePeriod = 47, + GracePeriodExpired = 48, + AutoRenewalFailed = 49, } diff --git a/contracts/manage_hub/src/lib.rs b/contracts/manage_hub/src/lib.rs index aea2980..4196012 100644 --- a/contracts/manage_hub/src/lib.rs +++ b/contracts/manage_hub/src/lib.rs @@ -425,6 +425,154 @@ impl Contract { MembershipTokenContract::query_tokens_by_attribute(env, attribute_key, attribute_value) } + // ============================================================================ + // Token Renewal System Endpoints + // ============================================================================ + + /// Sets the renewal configuration. Admin only. + /// + /// # Arguments + /// * `env` - The contract environment + /// * `grace_period_duration` - Grace period duration in seconds + /// * `auto_renewal_notice_days` - Days before expiry to trigger auto-renewal + /// * `renewals_enabled` - Whether renewals are enabled + /// + /// # Errors + /// * `AdminNotSet` - No admin configured + /// * `Unauthorized` - Caller is not admin + pub fn set_renewal_config( + env: Env, + grace_period_duration: u64, + auto_renewal_notice_days: u64, + renewals_enabled: bool, + ) -> Result<(), Error> { + MembershipTokenContract::set_renewal_config( + env, + grace_period_duration, + auto_renewal_notice_days, + renewals_enabled, + ) + } + + /// Gets the renewal configuration. + /// + /// # Arguments + /// * `env` - The contract environment + /// + /// # Returns + /// * The renewal configuration with defaults if not set + pub fn get_renewal_config(env: Env) -> types::RenewalConfig { + MembershipTokenContract::get_renewal_config(env) + } + + /// Renews a membership token with payment validation and tier pricing. + /// + /// # Arguments + /// * `env` - The contract environment + /// * `id` - Token ID to renew + /// * `payment_token` - Payment token address (must be USDC) + /// * `tier_id` - Tier ID for pricing lookup + /// * `billing_cycle` - Billing cycle (Monthly or Annual) + /// + /// # Errors + /// * `TokenNotFound` - Token doesn't exist + /// * `RenewalNotAllowed` - Renewals are disabled + /// * `TierNotFound` - Tier doesn't exist + /// * `InvalidPaymentAmount` - Invalid payment amount + /// * `InvalidPaymentToken` - Invalid payment token + /// * `Unauthorized` - Caller is not token owner + pub fn renew_token( + env: Env, + id: BytesN<32>, + payment_token: Address, + tier_id: String, + billing_cycle: BillingCycle, + ) -> Result<(), Error> { + MembershipTokenContract::renew_token(env, id, payment_token, tier_id, billing_cycle) + } + + /// Gets the renewal history for a token. + /// + /// # Arguments + /// * `env` - The contract environment + /// * `token_id` - Token ID + /// + /// # Returns + /// * Vector of renewal history entries + pub fn get_renewal_history(env: Env, token_id: BytesN<32>) -> Vec { + MembershipTokenContract::get_renewal_history(env, token_id) + } + + /// Checks and applies grace period to an expired token. + /// + /// # Arguments + /// * `env` - The contract environment + /// * `id` - Token ID + /// + /// # Returns + /// * Updated token if grace period was applied + pub fn check_and_apply_grace_period( + env: Env, + id: BytesN<32>, + ) -> Result { + MembershipTokenContract::check_and_apply_grace_period(env, id) + } + + /// Sets auto-renewal settings for a user's token. + /// + /// # Arguments + /// * `env` - The contract environment + /// * `token_id` - Token ID to enable auto-renewal for + /// * `enabled` - Whether to enable auto-renewal + /// * `payment_token` - Payment token to use for auto-renewal + pub fn set_auto_renewal( + env: Env, + token_id: BytesN<32>, + enabled: bool, + payment_token: Address, + ) -> Result<(), Error> { + MembershipTokenContract::set_auto_renewal(env, token_id, enabled, payment_token) + } + + /// Gets auto-renewal settings for a user. + /// + /// # Arguments + /// * `env` - The contract environment + /// * `user` - User address + /// + /// # Returns + /// * Auto-renewal settings or None if not set + pub fn get_auto_renewal_settings( + env: Env, + user: Address, + ) -> Option { + MembershipTokenContract::get_auto_renewal_settings(env, user) + } + + /// Checks if a token is eligible for auto-renewal. + /// + /// # Arguments + /// * `env` - The contract environment + /// * `id` - Token ID + /// + /// # Returns + /// * True if token is within auto-renewal window + pub fn check_auto_renewal_eligibility(env: Env, id: BytesN<32>) -> Result { + MembershipTokenContract::check_auto_renewal_eligibility(env, id) + } + + /// Processes auto-renewal for a token. Enters grace period on failure. + /// + /// # Arguments + /// * `env` - The contract environment + /// * `id` - Token ID + /// + /// # Returns + /// * Success or error + pub fn process_auto_renewal(env: Env, id: BytesN<32>) -> Result<(), Error> { + MembershipTokenContract::process_auto_renewal(env, id) + } + // ============================================================================ // Attendance Analytics Endpoints // ============================================================================ diff --git a/contracts/manage_hub/src/membership_token.rs b/contracts/manage_hub/src/membership_token.rs index 5002efe..f00b904 100644 --- a/contracts/manage_hub/src/membership_token.rs +++ b/contracts/manage_hub/src/membership_token.rs @@ -14,6 +14,9 @@ pub enum DataKey { Admin, Metadata(BytesN<32>), MetadataHistory(BytesN<32>), + RenewalConfig, + RenewalHistory(BytesN<32>), + AutoRenewalSettings(Address), } #[contracttype] @@ -24,6 +27,16 @@ pub struct MembershipToken { pub status: MembershipStatus, pub issue_date: u64, pub expiry_date: u64, + /// Tier ID for pricing lookup during renewals + pub tier_id: Option, + /// Timestamp when grace period was entered (None if not in grace period) + pub grace_period_entered_at: Option, + /// Timestamp when grace period expires (None if not in grace period) + pub grace_period_expires_at: Option, + /// Number of renewal attempts (for tracking and limiting) + pub renewal_attempts: u32, + /// Timestamp of last renewal attempt + pub last_renewal_attempt_at: Option, } pub struct MembershipTokenContract; @@ -61,6 +74,11 @@ impl MembershipTokenContract { status: MembershipStatus::Active, issue_date: current_time, expiry_date, + tier_id: None, + grace_period_entered_at: None, + grace_period_expires_at: None, + renewal_attempts: 0, + last_renewal_attempt_at: None, }; env.storage() .persistent() @@ -88,6 +106,11 @@ impl MembershipTokenContract { .get(&DataKey::Token(id.clone())) .ok_or(Error::TokenNotFound)?; + // Check if token is in grace period - transfers not allowed + if token.status == MembershipStatus::GracePeriod { + return Err(Error::TransferNotAllowedInGracePeriod); + } + // Check if token is active if token.status != MembershipStatus::Active { return Err(Error::TokenExpired); @@ -466,4 +489,510 @@ impl MembershipTokenContract { // 2. Query the index instead of scanning all tokens // 3. Return matching token IDs } + + // ============================================================================ + // Token Renewal System + // ============================================================================ + + /// Sets the renewal configuration. Admin only. + /// + /// # Arguments + /// * `env` - The contract environment + /// * `grace_period_duration` - Grace period duration in seconds + /// * `auto_renewal_notice_days` - Days before expiry to trigger auto-renewal + /// * `renewals_enabled` - Whether renewals are enabled + /// + /// # Errors + /// * `AdminNotSet` - No admin configured + /// * `Unauthorized` - Caller is not admin + pub fn set_renewal_config( + env: Env, + grace_period_duration: u64, + auto_renewal_notice_days: u64, + renewals_enabled: bool, + ) -> Result<(), Error> { + // Get admin address and require authorization + let admin: Address = env + .storage() + .instance() + .get(&DataKey::Admin) + .ok_or(Error::AdminNotSet)?; + admin.require_auth(); + + let config = crate::types::RenewalConfig { + grace_period_duration, + auto_renewal_notice_days, + renewals_enabled, + }; + + env.storage() + .instance() + .set(&DataKey::RenewalConfig, &config); + + // Emit renewal config updated event + env.events().publish( + (symbol_short!("rnw_cfg"), admin), + ( + grace_period_duration, + auto_renewal_notice_days, + renewals_enabled, + ), + ); + + Ok(()) + } + + /// Gets the renewal configuration. + /// + /// # Arguments + /// * `env` - The contract environment + /// + /// # Returns + /// * The renewal configuration with defaults if not set + pub fn get_renewal_config(env: Env) -> crate::types::RenewalConfig { + env.storage() + .instance() + .get(&DataKey::RenewalConfig) + .unwrap_or(crate::types::RenewalConfig { + grace_period_duration: 7 * 24 * 60 * 60, // 7 days default + auto_renewal_notice_days: 24 * 60 * 60, // 1 day default + renewals_enabled: true, + }) + } + + /// Renews a membership token with payment validation and tier pricing. + /// + /// # Arguments + /// * `env` - The contract environment + /// * `id` - Token ID to renew + /// * `payment_token` - Payment token address (must be USDC) + /// * `tier_id` - Tier ID for pricing lookup + /// * `billing_cycle` - Billing cycle (Monthly or Annual) + /// + /// # Errors + /// * `TokenNotFound` - Token doesn't exist + /// * `RenewalNotAllowed` - Renewals are disabled + /// * `TierNotFound` - Tier doesn't exist + /// * `InvalidPaymentAmount` - Invalid payment amount + /// * `InvalidPaymentToken` - Invalid payment token + /// * `Unauthorized` - Caller is not token owner + pub fn renew_token( + env: Env, + id: BytesN<32>, + payment_token: Address, + tier_id: String, + billing_cycle: crate::types::BillingCycle, + ) -> Result<(), Error> { + // Check if renewals are enabled + let config = Self::get_renewal_config(env.clone()); + if !config.renewals_enabled { + return Err(Error::RenewalNotAllowed); + } + + // Get token + let mut token: MembershipToken = env + .storage() + .persistent() + .get(&DataKey::Token(id.clone())) + .ok_or(Error::TokenNotFound)?; + + // Require token owner authorization + token.user.require_auth(); + + // Get tier pricing + use crate::subscription::SubscriptionContract; + let tier = SubscriptionContract::get_tier(env.clone(), tier_id.clone())?; + + // Calculate amount based on billing cycle + let amount = match billing_cycle { + crate::types::BillingCycle::Monthly => tier.price, + crate::types::BillingCycle::Annual => tier.annual_price, + }; + + // Calculate duration based on billing cycle + let duration = match billing_cycle { + crate::types::BillingCycle::Monthly => 30 * 24 * 60 * 60, // 30 days + crate::types::BillingCycle::Annual => 365 * 24 * 60 * 60, // 365 days + }; + + // Validate payment + let usdc_contract = SubscriptionContract::get_usdc_contract_address(&env)?; + if payment_token != usdc_contract { + return Err(Error::InvalidPaymentToken); + } + if amount <= 0 { + return Err(Error::InvalidPaymentAmount); + } + + // Capture old expiry for history + let old_expiry = token.expiry_date; + let current_time = env.ledger().timestamp(); + + // Determine renewal base (extend from expiry or current time if expired) + let renewal_base = if token.expiry_date > current_time { + token.expiry_date + } else { + current_time + }; + + // Calculate new expiry + let new_expiry = renewal_base + .checked_add(duration) + .ok_or(Error::TimestampOverflow)?; + + // Update token + token.expiry_date = new_expiry; + token.status = MembershipStatus::Active; + token.tier_id = Some(tier_id.clone()); + token.grace_period_entered_at = None; + token.grace_period_expires_at = None; + token.renewal_attempts = token.renewal_attempts.saturating_add(1); + token.last_renewal_attempt_at = Some(current_time); + + // Store updated token + env.storage() + .persistent() + .set(&DataKey::Token(id.clone()), &token); + env.storage() + .persistent() + .extend_ttl(&DataKey::Token(id.clone()), 100, 1000); + + // Record renewal in history + Self::record_renewal( + &env, + &id, + crate::types::RenewalHistory { + timestamp: env.ledger().timestamp(), + tier_id, + amount, + payment_token: payment_token.clone(), + success: true, + trigger: crate::types::RenewalTrigger::Manual, + old_expiry_date: old_expiry, + new_expiry_date: Some(new_expiry), + error: None, + }, + ); + + // Emit token renewal event + env.events().publish( + (symbol_short!("token_rnw"), id.clone(), token.user.clone()), + (payment_token, amount, old_expiry, new_expiry), + ); + + Ok(()) + } + + /// Records a renewal attempt in history. + fn record_renewal(env: &Env, token_id: &BytesN<32>, entry: crate::types::RenewalHistory) { + let history_key = DataKey::RenewalHistory(token_id.clone()); + let mut history: Vec = env + .storage() + .persistent() + .get(&history_key) + .unwrap_or_else(|| Vec::new(env)); + + history.push_back(entry); + + env.storage().persistent().set(&history_key, &history); + env.storage() + .persistent() + .extend_ttl(&history_key, 100, 1000); + } + + /// Gets the renewal history for a token. + /// + /// # Arguments + /// * `env` - The contract environment + /// * `token_id` - Token ID + /// + /// # Returns + /// * Vector of renewal history entries + pub fn get_renewal_history( + env: Env, + token_id: BytesN<32>, + ) -> Vec { + let history_key = DataKey::RenewalHistory(token_id); + env.storage() + .persistent() + .get(&history_key) + .unwrap_or_else(|| Vec::new(&env)) + } + + /// Checks and applies grace period to an expired token. + /// + /// # Arguments + /// * `env` - The contract environment + /// * `id` - Token ID + /// + /// # Returns + /// * Updated token if grace period was applied + pub fn check_and_apply_grace_period( + env: Env, + id: BytesN<32>, + ) -> Result { + let mut token: MembershipToken = env + .storage() + .persistent() + .get(&DataKey::Token(id.clone())) + .ok_or(Error::TokenNotFound)?; + + let current_time = env.ledger().timestamp(); + let config = Self::get_renewal_config(env.clone()); + + // Check if token is expired and not already in grace period + if token.status == MembershipStatus::Active && current_time > token.expiry_date { + // Enter grace period + token.status = MembershipStatus::GracePeriod; + token.grace_period_entered_at = Some(current_time); + token.grace_period_expires_at = Some( + current_time + .checked_add(config.grace_period_duration) + .ok_or(Error::TimestampOverflow)?, + ); + + env.storage() + .persistent() + .set(&DataKey::Token(id.clone()), &token); + + // Emit grace period entered event + env.events().publish( + (symbol_short!("grace_in"), id, token.user.clone()), + (current_time, token.grace_period_expires_at.unwrap()), + ); + } + + // Check if grace period has expired + if token.status == MembershipStatus::GracePeriod { + if let Some(grace_expiry) = token.grace_period_expires_at { + if current_time > grace_expiry { + return Err(Error::GracePeriodExpired); + } + } + } + + Ok(token) + } + + /// Sets auto-renewal settings for a user's token. + /// + /// # Arguments + /// * `env` - The contract environment + /// * `token_id` - Token ID to enable auto-renewal for + /// * `enabled` - Whether to enable auto-renewal + /// * `payment_token` - Payment token to use for auto-renewal + pub fn set_auto_renewal( + env: Env, + token_id: BytesN<32>, + enabled: bool, + payment_token: Address, + ) -> Result<(), Error> { + // Get token to verify it exists and get user + let token: MembershipToken = env + .storage() + .persistent() + .get(&DataKey::Token(token_id.clone())) + .ok_or(Error::TokenNotFound)?; + + // Require token owner authorization + token.user.require_auth(); + + let settings = crate::types::AutoRenewalSettings { + enabled, + token_id: token_id.clone(), + payment_token: payment_token.clone(), + updated_at: env.ledger().timestamp(), + }; + + env.storage() + .persistent() + .set(&DataKey::AutoRenewalSettings(token.user.clone()), &settings); + + // Emit auto-renewal settings updated event + env.events().publish( + (symbol_short!("auto_rnw"), token_id, token.user), + (enabled, payment_token), + ); + + Ok(()) + } + + /// Gets auto-renewal settings for a user. + /// + /// # Arguments + /// * `env` - The contract environment + /// * `user` - User address + /// + /// # Returns + /// * Auto-renewal settings or None if not set + pub fn get_auto_renewal_settings( + env: Env, + user: Address, + ) -> Option { + env.storage() + .persistent() + .get(&DataKey::AutoRenewalSettings(user)) + } + + /// Checks if a token is eligible for auto-renewal. + /// + /// # Arguments + /// * `env` - The contract environment + /// * `id` - Token ID + /// + /// # Returns + /// * True if token is within auto-renewal window + pub fn check_auto_renewal_eligibility(env: Env, id: BytesN<32>) -> Result { + let token: MembershipToken = env + .storage() + .persistent() + .get(&DataKey::Token(id)) + .ok_or(Error::TokenNotFound)?; + + let config = Self::get_renewal_config(env.clone()); + let current_time = env.ledger().timestamp(); + + // Calculate renewal threshold (notice period before expiry) + let renewal_threshold = token + .expiry_date + .checked_sub(config.auto_renewal_notice_days) + .ok_or(Error::TimestampOverflow)?; + + // Token is eligible if: + // 1. Current time is past the renewal threshold + // 2. Current time is before expiry + // 3. Token status is Active + Ok(current_time >= renewal_threshold + && current_time < token.expiry_date + && token.status == MembershipStatus::Active) + } + + /// Processes auto-renewal for a token. Enters grace period on failure. + /// + /// # Arguments + /// * `env` - The contract environment + /// * `id` - Token ID + /// + /// # Returns + /// * Success or error + pub fn process_auto_renewal(env: Env, id: BytesN<32>) -> Result<(), Error> { + // Get token + let mut token: MembershipToken = env + .storage() + .persistent() + .get(&DataKey::Token(id.clone())) + .ok_or(Error::TokenNotFound)?; + + // Check if auto-renewal is enabled for this user + let settings = Self::get_auto_renewal_settings(env.clone(), token.user.clone()) + .ok_or(Error::AutoRenewalFailed)?; + + if !settings.enabled || settings.token_id != id { + return Err(Error::AutoRenewalFailed); + } + + // Check eligibility + if !Self::check_auto_renewal_eligibility(env.clone(), id.clone())? { + return Err(Error::RenewalNotAllowed); + } + + // Get tier (use stored tier_id or error) + let tier_id = token.tier_id.clone().ok_or(Error::TierNotFound)?; + + use crate::subscription::SubscriptionContract; + let tier = SubscriptionContract::get_tier(env.clone(), tier_id.clone())?; + + // Use monthly pricing for auto-renewal + let amount = tier.price; + let duration = 30 * 24 * 60 * 60; // 30 days + + // Validate payment (but don't actually transfer - just validation) + let usdc_contract = SubscriptionContract::get_usdc_contract_address(&env)?; + if settings.payment_token != usdc_contract { + // Payment validation failed - enter grace period + Self::enter_grace_period_on_auto_renewal_failure(env, id, token)?; + return Err(Error::AutoRenewalFailed); + } + + // Note: In production, check if user has sufficient balance + // For now, we assume payment would succeed + + let old_expiry = token.expiry_date; + let current_time = env.ledger().timestamp(); + + // Calculate new expiry + let new_expiry = token + .expiry_date + .checked_add(duration) + .ok_or(Error::TimestampOverflow)?; + + // Update token + token.expiry_date = new_expiry; + token.renewal_attempts = token.renewal_attempts.saturating_add(1); + token.last_renewal_attempt_at = Some(current_time); + + // Store updated token + env.storage() + .persistent() + .set(&DataKey::Token(id.clone()), &token); + + // Record successful auto-renewal + Self::record_renewal( + &env, + &id, + crate::types::RenewalHistory { + timestamp: env.ledger().timestamp(), + tier_id, + amount, + payment_token: settings.payment_token.clone(), + success: true, + trigger: crate::types::RenewalTrigger::AutoRenewal, + old_expiry_date: old_expiry, + new_expiry_date: Some(new_expiry), + error: None, + }, + ); + + // Emit auto-renewal success event + env.events().publish( + (symbol_short!("auto_ok"), id, token.user), + (settings.payment_token, amount, old_expiry, new_expiry), + ); + + Ok(()) + } + + /// Helper function to enter grace period when auto-renewal fails. + fn enter_grace_period_on_auto_renewal_failure( + env: Env, + id: BytesN<32>, + mut token: MembershipToken, + ) -> Result<(), Error> { + let config = Self::get_renewal_config(env.clone()); + let current_time = env.ledger().timestamp(); + + token.status = MembershipStatus::GracePeriod; + token.grace_period_entered_at = Some(current_time); + token.grace_period_expires_at = Some( + current_time + .checked_add(config.grace_period_duration) + .ok_or(Error::TimestampOverflow)?, + ); + + env.storage() + .persistent() + .set(&DataKey::Token(id.clone()), &token); + + // Emit grace period entered due to auto-renewal failure + env.events().publish( + (symbol_short!("grace_ar"), id, token.user), + ( + current_time, + token.grace_period_expires_at.unwrap(), + String::from_str(&env, "auto_renewal_failed"), + ), + ); + + Ok(()) + } } diff --git a/contracts/manage_hub/src/subscription.rs b/contracts/manage_hub/src/subscription.rs index 98a5419..37e738f 100644 --- a/contracts/manage_hub/src/subscription.rs +++ b/contracts/manage_hub/src/subscription.rs @@ -432,7 +432,7 @@ impl SubscriptionContract { Ok(()) } - fn get_usdc_contract_address(env: &Env) -> Result { + pub fn get_usdc_contract_address(env: &Env) -> Result { env.storage() .instance() .get(&SubscriptionDataKey::UsdcContract) diff --git a/contracts/manage_hub/src/test.rs b/contracts/manage_hub/src/test.rs index 369105d..208ba08 100644 --- a/contracts/manage_hub/src/test.rs +++ b/contracts/manage_hub/src/test.rs @@ -1274,3 +1274,469 @@ fn test_renew_paused_subscription() { // Try to renew paused subscription - should fail client.renew_subscription(&subscription_id, &payment_token, &amount, &duration); } + +// ==================== Token Renewal System Tests ==================== + +#[test] +fn test_set_renewal_config_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); + client.set_admin(&admin); + + // Set renewal config + let grace_period = 7 * 24 * 60 * 60; // 7 days + let notice_period = 24 * 60 * 60; // 1 day + client.set_renewal_config(&grace_period, ¬ice_period, &true); + + // Get and verify config + let config = client.get_renewal_config(); + assert_eq!(config.grace_period_duration, grace_period); + assert_eq!(config.auto_renewal_notice_days, notice_period); + assert!(config.renewals_enabled); +} + +#[test] +fn test_renew_token_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 token_id = BytesN::<32>::random(&env); + let tier_id = String::from_str(&env, "tier_basic"); + + // Setup + client.set_admin(&admin); + client.set_usdc_contract(&admin, &payment_token); + + // Create tier + let tier_params = CreateTierParams { + id: tier_id.clone(), + name: String::from_str(&env, "Basic"), + level: common_types::TierLevel::Basic, + price: 100_000i128, + annual_price: 1_000_000i128, + features: soroban_sdk::vec![&env, common_types::TierFeature::BasicAccess], + max_users: 100, + max_storage: 10_000_000, + }; + client.create_tier(&admin, &tier_params); + + // Issue token + let expiry_date = env.ledger().timestamp() + 30 * 24 * 60 * 60; + client.issue_token(&token_id, &user, &expiry_date); + + let old_token = client.get_token(&token_id); + let old_expiry = old_token.expiry_date; + + // Renew token + client.renew_token(&token_id, &payment_token, &tier_id, &BillingCycle::Monthly); + + // Verify renewal + let renewed_token = client.get_token(&token_id); + assert!(renewed_token.expiry_date > old_expiry); + assert_eq!(renewed_token.status, MembershipStatus::Active); + assert_eq!(renewed_token.tier_id, Some(tier_id.clone())); + assert_eq!(renewed_token.renewal_attempts, 1); +} + +#[test] +#[should_panic(expected = "HostError: Error(Contract, #32)")] +fn test_renew_token_tier_not_found() { + 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 token_id = BytesN::<32>::random(&env); + + // Setup + client.set_admin(&admin); + client.set_usdc_contract(&admin, &payment_token); + + // Issue token + let expiry_date = env.ledger().timestamp() + 30 * 24 * 60 * 60; + client.issue_token(&token_id, &user, &expiry_date); + + // Try to renew with non-existent tier + client.renew_token( + &token_id, + &payment_token, + &String::from_str(&env, "nonexistent_tier"), + &BillingCycle::Monthly, + ); +} + +#[test] +fn test_grace_period_entry() { + 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 token_id = BytesN::<32>::random(&env); + + // Setup + client.set_admin(&admin); + + // Issue token with short expiry + let expiry_date = env.ledger().timestamp() + 100; + client.issue_token(&token_id, &user, &expiry_date); + + // Advance time past expiry + env.ledger().with_mut(|l| l.timestamp += 200); + + // Apply grace period + let token = client.check_and_apply_grace_period(&token_id); + assert_eq!(token.status, MembershipStatus::GracePeriod); + assert!(token.grace_period_entered_at.is_some()); + assert!(token.grace_period_expires_at.is_some()); +} + +#[test] +#[should_panic(expected = "HostError: Error(Contract, #47)")] +fn test_transfer_blocked_in_grace_period() { + 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 new_user = Address::generate(&env); + let token_id = BytesN::<32>::random(&env); + + // Setup + client.set_admin(&admin); + + // Issue token with short expiry + let expiry_date = env.ledger().timestamp() + 100; + client.issue_token(&token_id, &user, &expiry_date); + + // Advance time past expiry and enter grace period + env.ledger().with_mut(|l| l.timestamp += 200); + client.check_and_apply_grace_period(&token_id); + + // Try to transfer - should fail + client.transfer_token(&token_id, &new_user); +} + +#[test] +fn test_renewal_history_tracking() { + 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 token_id = BytesN::<32>::random(&env); + let tier_id = String::from_str(&env, "tier_pro"); + + // Setup + client.set_admin(&admin); + client.set_usdc_contract(&admin, &payment_token); + + // Create tier + let tier_params = CreateTierParams { + id: tier_id.clone(), + name: String::from_str(&env, "Pro"), + level: common_types::TierLevel::Pro, + price: 200_000i128, + annual_price: 2_000_000i128, + features: soroban_sdk::vec![&env, common_types::TierFeature::AdvancedAnalytics], + max_users: 500, + max_storage: 50_000_000, + }; + client.create_tier(&admin, &tier_params); + + // Issue token + let expiry_date = env.ledger().timestamp() + 30 * 24 * 60 * 60; + client.issue_token(&token_id, &user, &expiry_date); + + // Renew token twice + client.renew_token(&token_id, &payment_token, &tier_id, &BillingCycle::Monthly); + + env.ledger().with_mut(|l| l.timestamp += 1000); + client.renew_token(&token_id, &payment_token, &tier_id, &BillingCycle::Annual); + + // Check renewal history + let history = client.get_renewal_history(&token_id); + assert_eq!(history.len(), 2); + + let first_renewal = history.get(0).unwrap(); + assert_eq!(first_renewal.tier_id, tier_id); + assert!(first_renewal.success); + + let second_renewal = history.get(1).unwrap(); + assert_eq!(second_renewal.tier_id, tier_id); + assert!(second_renewal.success); +} + +#[test] +fn test_auto_renewal_settings() { + 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 token_id = BytesN::<32>::random(&env); + + // Setup + client.set_admin(&admin); + + // Issue token + let expiry_date = env.ledger().timestamp() + 30 * 24 * 60 * 60; + client.issue_token(&token_id, &user, &expiry_date); + + // Enable auto-renewal + client.set_auto_renewal(&token_id, &true, &payment_token); + + // Get settings + let settings = client.get_auto_renewal_settings(&user); + assert!(settings.is_some()); + + let settings_unwrapped = settings.unwrap(); + assert!(settings_unwrapped.enabled); + assert_eq!(settings_unwrapped.token_id, token_id); + assert_eq!(settings_unwrapped.payment_token, payment_token); +} + +#[test] +fn test_auto_renewal_eligibility() { + 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 token_id = BytesN::<32>::random(&env); + + // Setup with 1 day notice period + client.set_admin(&admin); + let grace_period = 7 * 24 * 60 * 60; + let notice_period = 24 * 60 * 60; + client.set_renewal_config(&grace_period, ¬ice_period, &true); + + // Issue token expiring in 2 days + let expiry_date = env.ledger().timestamp() + 2 * 24 * 60 * 60; + client.issue_token(&token_id, &user, &expiry_date); + + // Not yet eligible (2 days until expiry, need to be within 1 day) + let eligible_before = client.check_auto_renewal_eligibility(&token_id); + assert!(!eligible_before); + + // Advance time to 12 hours before expiry + env.ledger().with_mut(|l| l.timestamp += 36 * 60 * 60); + + // Now eligible + let eligible_after = client.check_auto_renewal_eligibility(&token_id); + assert!(eligible_after); +} + +#[test] +#[should_panic(expected = "HostError: Error(Contract, #48)")] +fn test_grace_period_expired() { + 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 token_id = BytesN::<32>::random(&env); + + // Setup with short grace period + client.set_admin(&admin); + let grace_period = 100; // 100 seconds + let notice_period = 50; + client.set_renewal_config(&grace_period, ¬ice_period, &true); + + // Issue token + let expiry_date = env.ledger().timestamp() + 50; + client.issue_token(&token_id, &user, &expiry_date); + + // Advance time past expiry + env.ledger().with_mut(|l| l.timestamp += 100); + client.check_and_apply_grace_period(&token_id); + + // Advance time past grace period + env.ledger().with_mut(|l| l.timestamp += 200); + + // Should fail - grace period expired + client.check_and_apply_grace_period(&token_id); +} + +#[test] +fn test_renewal_extends_from_current_expiry() { + 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 token_id = BytesN::<32>::random(&env); + let tier_id = String::from_str(&env, "tier_basic"); + + // Setup + client.set_admin(&admin); + client.set_usdc_contract(&admin, &payment_token); + + // Create tier + let tier_params = CreateTierParams { + id: tier_id.clone(), + name: String::from_str(&env, "Basic"), + level: common_types::TierLevel::Basic, + price: 100_000i128, + annual_price: 1_000_000i128, + features: soroban_sdk::vec![&env, common_types::TierFeature::BasicAccess], + max_users: 100, + max_storage: 10_000_000, + }; + client.create_tier(&admin, &tier_params); + + // Issue token expiring in 10 days + let expiry_date = env.ledger().timestamp() + 10 * 24 * 60 * 60; + client.issue_token(&token_id, &user, &expiry_date); + + // Renew before expiry (monthly = 30 days) + client.renew_token(&token_id, &payment_token, &tier_id, &BillingCycle::Monthly); + + // New expiry should be original_expiry + 30 days (not current_time + 30 days) + let renewed_token = client.get_token(&token_id); + let expected_expiry = expiry_date + 30 * 24 * 60 * 60; + assert_eq!(renewed_token.expiry_date, expected_expiry); +} + +#[test] +fn test_renewal_after_expiry_extends_from_current_time() { + 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 token_id = BytesN::<32>::random(&env); + let tier_id = String::from_str(&env, "tier_basic"); + + // Setup + client.set_admin(&admin); + client.set_usdc_contract(&admin, &payment_token); + + // Create tier + let tier_params = CreateTierParams { + id: tier_id.clone(), + name: String::from_str(&env, "Basic"), + level: common_types::TierLevel::Basic, + price: 100_000i128, + annual_price: 1_000_000i128, + features: soroban_sdk::vec![&env, common_types::TierFeature::BasicAccess], + max_users: 100, + max_storage: 10_000_000, + }; + client.create_tier(&admin, &tier_params); + + // Issue token + let expiry_date = env.ledger().timestamp() + 100; + client.issue_token(&token_id, &user, &expiry_date); + + // Advance time past expiry + env.ledger().with_mut(|l| l.timestamp += 200); + let current_time = env.ledger().timestamp(); + + // Enter grace period + client.check_and_apply_grace_period(&token_id); + + // Renew after expiry + client.renew_token(&token_id, &payment_token, &tier_id, &BillingCycle::Monthly); + + // New expiry should be current_time + 30 days (not expired_date + 30 days) + let renewed_token = client.get_token(&token_id); + let expected_expiry = current_time + 30 * 24 * 60 * 60; + assert_eq!(renewed_token.expiry_date, expected_expiry); +} + +#[test] +fn test_renewal_clears_grace_period() { + 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 token_id = BytesN::<32>::random(&env); + let tier_id = String::from_str(&env, "tier_basic"); + + // Setup + client.set_admin(&admin); + client.set_usdc_contract(&admin, &payment_token); + + // Create tier + let tier_params = CreateTierParams { + id: tier_id.clone(), + name: String::from_str(&env, "Basic"), + level: common_types::TierLevel::Basic, + price: 100_000i128, + annual_price: 1_000_000i128, + features: soroban_sdk::vec![&env, common_types::TierFeature::BasicAccess], + max_users: 100, + max_storage: 10_000_000, + }; + client.create_tier(&admin, &tier_params); + + // Issue token + let expiry_date = env.ledger().timestamp() + 100; + client.issue_token(&token_id, &user, &expiry_date); + + // Expire and enter grace period + env.ledger().with_mut(|l| l.timestamp += 200); + client.check_and_apply_grace_period(&token_id); + + let token_in_grace = client.get_token(&token_id); + assert_eq!(token_in_grace.status, MembershipStatus::GracePeriod); + assert!(token_in_grace.grace_period_entered_at.is_some()); + + // Renew token + client.renew_token(&token_id, &payment_token, &tier_id, &BillingCycle::Monthly); + + // Grace period should be cleared + let renewed_token = client.get_token(&token_id); + assert_eq!(renewed_token.status, MembershipStatus::Active); + assert!(renewed_token.grace_period_entered_at.is_none()); + assert!(renewed_token.grace_period_expires_at.is_none()); +} diff --git a/contracts/manage_hub/src/types.rs b/contracts/manage_hub/src/types.rs index 539b62e..fa9ef89 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, Vec}; +use soroban_sdk::{contracttype, Address, BytesN, String, Vec}; // Re-export types from common_types for consistency pub use common_types::MembershipStatus; @@ -223,3 +223,69 @@ pub struct SessionPair { pub clock_out_time: u64, pub duration: u64, } + +// ============================================================================ +// Token Renewal Types +// ============================================================================ + +/// Configuration for token renewal system. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct RenewalConfig { + /// Grace period duration in seconds (default 7 days) + pub grace_period_duration: u64, + /// Auto-renewal notice period in seconds (default 1 day before expiry) + pub auto_renewal_notice_days: u64, + /// Whether renewals are currently enabled + pub renewals_enabled: bool, +} + +/// Trigger reason for token renewal. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub enum RenewalTrigger { + /// Manual renewal by user or admin + Manual, + /// Automatic renewal triggered by system + AutoRenewal, + /// Renewal during grace period + GracePeriod, +} + +/// Record of a token renewal attempt. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct RenewalHistory { + /// Timestamp of renewal attempt + pub timestamp: u64, + /// Tier ID used for pricing + pub tier_id: String, + /// Amount paid for renewal + pub amount: i128, + /// Payment token address + pub payment_token: Address, + /// Whether renewal was successful + pub success: bool, + /// What triggered the renewal + pub trigger: RenewalTrigger, + /// Old expiry date before renewal + pub old_expiry_date: u64, + /// New expiry date after renewal (if successful) + pub new_expiry_date: Option, + /// Error message if renewal failed + pub error: Option, +} + +/// Auto-renewal settings for a user's token. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct AutoRenewalSettings { + /// Whether auto-renewal is enabled + pub enabled: bool, + /// Token ID to auto-renew + pub token_id: BytesN<32>, + /// Payment token to use for auto-renewal + pub payment_token: Address, + /// Timestamp when settings were last updated + pub updated_at: u64, +}