diff --git a/contracts/assetsup/src/error.rs b/contracts/assetsup/src/error.rs index 42fa4e3..ce8daa2 100644 --- a/contracts/assetsup/src/error.rs +++ b/contracts/assetsup/src/error.rs @@ -20,6 +20,13 @@ pub enum Error { Unauthorized = 8, // Payment is not valid InvalidPayment = 9, + //Subscription errors + SubscriptionNotFound = 201, + SubscriptionNotActive = 202, + SubscriptionActive = 203, + + //Payment errors + InsufficientPayment = 300, } pub fn handle_error(env: &Env, error: Error) -> ! { diff --git a/contracts/assetsup/src/lib.rs b/contracts/assetsup/src/lib.rs index a4a1881..a151d07 100644 --- a/contracts/assetsup/src/lib.rs +++ b/contracts/assetsup/src/lib.rs @@ -1,12 +1,14 @@ #![no_std] use crate::error::{Error, handle_error}; +use crate::subscription::SubscriptionService; use soroban_sdk::{Address, BytesN, Env, String, Vec, contract, contractimpl, contracttype}; pub(crate) mod asset; pub(crate) mod audit; pub(crate) mod branch; pub(crate) mod error; +pub(crate) mod subscription; pub(crate) mod types; pub use types::*; @@ -319,6 +321,31 @@ impl AssetUpContract { ) -> Result, Error> { Ok(audit::get_asset_log(&env, &asset_id)) } + //creates a new subscription + pub fn create_subscription( + env: Env, + id: BytesN<32>, + user: Address, + plan: crate::types::PlanType, + payment_token: Address, + duration_days: u32, + ) -> Result { + SubscriptionService::create_subscription(env, id, user, plan, payment_token, duration_days) + } + /// Cancels an active subscription. + pub fn cancel_subscription( + env: Env, + id: soroban_sdk::BytesN<32>, + ) -> Result { + SubscriptionService::cancel_subscription(env, id) + } + /// Retrieves subscription details. + pub fn get_subscription( + env: Env, + id: soroban_sdk::BytesN<32>, + ) -> Result { + SubscriptionService::get_subscription(env, id) + } } mod tests; diff --git a/contracts/assetsup/src/subscription.rs b/contracts/assetsup/src/subscription.rs new file mode 100644 index 0000000..be0c498 --- /dev/null +++ b/contracts/assetsup/src/subscription.rs @@ -0,0 +1,95 @@ +use crate::error::Error; +use crate::types::{DataKey, PlanType, Subscription, SubscriptionStatus}; +use soroban_sdk::{Address, BytesN, Env, token}; + +const LEDGERS_PER_DAY: u32 = 17280; //constants for ledgers +pub struct SubscriptionService; + +impl SubscriptionService { + pub fn create_subscription( + env: Env, + id: BytesN<32>, + user: Address, + plan: PlanType, + payment_token: Address, + duration_days: u32, + ) -> Result { + // 1. Authorization check + //user.require_auth(); + + // 2. Existence check + let sub_key = DataKey::Subscription(id.clone()); + if env.storage().persistent().has(&sub_key) { + return Err(Error::SubscriptionAlreadyExists); + } + + // 3. Payment Logic + let token_client = token::Client::new(&env, &payment_token); + let amount = plan.get_price_7_decimal(); + let recipient = env.current_contract_address(); + + // simualate token transfer from user to contract(payment) + //User has executed an 'approve' call on the token contract + //The contract pulls the token + token_client.transfer_from(&user, &user, &recipient, &amount); + + // 4. Calculate Dates + let current_ledger = env.ledger().sequence(); + //let seconds_in_day=24*60*60; + //let ledgers_in_day=seconds_in_day/env.ledger().close_time_resolution(); + let duration_ledgers = duration_days.saturating_mul(LEDGERS_PER_DAY); + let start_date = current_ledger; + let end_date = current_ledger.saturating_add(duration_ledgers); + + // 5. Create and store subscription object + let new_subscription = Subscription { + id, + user, + plan, + status: SubscriptionStatus::Active, + payment_token, + start_date, + end_date, + }; + + env.storage().persistent().set(&sub_key, &new_subscription); + + Ok(new_subscription) + } + + pub fn cancel_subscription(env: Env, id: BytesN<32>) -> Result { + let sub_key = DataKey::Subscription(id.clone()); + + //1. Retrieve subscription + let mut subscription: Subscription = env + .storage() + .persistent() + .get(&sub_key) + .ok_or(Error::SubscriptionNotFound)?; + + //2. Authorization check(only the subscriber can cancel) + subscription.user.require_auth(); + + // 3. Status check + if subscription.status != SubscriptionStatus::Active { + return Err(Error::SubscriptionNotActive); + } + + // 4. Update status and date + subscription.status = SubscriptionStatus::Cancelled; + //subscription.end_date = env.ledger().sequence(); //end immediately + + // 5.Store update + env.storage().persistent().set(&sub_key, &subscription); + + Ok(subscription) + } + + pub fn get_subscription(env: Env, id: BytesN<32>) -> Result { + let sub_key = DataKey::Subscription(id.clone()); + env.storage() + .persistent() + .get(&sub_key) + .ok_or(Error::SubscriptionNotFound) + } +} diff --git a/contracts/assetsup/src/subscription_tests.rs b/contracts/assetsup/src/subscription_tests.rs new file mode 100644 index 0000000..5456243 --- /dev/null +++ b/contracts/assetsup/src/subscription_tests.rs @@ -0,0 +1,154 @@ +#![cfg(test)] + +extern crate std; +use soroban_sdk::{ + testutils::{Address as _, MockAuth}, + token, + vec, + Address, BytesN, Env, Symbol, IntoVal, +}; + +use crate::{ + errors::ContractError, + types::{PlanType, Subscription, SubscriptionStatus}, +}; +//import shared helper +use super::common::setup_test_environment; + +// Subscription Test Cases +fn test_subscription_creation(){ + let (env,client, _admin, token_client)=setup_test_environment(); + let subscriber= Address::generate(&env); + let sub_id = BytesN::from_array(&env, &[1; 32]); + let plan=PlanType::Basic; + let amount=plan.get_price_7_decimal(); + + // 1. Fund the subscriber and set allowance + token_client.with_admin(&Address::generate(&env)).mint(&subscriber, &amount); + token_client.with_source(&subscribe).approve(&subscriber, &client_contract_id,&amount, &amount); + + // 2. Create subscription + let sub:Subsciption=client.mock_auths(&[MockAuth{ + address: subscriber.clone(), + invoke: &vec![ + &env, + ( + &token_client.contract_id(), + &Symbol::new(&env, "transfer_from"), + vec![ + &env, + subscriber.to_val(), + subscriber.to_val(), + client_contract_id.to_val(&env), + amount.to_val(), + ], + ), + ], + }]) + .create_subscription(&sub_id, &subscriber, &plan, &token_client.contract_id(), 30); + + // 3. Assert properties + assert_eq!(sub.id, sub_id); + assert_eq!(sub.status, SubscriptionStatus::Active); + assert_eq!(sub.start_date, 100); + // 30 days*24h*60.*60s/5s/ledger=518400 ledgers + assert_eq!(sub.end_date, 100+518400); + + // 4. Check contract balance(payment successful) + assert_eq!(token_client.balance(&client.contract_id),amount); + + // 5. Try to create again(fails with SubscriptionAlreadyExists) + let result=client + .mock_auths(&[MockAuth{ + address: subscriber.clone(), + invoke: &vec![ + &env, + ( + &token_client.contract_id, + &Symbol::new(&env,"transfer_from"), + vec![ + &env, + subscriber.to_val(), + subscriber.to_val(), + client_contract_id.to_val(&env), + amount.to_val(), + ], + ), + ], + }]) + .try_cancel_subscription(&sub_id, &subscriber, &plan, &token_client.contract_id,30); + + assert_eq!(result.unwrap_err().as_error().unwrap(), ContractError::SubscriptionNotActive.into()); + +} + +#[test] +fn test_subscription_cancellation(){ + let (env,client, _admin, token_client)=setup_test_environment(); + let subscriber= Address::generate(&env); + let sub_id = BytesN::from_array(&env, &[2; 32]); + let plan=PlanType::Premium; + let amount=plan.get_price_7_decimal(); + + // 1. Create subscription + token_client.with_admin(&Address::generate(&env)).mint(&subscriber, &amount); + token_client.with_source(&subscribe).approve(&subscriber, &client_contract_id, &amount); + + client.mock_auths(&[MockAuth{ + address: subscriber.clone(), + invoke: &vec![ + &env, + ( + &token_client.contract_id(), + &Symbol::new(&env, "transfer_from"), + vec![ + &env, + subscriber.to_val(), + subscriber.to_val(), + client_contract_id.to_val(&env), + amount.to_val(), + ], + ), + ], + }]) + .create_subscription(&sub_id, &subscriber, &plan, &token_client.contract_id(), 30); + + // Advance ledger sequence to simulate time passage + env.ledger().set_sequence(200); + + // 2. Cancel the subscription + let cancelled_sub: Subscription=client + .mock_auths(&[MockAuth{ + address: subscriber.clone(), + invoke: &vec![], + }]) + .cancel_subscription(&sub_id); + + // 3. Assert properties + assert_eq!(cancelled_sub.status, SubscriptionStatus::Cancelled); + assert_eq!(cancelled_sub.end_date, 200); //should be current ledger sequence + + + let result=client + .mock_auths(&[MockAuth{ + address: subscriber.clone(), + invoke: &vec![&env], + }]) + .try_cancel_subscription(&sub_id); + + assert_eq!(result.unwrap_err().as_error().unwrap(), ContractError::SubscriptionNotActive.into()); + +} + +#[test] +fn test_get_subscription_not_found() { + let (env, client, _admin, _token_client) = setup_test_environment(); + let sub_id = BytesN::from_array(&env, &[3; 32]); + + // try to get a non-existent subscription + let result = client.get_subscription(&sub_id); + assert_eq!( + result.unwrap_err().as_error().unwrap(), + ContractError::SubscriptionNotFound.into() + ); +} \ No newline at end of file diff --git a/contracts/assetsup/src/types.rs b/contracts/assetsup/src/types.rs index ba7077f..259ff12 100644 --- a/contracts/assetsup/src/types.rs +++ b/contracts/assetsup/src/types.rs @@ -1,5 +1,5 @@ #![allow(clippy::upper_case_acronyms)] -use soroban_sdk::contracttype; +use soroban_sdk::{Address, BytesN, contracttype}; /// Represents the fundamental type of asset being managed /// Distinguishes between physical and digital assets for different handling requirements @@ -19,7 +19,12 @@ pub enum AssetStatus { InMaintenance, Disposed, } - +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum DataKey { + Admin, + Subscription(BytesN<32>), +} /// Represents different types of actions that can be performed on assets /// Used for audit trails and tracking asset lifecycle events #[contracttype] @@ -43,7 +48,16 @@ pub enum PlanType { Pro, Enterprise, } - +impl PlanType { + /// Returns the required monthly payment amount in 7-decimal precision tokens (e.g., USDC). + pub fn get_price_7_decimal(&self) -> i128 { + match self { + PlanType::Basic => 10_0000000, + PlanType::Pro => 20_000000, + PlanType::Enterprise => 50_0000000, + } + } +} /// Represents the current status of a subscription /// Used to control access to platform features #[contracttype] @@ -53,3 +67,22 @@ pub enum SubscriptionStatus { Expired, Cancelled, } +/// Main structure holding subscription details. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Subscription { + /// Unique identifier for the subscription. + pub id: BytesN<32>, + /// Address of the user/owns the subscription. + pub user: Address, + /// Type of plan subscribed to. + pub plan: PlanType, + /// Current status of the subscription. + pub status: SubscriptionStatus, + /// Ledger sequence number when the subscription started. + pub start_date: u32, + /// Ledger sequence number when the subscription is scheduled to end. + pub end_date: u32, + /// Address of the payment token used (e.g., USDC contract address). + pub payment_token: Address, +} diff --git a/contracts/assetsup/test_snapshots/tests/asset/test_update_status_creates_audit_log.1.json b/contracts/assetsup/test_snapshots/tests/asset/test_update_status_creates_audit_log.1.json index 139b2bf..44c8f0d 100644 --- a/contracts/assetsup/test_snapshots/tests/asset/test_update_status_creates_audit_log.1.json +++ b/contracts/assetsup/test_snapshots/tests/asset/test_update_status_creates_audit_log.1.json @@ -33,7 +33,7 @@ "symbol": "branch_id" }, "val": { - "bytes": "9e2b92242521971b9940a9ded06b307c9d74d5316d43b545ca5ef67e3f9ec93a" + "bytes": "707f2737376b596a94884c54b5ea9642a1b463daf4fdee579d2c0fa9f85ddb91" } }, { @@ -68,7 +68,7 @@ "symbol": "id" }, "val": { - "bytes": "9eb73418d79f261e3ad00e39c9814bcf585538578b5bab8944cb2e5ee975b8e8" + "bytes": "9ce714d3c8b0c516d34190952dec010344eaeaa758bcb583fd331efcc82c5ebf" } }, { @@ -123,7 +123,7 @@ "symbol": "stellar_token_id" }, "val": { - "bytes": "8bcda75d46ac26a2e1aff5e3562da3d8466a092fdd8cc9ed05a7ad21d3e08c9b" + "bytes": "066c079db2927db08e15ce2de4a32024b4c2bd04a021c01cde7b0e302f4b7ad1" } }, { @@ -153,7 +153,7 @@ "function_name": "update_asset_status", "args": [ { - "bytes": "9eb73418d79f261e3ad00e39c9814bcf585538578b5bab8944cb2e5ee975b8e8" + "bytes": "9ce714d3c8b0c516d34190952dec010344eaeaa758bcb583fd331efcc82c5ebf" }, { "vec": [ @@ -191,7 +191,7 @@ "symbol": "Asset" }, { - "bytes": "9eb73418d79f261e3ad00e39c9814bcf585538578b5bab8944cb2e5ee975b8e8" + "bytes": "9ce714d3c8b0c516d34190952dec010344eaeaa758bcb583fd331efcc82c5ebf" } ] }, @@ -211,7 +211,7 @@ "symbol": "Asset" }, { - "bytes": "9eb73418d79f261e3ad00e39c9814bcf585538578b5bab8944cb2e5ee975b8e8" + "bytes": "9ce714d3c8b0c516d34190952dec010344eaeaa758bcb583fd331efcc82c5ebf" } ] }, @@ -235,7 +235,7 @@ "symbol": "branch_id" }, "val": { - "bytes": "9e2b92242521971b9940a9ded06b307c9d74d5316d43b545ca5ef67e3f9ec93a" + "bytes": "707f2737376b596a94884c54b5ea9642a1b463daf4fdee579d2c0fa9f85ddb91" } }, { @@ -270,7 +270,7 @@ "symbol": "id" }, "val": { - "bytes": "9eb73418d79f261e3ad00e39c9814bcf585538578b5bab8944cb2e5ee975b8e8" + "bytes": "9ce714d3c8b0c516d34190952dec010344eaeaa758bcb583fd331efcc82c5ebf" } }, { @@ -325,7 +325,7 @@ "symbol": "stellar_token_id" }, "val": { - "bytes": "8bcda75d46ac26a2e1aff5e3562da3d8466a092fdd8cc9ed05a7ad21d3e08c9b" + "bytes": "066c079db2927db08e15ce2de4a32024b4c2bd04a021c01cde7b0e302f4b7ad1" } }, { @@ -355,7 +355,7 @@ "symbol": "AuditLog" }, { - "bytes": "9eb73418d79f261e3ad00e39c9814bcf585538578b5bab8944cb2e5ee975b8e8" + "bytes": "9ce714d3c8b0c516d34190952dec010344eaeaa758bcb583fd331efcc82c5ebf" } ] }, @@ -375,7 +375,7 @@ "symbol": "AuditLog" }, { - "bytes": "9eb73418d79f261e3ad00e39c9814bcf585538578b5bab8944cb2e5ee975b8e8" + "bytes": "9ce714d3c8b0c516d34190952dec010344eaeaa758bcb583fd331efcc82c5ebf" } ] },