diff --git a/contracts/cntr/Cargo.toml b/contracts/cntr/Cargo.toml index 40fdfbc..1bb6c17 100644 --- a/contracts/cntr/Cargo.toml +++ b/contracts/cntr/Cargo.toml @@ -2,3 +2,7 @@ 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 index f0def92..d8e5eb4 100644 --- a/contracts/cntr/src/lib.rs +++ b/contracts/cntr/src/lib.rs @@ -1,3 +1,5 @@ +pub mod grace_period; +pub mod payment_validator; pub mod credit_topup; pub mod multi_booking; pub mod payment_validator; diff --git a/contracts/cntr/src/payment_validator.rs b/contracts/cntr/src/payment_validator.rs index 0ca7a2e..0540179 100644 --- a/contracts/cntr/src/payment_validator.rs +++ b/contracts/cntr/src/payment_validator.rs @@ -1,3 +1,32 @@ +/// 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(()) /// Validates that a received payment amount is within tolerance of the expected amount. /// /// Returns `Ok(())` when `received_stroops >= expected_stroops - tolerance_stroops`. 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()); +}