Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions contracts/subscription/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
[package]
name = "subscription"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["cdylib", "rlib"]

[dependencies]
soroban-sdk = "22.0.10"

[dev-dependencies]
soroban-sdk = { version = "22.0.10", features = ["testutils"] }

[profile.release]
opt-level = "z"
overflow-checks = true
107 changes: 107 additions & 0 deletions contracts/subscription/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
#![cfg_attr(target_family = "wasm", no_std)]

use soroban_sdk::{contract, contractimpl, contracttype, contracterror, token, Address, Env};

#[contracterror]
#[derive(Copy, Clone, Debug, PartialEq)]
#[repr(u32)]
pub enum Error {
AlreadySubscribed = 1,
NotSubscribed = 2,
PaymentNotDue = 3,
}

#[contracttype]
#[derive(Clone, Debug, PartialEq)]
pub struct Subscription {
pub subscriber: Address,
pub merchant: Address,
pub token: Address,
pub amount: i128,
pub interval: u64,
pub next_payment: u64,
}

#[contracttype]
pub enum DataKey {
Sub(Address),
}

#[contract]
pub struct SubscriptionContract;

#[contractimpl]
impl SubscriptionContract {
/// Create a new subscription. The first payment is due immediately (next_payment = now).
pub fn subscribe(
env: Env,
subscriber: Address,
merchant: Address,
token: Address,
amount: i128,
interval: u64,
) -> Result<(), Error> {
subscriber.require_auth();

let key = DataKey::Sub(subscriber.clone());
if env.storage().persistent().has(&key) {
return Err(Error::AlreadySubscribed);
}

let now = env.ledger().timestamp();
let sub = Subscription {
subscriber,
merchant,
token,
amount,
interval,
next_payment: now,
};
env.storage().persistent().set(&key, &sub);
Ok(())
}

/// Execute a payment. Fails with PaymentNotDue if called before next_payment.
pub fn execute_payment(env: Env, subscriber: Address) -> Result<(), Error> {
let key = DataKey::Sub(subscriber.clone());
let mut sub: Subscription = env
.storage()
.persistent()
.get(&key)
.ok_or(Error::NotSubscribed)?;

let now = env.ledger().timestamp();
if now < sub.next_payment {
return Err(Error::PaymentNotDue);
}

let token_client = token::Client::new(&env, &sub.token);
token_client.transfer(&sub.subscriber, &sub.merchant, &sub.amount);

sub.next_payment = now + sub.interval;
env.storage().persistent().set(&key, &sub);
Ok(())
}

/// Cancel an active subscription.
pub fn cancel(env: Env, subscriber: Address) -> Result<(), Error> {
subscriber.require_auth();

let key = DataKey::Sub(subscriber);
if !env.storage().persistent().has(&key) {
return Err(Error::NotSubscribed);
}

env.storage().persistent().remove(&key);
Ok(())
}

/// Read the stored subscription record.
pub fn get_subscription(env: Env, subscriber: Address) -> Option<Subscription> {
env.storage()
.persistent()
.get(&DataKey::Sub(subscriber))
}
}

mod test;
108 changes: 108 additions & 0 deletions contracts/subscription/src/test.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
#![cfg(test)]

use super::*;
use soroban_sdk::{
testutils::{Address as _, Ledger},
token, Address, Env,
};

fn setup() -> (Env, Address, Address, Address, Address) {
let env = Env::default();
env.mock_all_auths();

let contract_id = env.register_contract(None, SubscriptionContract);
let admin = Address::generate(&env);
let token_id = env.register_stellar_asset_contract_v2(admin.clone()).address();

let subscriber = Address::generate(&env);
let merchant = Address::generate(&env);

let token_admin = token::StellarAssetClient::new(&env, &token_id);
token_admin.mint(&subscriber, &10_000);

(env, contract_id, token_id, subscriber, merchant)
}

// ---------------------------------------------------------------------------
// Issue #188 – execute_payment fails with PaymentNotDue before next_payment
// ---------------------------------------------------------------------------
#[test]
fn test_execute_payment_before_due_time_returns_error() {
let (env, contract_id, token_id, subscriber, merchant) = setup();
let client = SubscriptionContractClient::new(&env, &contract_id);

env.ledger().with_mut(|l| l.timestamp = 1000);
client.subscribe(&subscriber, &merchant, &token_id, &100, &3600).unwrap();

env.ledger().with_mut(|l| l.timestamp = 1001);
client.execute_payment(&subscriber).unwrap();

// next_payment is now 1001 + 3600 = 4601; calling again at T=1001 must fail
let err = client.try_execute_payment(&subscriber).unwrap_err().unwrap();
assert_eq!(err, Error::PaymentNotDue);

let sub = client.get_subscription(&subscriber).unwrap();
assert_eq!(sub.next_payment, 4601);
assert_eq!(sub.amount, 100);
}

#[test]
fn test_execute_payment_succeeds_at_due_time() {
let (env, contract_id, token_id, subscriber, merchant) = setup();
let client = SubscriptionContractClient::new(&env, &contract_id);

env.ledger().with_mut(|l| l.timestamp = 500);
client.subscribe(&subscriber, &merchant, &token_id, &200, &3600).unwrap();
client.execute_payment(&subscriber).unwrap();

let sub = client.get_subscription(&subscriber).unwrap();
assert_eq!(sub.next_payment, 500 + 3600);

let token_client = token::Client::new(&env, &token_id);
assert_eq!(token_client.balance(&merchant), 200);
}

// ---------------------------------------------------------------------------
// Issue #189 – cancel then re-subscribe replaces the storage record cleanly
// ---------------------------------------------------------------------------
#[test]
fn test_cancel_and_resubscribe_replaces_record() {
let (env, contract_id, token_id, subscriber, merchant) = setup();
let client = SubscriptionContractClient::new(&env, &contract_id);

env.ledger().with_mut(|l| l.timestamp = 0);
client.subscribe(&subscriber, &merchant, &token_id, &50, &600).unwrap();
assert_eq!(client.get_subscription(&subscriber).unwrap().amount, 50);

client.cancel(&subscriber).unwrap();
assert!(client.get_subscription(&subscriber).is_none());

env.ledger().with_mut(|l| l.timestamp = 100);
client.subscribe(&subscriber, &merchant, &token_id, &99, &1200).unwrap();

let sub = client.get_subscription(&subscriber).unwrap();
assert_eq!(sub.amount, 99);
assert_eq!(sub.interval, 1200);
assert_eq!(sub.next_payment, 100);
}

#[test]
fn test_cancel_nonexistent_subscription_returns_error() {
let (env, contract_id, _, subscriber, _) = setup();
let client = SubscriptionContractClient::new(&env, &contract_id);

let err = client.try_cancel(&subscriber).unwrap_err().unwrap();
assert_eq!(err, Error::NotSubscribed);
}

#[test]
fn test_double_subscribe_returns_error() {
let (env, contract_id, token_id, subscriber, merchant) = setup();
let client = SubscriptionContractClient::new(&env, &contract_id);

client.subscribe(&subscriber, &merchant, &token_id, &10, &100).unwrap();

let err = client.try_subscribe(&subscriber, &merchant, &token_id, &10, &100)
.unwrap_err().unwrap();
assert_eq!(err, Error::AlreadySubscribed);
}
Loading