Skip to content

Commit 70277d2

Browse files
Caleb PetersonCaleb Peterson
authored andcommitted
feat: add subscription pausing with duration limits, auto-resume, and refund mechanism
- Add pause_by_subscriber with configurable duration (max 30 days) - pause_subscription defaults to max pause duration - Auto-resume when pause duration expires (checked on charge/get) - Add request_refund, approve_refund, reject_refund flows - Add paused_at, pause_duration, refund_requested_amount fields to Subscription - Publish on-chain events for pause, resume, refund_requested, approved, rejected - Add comprehensive test coverage for all new functionality
1 parent ee39faa commit 70277d2

13 files changed

Lines changed: 14531 additions & 9 deletions

contracts/src/lib.rs

Lines changed: 250 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,14 @@ use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, String, Ve
66
#[contracttype]
77
#[derive(Clone, Debug, PartialEq)]
88
pub enum Interval {
9-
Weekly, // 604800s
10-
Monthly, // 2592000s (30 days)
11-
Quarterly, // 7776000s (90 days)
12-
Yearly, // 31536000s (365 days)
9+
Weekly, // 604800s
10+
Monthly, // 2592000s (30 days)
11+
Quarterly, // 7776000s (90 days)
12+
Yearly, // 31536000s (365 days)
1313
}
1414

15+
const MAX_PAUSE_DURATION: u64 = 2_592_000; // 30 days
16+
1517
impl Interval {
1618
pub fn seconds(&self) -> u64 {
1719
match self {
@@ -59,6 +61,9 @@ pub struct Subscription {
5961
pub last_charged_at: u64,
6062
pub next_charge_at: u64,
6163
pub total_paid: i128,
64+
pub paused_at: u64,
65+
pub pause_duration: u64,
66+
pub refund_requested_amount: i128,
6267
}
6368

6469
#[contracttype]
@@ -209,6 +214,9 @@ impl SubTrackrContract {
209214
last_charged_at: now,
210215
next_charge_at: now + plan.interval.seconds(),
211216
total_paid: 0,
217+
paused_at: 0,
218+
pause_duration: 0,
219+
refund_requested_amount: 0,
212220
};
213221

214222
env.storage()
@@ -280,6 +288,16 @@ impl SubTrackrContract {
280288

281289
/// User pauses their subscription
282290
pub fn pause_subscription(env: Env, subscriber: Address, subscription_id: u64) {
291+
Self::pause_by_subscriber(env, subscriber, subscription_id, MAX_PAUSE_DURATION);
292+
}
293+
294+
/// User pauses their subscription with a specific duration
295+
pub fn pause_by_subscriber(
296+
env: Env,
297+
subscriber: Address,
298+
subscription_id: u64,
299+
duration: u64,
300+
) {
283301
subscriber.require_auth();
284302

285303
let mut sub: Subscription = env
@@ -293,12 +311,24 @@ impl SubTrackrContract {
293311
sub.status == SubscriptionStatus::Active,
294312
"Only active subscriptions can be paused"
295313
);
314+
assert!(
315+
duration <= MAX_PAUSE_DURATION,
316+
"Pause duration exceeds limit"
317+
);
296318

297319
sub.status = SubscriptionStatus::Paused;
320+
sub.paused_at = env.ledger().timestamp();
321+
sub.pause_duration = duration;
298322

299323
env.storage()
300324
.persistent()
301325
.set(&DataKey::Subscription(subscription_id), &sub);
326+
327+
// Publish event
328+
env.events().publish(
329+
(String::from_str(&env, "subscription_paused"), subscriber),
330+
(subscription_id, sub.paused_at, duration),
331+
);
302332
}
303333

304334
/// User resumes a paused subscription
@@ -313,7 +343,8 @@ impl SubTrackrContract {
313343

314344
assert!(sub.subscriber == subscriber, "Only subscriber can resume");
315345
assert!(
316-
sub.status == SubscriptionStatus::Paused,
346+
sub.status == SubscriptionStatus::Paused
347+
|| Self::check_and_resume_internal(&env, &mut sub),
317348
"Only paused subscriptions can be resumed"
318349
);
319350

@@ -326,10 +357,18 @@ impl SubTrackrContract {
326357

327358
sub.status = SubscriptionStatus::Active;
328359
sub.next_charge_at = now + plan.interval.seconds();
360+
sub.paused_at = 0;
361+
sub.pause_duration = 0;
329362

330363
env.storage()
331364
.persistent()
332365
.set(&DataKey::Subscription(subscription_id), &sub);
366+
367+
// Publish event
368+
env.events().publish(
369+
(String::from_str(&env, "subscription_resumed"), subscriber),
370+
subscription_id,
371+
);
333372
}
334373

335374
// ── Payment Processing ──
@@ -342,6 +381,15 @@ impl SubTrackrContract {
342381
.get(&DataKey::Subscription(subscription_id))
343382
.expect("Subscription not found");
344383

384+
sub.subscriber.require_auth();
385+
386+
// Handle auto-resume if needed
387+
if Self::check_and_resume_internal(&env, &mut sub) {
388+
env.storage()
389+
.persistent()
390+
.set(&DataKey::Subscription(subscription_id), &sub);
391+
}
392+
345393
assert!(
346394
sub.status == SubscriptionStatus::Active,
347395
"Subscription not active"
@@ -370,6 +418,108 @@ impl SubTrackrContract {
370418
.set(&DataKey::Subscription(subscription_id), &sub);
371419
}
372420

421+
/// Request a refund for a subscription (can only be called by the subscriber)
422+
pub fn request_refund(env: Env, subscription_id: u64, amount: i128) {
423+
let mut sub: Subscription = env
424+
.storage()
425+
.persistent()
426+
.get(&DataKey::Subscription(subscription_id))
427+
.expect("Subscription not found");
428+
429+
sub.subscriber.require_auth();
430+
431+
assert!(amount > 0, "Refund amount must be positive");
432+
assert!(
433+
amount <= sub.total_paid,
434+
"Refund amount cannot exceed total paid"
435+
);
436+
437+
sub.refund_requested_amount = amount;
438+
439+
env.storage()
440+
.persistent()
441+
.set(&DataKey::Subscription(subscription_id), &sub);
442+
443+
// Publish event
444+
env.events().publish(
445+
(String::from_str(&env, "refund_requested"), subscription_id),
446+
(sub.subscriber.clone(), amount),
447+
);
448+
}
449+
450+
/// Approve a refund (can only be called by the admin)
451+
pub fn approve_refund(env: Env, subscription_id: u64) {
452+
let mut sub: Subscription = env
453+
.storage()
454+
.persistent()
455+
.get(&DataKey::Subscription(subscription_id))
456+
.expect("Subscription not found");
457+
458+
let admin: Address = env
459+
.storage()
460+
.instance()
461+
.get(&DataKey::Admin)
462+
.expect("Admin not set");
463+
admin.require_auth();
464+
465+
let amount = sub.refund_requested_amount;
466+
assert!(amount > 0, "No pending refund request");
467+
468+
let _plan: Plan = env
469+
.storage()
470+
.persistent()
471+
.get(&DataKey::Plan(sub.plan_id))
472+
.expect("Plan not found");
473+
474+
// TODO: Execute actual token transfer from merchant back to subscriber
475+
// token::Client::new(&env, &plan.token).transfer(
476+
// &plan.merchant, &sub.subscriber, &amount
477+
// );
478+
479+
sub.total_paid -= amount;
480+
sub.refund_requested_amount = 0;
481+
482+
env.storage()
483+
.persistent()
484+
.set(&DataKey::Subscription(subscription_id), &sub);
485+
486+
// Publish event
487+
env.events().publish(
488+
(String::from_str(&env, "refund_approved"), subscription_id),
489+
(sub.subscriber.clone(), amount),
490+
);
491+
}
492+
493+
/// Reject a refund (can only be called by the admin)
494+
pub fn reject_refund(env: Env, subscription_id: u64) {
495+
let mut sub: Subscription = env
496+
.storage()
497+
.persistent()
498+
.get(&DataKey::Subscription(subscription_id))
499+
.expect("Subscription not found");
500+
501+
let admin: Address = env
502+
.storage()
503+
.instance()
504+
.get(&DataKey::Admin)
505+
.expect("Admin not set");
506+
admin.require_auth();
507+
508+
assert!(sub.refund_requested_amount > 0, "No pending refund request");
509+
510+
sub.refund_requested_amount = 0;
511+
512+
env.storage()
513+
.persistent()
514+
.set(&DataKey::Subscription(subscription_id), &sub);
515+
516+
// Publish event
517+
env.events().publish(
518+
(String::from_str(&env, "refund_rejected"), subscription_id),
519+
sub.subscriber.clone(),
520+
);
521+
}
522+
373523
// ── Queries ──
374524

375525
/// Get plan details
@@ -382,10 +532,14 @@ impl SubTrackrContract {
382532

383533
/// Get subscription details
384534
pub fn get_subscription(env: Env, subscription_id: u64) -> Subscription {
385-
env.storage()
535+
let mut sub: Subscription = env
536+
.storage()
386537
.persistent()
387538
.get(&DataKey::Subscription(subscription_id))
388-
.expect("Subscription not found")
539+
.expect("Subscription not found");
540+
541+
Self::check_and_resume_internal(&env, &mut sub);
542+
sub
389543
}
390544

391545
/// Get all subscription IDs for a user
@@ -419,6 +573,21 @@ impl SubTrackrContract {
419573
.get(&DataKey::SubscriptionCount)
420574
.unwrap_or(0)
421575
}
576+
577+
// ── Internal Helpers ──
578+
579+
fn check_and_resume_internal(env: &Env, sub: &mut Subscription) -> bool {
580+
if sub.status == SubscriptionStatus::Paused {
581+
let now = env.ledger().timestamp();
582+
if now >= sub.paused_at + sub.pause_duration {
583+
sub.status = SubscriptionStatus::Active;
584+
sub.paused_at = 0;
585+
sub.pause_duration = 0;
586+
return true;
587+
}
588+
}
589+
false
590+
}
422591
}
423592

424593
#[cfg(test)]
@@ -453,7 +622,7 @@ mod test {
453622
fn test_create_plan_and_subscribe() {
454623
let env = Env::default();
455624
let contract_id = env.register_contract(None, SubTrackrContract);
456-
let client = SubTrackrContract::new(&env, &contract_id);
625+
let client = SubTrackrContractClient::new(&env, &contract_id);
457626

458627
let admin = Address::generate(&env);
459628
let merchant = Address::generate(&env);
@@ -590,7 +759,79 @@ mod test {
590759
client.resume_subscription(&subscriber, &sub_id);
591760
let resumed = client.get_subscription(&sub_id);
592761
assert_eq!(resumed.status, SubscriptionStatus::Active);
593-
assert_eq!(resumed.next_charge_at, env.ledger().timestamp() + Interval::Monthly.seconds());
762+
assert_eq!(
763+
resumed.next_charge_at,
764+
env.ledger().timestamp() + Interval::Monthly.seconds()
765+
);
594766
assert!(resumed.next_charge_at > initial.next_charge_at);
595767
}
768+
769+
#[test]
770+
#[should_panic(expected = "Pause duration exceeds limit")]
771+
fn test_pause_by_subscriber_limit_enforced() {
772+
let env = Env::default();
773+
let (client, _admin, _merchant, subscriber, _token) = setup(&env);
774+
let sub_id = client.subscribe(&subscriber, &1);
775+
776+
// Max is 30 days (2,592_000s). Try 31 days.
777+
client.pause_by_subscriber(&subscriber, &sub_id, &2_678_400);
778+
}
779+
780+
#[test]
781+
fn test_auto_resume() {
782+
let env = Env::default();
783+
let (client, _admin, _merchant, subscriber, _token) = setup(&env);
784+
let sub_id = client.subscribe(&subscriber, &1);
785+
786+
// Pause for 1 day (86,400s)
787+
client.pause_by_subscriber(&subscriber, &sub_id, &86_400);
788+
let paused = client.get_subscription(&sub_id);
789+
assert_eq!(paused.status, SubscriptionStatus::Paused);
790+
791+
// Fast forward 2 days (172,800s)
792+
env.ledger().with_mut(|li| {
793+
li.timestamp += 172_800;
794+
});
795+
796+
// get_subscription should now return Active due to auto-resume
797+
let resumed = client.get_subscription(&sub_id);
798+
assert_eq!(resumed.status, SubscriptionStatus::Active);
799+
assert_eq!(resumed.paused_at, 0);
800+
assert_eq!(resumed.pause_duration, 0);
801+
802+
// charge_subscription should also work now
803+
// But we need to make sure next_charge_at is reached
804+
env.ledger().with_mut(|li| {
805+
li.timestamp += Interval::Monthly.seconds();
806+
});
807+
client.charge_subscription(&sub_id);
808+
809+
let charged = client.get_subscription(&sub_id);
810+
assert_eq!(charged.total_paid, 500);
811+
}
812+
813+
#[test]
814+
fn test_refund_flow() {
815+
let env = Env::default();
816+
let (client, _admin, _merchant, subscriber, _token) = setup(&env);
817+
let sub_id = client.subscribe(&subscriber, &1);
818+
819+
// Charge the subscription at month 1
820+
env.ledger().set_timestamp(86_400 * 31);
821+
client.charge_subscription(&sub_id);
822+
823+
let sub = client.get_subscription(&sub_id);
824+
assert_eq!(sub.total_paid, 500);
825+
826+
// Request refund
827+
client.request_refund(&sub_id, &200);
828+
let sub = client.get_subscription(&sub_id);
829+
assert_eq!(sub.refund_requested_amount, 200);
830+
831+
// Approve refund
832+
client.approve_refund(&sub_id);
833+
let sub = client.get_subscription(&sub_id);
834+
assert_eq!(sub.total_paid, 300);
835+
assert_eq!(sub.refund_requested_amount, 0);
836+
}
596837
}

0 commit comments

Comments
 (0)