@@ -6,12 +6,14 @@ use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, String, Ve
66#[ contracttype]
77#[ derive( Clone , Debug , PartialEq ) ]
88pub 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+
1517impl 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