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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ on:

env:
NODE_VERSION: '20'
RUST_VERSION: '1.77'
RUST_VERSION: '1.85'

jobs:
commitlint:
Expand Down
215 changes: 212 additions & 3 deletions contracts/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ pub enum Interval {
Yearly, // 31536000s (365 days)
}

const MAX_PAUSE_DURATION: u64 = 2_592_000; // 30 days

impl Interval {
pub fn seconds(&self) -> u64 {
match self {
Expand Down Expand Up @@ -59,6 +61,8 @@ pub struct Subscription {
pub last_charged_at: u64,
pub next_charge_at: u64,
pub total_paid: i128,
pub paused_at: u64,
pub pause_duration: u64,
pub refund_requested_amount: i128,
}

Expand Down Expand Up @@ -209,6 +213,8 @@ impl SubTrackrContract {
last_charged_at: now,
next_charge_at: now + plan.interval.seconds(),
total_paid: 0,
paused_at: 0,
pause_duration: 0,
refund_requested_amount: 0,
};

Expand Down Expand Up @@ -277,6 +283,16 @@ impl SubTrackrContract {

/// User pauses their subscription
pub fn pause_subscription(env: Env, subscriber: Address, subscription_id: u64) {
Self::pause_by_subscriber(env, subscriber, subscription_id, MAX_PAUSE_DURATION);
}

/// User pauses their subscription with a specific duration
pub fn pause_by_subscriber(
env: Env,
subscriber: Address,
subscription_id: u64,
duration: u64,
) {
subscriber.require_auth();

let mut sub: Subscription = env
Expand All @@ -290,12 +306,24 @@ impl SubTrackrContract {
sub.status == SubscriptionStatus::Active,
"Only active subscriptions can be paused"
);
assert!(
duration <= MAX_PAUSE_DURATION,
"Pause duration exceeds limit"
);

sub.status = SubscriptionStatus::Paused;
sub.paused_at = env.ledger().timestamp();
sub.pause_duration = duration;

env.storage()
.persistent()
.set(&DataKey::Subscription(subscription_id), &sub);

// Publish event
env.events().publish(
(String::from_str(&env, "subscription_paused"), subscriber),
(subscription_id, sub.paused_at, duration),
);
}

/// User resumes a paused subscription
Expand All @@ -310,7 +338,8 @@ impl SubTrackrContract {

assert!(sub.subscriber == subscriber, "Only subscriber can resume");
assert!(
sub.status == SubscriptionStatus::Paused,
sub.status == SubscriptionStatus::Paused
|| Self::check_and_resume_internal(&env, &mut sub),
"Only paused subscriptions can be resumed"
);

Expand All @@ -323,10 +352,18 @@ impl SubTrackrContract {

sub.status = SubscriptionStatus::Active;
sub.next_charge_at = now + plan.interval.seconds();
sub.paused_at = 0;
sub.pause_duration = 0;

env.storage()
.persistent()
.set(&DataKey::Subscription(subscription_id), &sub);

// Publish event
env.events().publish(
(String::from_str(&env, "subscription_resumed"), subscriber),
subscription_id,
);
}

// ── Payment Processing ──
Expand All @@ -341,6 +378,13 @@ impl SubTrackrContract {

sub.subscriber.require_auth();

// Handle auto-resume if needed
if Self::check_and_resume_internal(&env, &mut sub) {
env.storage()
.persistent()
.set(&DataKey::Subscription(subscription_id), &sub);
}

assert!(
sub.status == SubscriptionStatus::Active,
"Subscription not active"
Expand Down Expand Up @@ -478,6 +522,108 @@ impl SubTrackrContract {
);
}

/// Request a refund for a subscription (can only be called by the subscriber)
pub fn request_refund(env: Env, subscription_id: u64, amount: i128) {
let mut sub: Subscription = env
.storage()
.persistent()
.get(&DataKey::Subscription(subscription_id))
.expect("Subscription not found");

sub.subscriber.require_auth();

assert!(amount > 0, "Refund amount must be positive");
assert!(
amount <= sub.total_paid,
"Refund amount cannot exceed total paid"
);

sub.refund_requested_amount = amount;

env.storage()
.persistent()
.set(&DataKey::Subscription(subscription_id), &sub);

// Publish event
env.events().publish(
(String::from_str(&env, "refund_requested"), subscription_id),
(sub.subscriber.clone(), amount),
);
}

/// Approve a refund (can only be called by the admin)
pub fn approve_refund(env: Env, subscription_id: u64) {
let mut sub: Subscription = env
.storage()
.persistent()
.get(&DataKey::Subscription(subscription_id))
.expect("Subscription not found");

let admin: Address = env
.storage()
.instance()
.get(&DataKey::Admin)
.expect("Admin not set");
admin.require_auth();

let amount = sub.refund_requested_amount;
assert!(amount > 0, "No pending refund request");

let _plan: Plan = env
.storage()
.persistent()
.get(&DataKey::Plan(sub.plan_id))
.expect("Plan not found");

// TODO: Execute actual token transfer from merchant back to subscriber
// token::Client::new(&env, &plan.token).transfer(
// &plan.merchant, &sub.subscriber, &amount
// );

sub.total_paid -= amount;
sub.refund_requested_amount = 0;

env.storage()
.persistent()
.set(&DataKey::Subscription(subscription_id), &sub);

// Publish event
env.events().publish(
(String::from_str(&env, "refund_approved"), subscription_id),
(sub.subscriber.clone(), amount),
);
}

/// Reject a refund (can only be called by the admin)
pub fn reject_refund(env: Env, subscription_id: u64) {
let mut sub: Subscription = env
.storage()
.persistent()
.get(&DataKey::Subscription(subscription_id))
.expect("Subscription not found");

let admin: Address = env
.storage()
.instance()
.get(&DataKey::Admin)
.expect("Admin not set");
admin.require_auth();

assert!(sub.refund_requested_amount > 0, "No pending refund request");

sub.refund_requested_amount = 0;

env.storage()
.persistent()
.set(&DataKey::Subscription(subscription_id), &sub);

// Publish event
env.events().publish(
(String::from_str(&env, "refund_rejected"), subscription_id),
sub.subscriber.clone(),
);
}

// ── Queries ──

/// Get plan details
Expand All @@ -490,10 +636,14 @@ impl SubTrackrContract {

/// Get subscription details
pub fn get_subscription(env: Env, subscription_id: u64) -> Subscription {
env.storage()
let mut sub: Subscription = env
.storage()
.persistent()
.get(&DataKey::Subscription(subscription_id))
.expect("Subscription not found")
.expect("Subscription not found");

Self::check_and_resume_internal(&env, &mut sub);
sub
}

/// Get all subscription IDs for a user
Expand Down Expand Up @@ -527,6 +677,21 @@ impl SubTrackrContract {
.get(&DataKey::SubscriptionCount)
.unwrap_or(0)
}

// ── Internal Helpers ──

fn check_and_resume_internal(env: &Env, sub: &mut Subscription) -> bool {
if sub.status == SubscriptionStatus::Paused {
let now = env.ledger().timestamp();
if now >= sub.paused_at + sub.pause_duration {
sub.status = SubscriptionStatus::Active;
sub.paused_at = 0;
sub.pause_duration = 0;
return true;
}
}
false
}
}

#[cfg(test)]
Expand Down Expand Up @@ -721,6 +886,50 @@ mod test {
assert!(resumed.next_charge_at > initial.next_charge_at);
}

#[test]
#[should_panic(expected = "Pause duration exceeds limit")]
fn test_pause_by_subscriber_limit_enforced() {
let env = Env::default();
let (client, _admin, _merchant, subscriber, _token) = setup(&env);
let sub_id = client.subscribe(&subscriber, &1);

// Max is 30 days (2,592_000s). Try 31 days.
client.pause_by_subscriber(&subscriber, &sub_id, &2_678_400);
}

#[test]
fn test_auto_resume() {
let env = Env::default();
let (client, _admin, _merchant, subscriber, _token) = setup(&env);
let sub_id = client.subscribe(&subscriber, &1);

// Pause for 1 day (86,400s)
client.pause_by_subscriber(&subscriber, &sub_id, &86_400);
let paused = client.get_subscription(&sub_id);
assert_eq!(paused.status, SubscriptionStatus::Paused);

// Fast forward 2 days (172,800s)
env.ledger().with_mut(|li| {
li.timestamp += 172_800;
});

// get_subscription should now return Active due to auto-resume
let resumed = client.get_subscription(&sub_id);
assert_eq!(resumed.status, SubscriptionStatus::Active);
assert_eq!(resumed.paused_at, 0);
assert_eq!(resumed.pause_duration, 0);

// charge_subscription should also work now
// But we need to make sure next_charge_at is reached
env.ledger().with_mut(|li| {
li.timestamp += Interval::Monthly.seconds();
});
client.charge_subscription(&sub_id);

let charged = client.get_subscription(&sub_id);
assert_eq!(charged.total_paid, 500);
}

#[test]
fn test_refund_flow() {
let env = Env::default();
Expand Down
Loading
Loading