diff --git a/contracts/Cargo.toml b/contracts/Cargo.toml index a6c082c..e3768cc 100644 --- a/contracts/Cargo.toml +++ b/contracts/Cargo.toml @@ -2,6 +2,7 @@ resolver = "2" members = [ "access_control", + "cntr", "manage_hub", "membership_token", "common_types", diff --git a/contracts/cntr/Cargo.toml b/contracts/cntr/Cargo.toml new file mode 100644 index 0000000..1bb6c17 --- /dev/null +++ b/contracts/cntr/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "cntr" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +doctest = false diff --git a/contracts/cntr/src/grace_period.rs b/contracts/cntr/src/grace_period.rs new file mode 100644 index 0000000..888cd05 --- /dev/null +++ b/contracts/cntr/src/grace_period.rs @@ -0,0 +1,84 @@ +/// Default grace period: 3 days in seconds. +pub const DEFAULT_GRACE_SECONDS: u64 = 259_200; + +#[derive(Debug, PartialEq)] +pub enum SubscriptionStatus { + Active, + InGracePeriod, + Expired, +} + +/// Returns the subscription status based on timestamps. +/// +/// - `Active` when `current_ts < expiry_ts` +/// - `InGracePeriod` when `expiry_ts <= current_ts < expiry_ts + grace_seconds` +/// - `Expired` when `current_ts >= expiry_ts + grace_seconds` +pub fn get_subscription_status( + expiry_ts: u64, + current_ts: u64, + grace_seconds: u64, +) -> SubscriptionStatus { + if current_ts < expiry_ts { + SubscriptionStatus::Active + } else if current_ts < expiry_ts.saturating_add(grace_seconds) { + SubscriptionStatus::InGracePeriod + } else { + SubscriptionStatus::Expired + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const EXPIRY: u64 = 1_000_000; + const GRACE: u64 = DEFAULT_GRACE_SECONDS; + + #[test] + fn active_well_before_expiry() { + assert_eq!( + get_subscription_status(EXPIRY, EXPIRY - 1000, GRACE), + SubscriptionStatus::Active + ); + } + + #[test] + fn active_one_second_before_expiry() { + assert_eq!( + get_subscription_status(EXPIRY, EXPIRY - 1, GRACE), + SubscriptionStatus::Active + ); + } + + #[test] + fn in_grace_period_at_exact_expiry() { + assert_eq!( + get_subscription_status(EXPIRY, EXPIRY, GRACE), + SubscriptionStatus::InGracePeriod + ); + } + + #[test] + fn in_grace_period_one_second_before_grace_ends() { + assert_eq!( + get_subscription_status(EXPIRY, EXPIRY + GRACE - 1, GRACE), + SubscriptionStatus::InGracePeriod + ); + } + + #[test] + fn expired_at_exact_grace_boundary() { + assert_eq!( + get_subscription_status(EXPIRY, EXPIRY + GRACE, GRACE), + SubscriptionStatus::Expired + ); + } + + #[test] + fn expired_well_past_grace() { + assert_eq!( + get_subscription_status(EXPIRY, EXPIRY + GRACE + 10_000, GRACE), + SubscriptionStatus::Expired + ); + } +} diff --git a/contracts/cntr/src/lib.rs b/contracts/cntr/src/lib.rs new file mode 100644 index 0000000..be9f09c --- /dev/null +++ b/contracts/cntr/src/lib.rs @@ -0,0 +1,2 @@ +pub mod grace_period; +pub mod payment_validator; diff --git a/contracts/cntr/src/payment_validator.rs b/contracts/cntr/src/payment_validator.rs new file mode 100644 index 0000000..120d1df --- /dev/null +++ b/contracts/cntr/src/payment_validator.rs @@ -0,0 +1,30 @@ +/// Default payment tolerance in stroops (1 XLM = 10_000_000 stroops). +pub const DEFAULT_TOLERANCE: i128 = 100; + +#[derive(Debug, PartialEq)] +pub enum PaymentError { + NegativePayment, + ZeroPayment, + Underpayment, +} + +/// Validates that `paid_amount` satisfies `expected_amount` within `tolerance`. +/// +/// Accepts if `paid_amount >= expected_amount - tolerance`. +/// Overpayment is always accepted. +pub fn validate_payment( + paid_amount: i128, + expected_amount: i128, + tolerance: i128, +) -> Result<(), PaymentError> { + if paid_amount < 0 { + return Err(PaymentError::NegativePayment); + } + if paid_amount == 0 && expected_amount > 0 { + return Err(PaymentError::ZeroPayment); + } + if paid_amount < expected_amount - tolerance { + return Err(PaymentError::Underpayment); + } + Ok(()) +} diff --git a/contracts/cntr/tests/payment_verification_tests.rs b/contracts/cntr/tests/payment_verification_tests.rs new file mode 100644 index 0000000..44a6d3d --- /dev/null +++ b/contracts/cntr/tests/payment_verification_tests.rs @@ -0,0 +1,75 @@ +use cntr::payment_validator::{validate_payment, PaymentError, DEFAULT_TOLERANCE}; + +const EXPECTED: i128 = 10_000_000; // 1 XLM in stroops +const TOL: i128 = DEFAULT_TOLERANCE; + +#[test] +fn exact_amount_passes() { + assert!(validate_payment(EXPECTED, EXPECTED, TOL).is_ok()); +} + +#[test] +fn overpayment_passes() { + assert!(validate_payment(EXPECTED + 1_000_000, EXPECTED, TOL).is_ok()); +} + +#[test] +fn within_tolerance_passes() { + assert!(validate_payment(EXPECTED - TOL, EXPECTED, TOL).is_ok()); +} + +#[test] +fn one_stroop_below_tolerance_fails() { + assert_eq!( + validate_payment(EXPECTED - TOL - 1, EXPECTED, TOL), + Err(PaymentError::Underpayment) + ); +} + +#[test] +fn underpayment_well_below_fails() { + assert_eq!( + validate_payment(EXPECTED / 2, EXPECTED, TOL), + Err(PaymentError::Underpayment) + ); +} + +#[test] +fn zero_payment_fails() { + assert_eq!( + validate_payment(0, EXPECTED, TOL), + Err(PaymentError::ZeroPayment) + ); +} + +#[test] +fn negative_payment_fails() { + assert_eq!( + validate_payment(-1, EXPECTED, TOL), + Err(PaymentError::NegativePayment) + ); +} + +#[test] +fn zero_expected_amount_with_zero_paid_passes() { + assert!(validate_payment(0, 0, TOL).is_ok()); +} + +#[test] +fn tolerance_of_zero_exact_passes() { + assert!(validate_payment(EXPECTED, EXPECTED, 0).is_ok()); +} + +#[test] +fn tolerance_of_zero_one_stroop_under_fails() { + assert_eq!( + validate_payment(EXPECTED - 1, EXPECTED, 0), + Err(PaymentError::Underpayment) + ); +} + +#[test] +fn large_amount_near_i128_max_passes() { + let large = i128::MAX / 2; + assert!(validate_payment(large, large, TOL).is_ok()); +}