diff --git a/contracts/subscription/src/lib.rs b/contracts/subscription/src/lib.rs index 7a15210b..0d0f9ad0 100644 --- a/contracts/subscription/src/lib.rs +++ b/contracts/subscription/src/lib.rs @@ -146,6 +146,7 @@ fn check_and_resume_internal(env: &Env, sub: &mut Subscription) -> bool { let now = env.ledger().timestamp(); if now >= sub.paused_at + sub.pause_duration { sub.status = SubscriptionStatus::Active; + sub.next_charge_at = sub.next_charge_at.saturating_add(sub.pause_duration); sub.paused_at = 0; sub.pause_duration = 0; return true; @@ -937,11 +938,17 @@ impl SubTrackrSubscription { ); let now = env.ledger().timestamp(); - let plan: Plan = storage_persistent_get(&env, &storage, StorageKey::Plan(sub.plan_id)) - .expect("Plan not found"); + + let elapsed_pause = if sub.paused_at > 0 && now > sub.paused_at { + (now - sub.paused_at).min(sub.pause_duration) + } else { + 0 + }; sub.status = SubscriptionStatus::Active; - sub.next_charge_at = now + plan.interval.seconds(); + if elapsed_pause > 0 { + sub.next_charge_at = sub.next_charge_at.saturating_add(elapsed_pause); + } sub.paused_at = 0; sub.pause_duration = 0; diff --git a/docs/SUBSCRIPTION_PAUSE_RESUME.md b/docs/SUBSCRIPTION_PAUSE_RESUME.md new file mode 100644 index 00000000..2276c77d --- /dev/null +++ b/docs/SUBSCRIPTION_PAUSE_RESUME.md @@ -0,0 +1,65 @@ +# Subscription Pause/Resume with Billing Adjustment + +This document details the architecture, formulas, and integration patterns for subscription pause and resume functionality with billing adjustment in SubTrackr. + +## Overview + +SubTrackr allows subscribers to pause an active subscription for up to 30 days (`MAX_PAUSE_DURATION = 2,592,000` seconds). During the pause period: +- Automatic recurring billing is suspended. +- Unused prepaid service time is preserved. +- When the subscription is resumed (manually or via auto-resume expiration), the billing schedule (`next_charge_at` / `nextBillingDate`) is shifted forward by the actual elapsed pause duration. + +--- + +## Technical Architecture + +### 1. Smart Contract Implementation (`contracts/subscription/src/`) + +- **State Fields**: + - `status: SubscriptionStatus` (`Active`, `Paused`, `Cancelled`, `PastDue`) + - `paused_at: u64` (ledger timestamp when pause was requested) + - `pause_duration: u64` (requested pause duration in seconds) + - `next_charge_at: u64` (timestamp for next charge) + +- **Billing Adjustment Formula**: + $$\text{elapsed\_pause} = \min(\text{resume\_timestamp} - \text{paused\_at}, \text{pause\_duration})$$ + $$\text{adjusted\_next\_charge\_at} = \text{next\_charge\_at} + \text{elapsed\_pause}$$ + +- **Contract Methods**: + - `pause_subscription(env, proxy, storage, subscriber, subscription_id)`: Pauses subscription for default max duration (30 days). + - `pause_by_subscriber(env, proxy, storage, subscriber, subscription_id, duration)`: Pauses subscription for custom duration $\le 30$ days. + - `resume_subscription(env, proxy, storage, subscriber, subscription_id)`: Resumes subscription and adjusts `next_charge_at` by actual elapsed pause duration. + - `preview_pause_adjustment(env, proxy, storage, subscription_id, resume_timestamp)`: Previews billing adjustments, credit amount, and shifted next charge date. + +### 2. Frontend State Management (`src/store/subscriptionStore.ts`) + +- **Store Actions**: + - `pauseSubscription(id, durationDays)`: Sets `isPaused: true`, `isActive: false`, records `pausedAt`, `pauseDurationDays`, and `pausedUntil`. + - `resumeSubscription(id)`: Calculates elapsed pause duration, shifts `nextBillingDate` forward by `pauseMs`, and reactivates subscription. + - `previewPauseAdjustment(id, resumeDate)`: Returns `{ adjustedNextBillingDate, elapsedPauseDays, creditAmount }` preview. + +--- + +## Events & Auditing + +When a subscription is paused or resumed, contracts publish on-chain events: +- `("subscription_paused", subscriber)`: Payload `(subscription_id, paused_at, duration)` +- `("subscription_resumed", subscriber)`: Payload `subscription_id` + +--- + +## API & Store Usage Examples + +```typescript +import { useSubscriptionStore } from '../store/subscriptionStore'; + +// Pause a subscription for 14 days +await useSubscriptionStore.getState().pauseSubscription('sub_123', 14); + +// Preview billing adjustment before resuming +const preview = useSubscriptionStore.getState().previewPauseAdjustment('sub_123'); +console.log(`Adjusted next billing date: ${preview.adjustedNextBillingDate}`); + +// Resume subscription +await useSubscriptionStore.getState().resumeSubscription('sub_123'); +``` diff --git a/src/store/subscriptionStore.ts b/src/store/subscriptionStore.ts index 263048ec..4c74c24f 100644 --- a/src/store/subscriptionStore.ts +++ b/src/store/subscriptionStore.ts @@ -335,6 +335,9 @@ interface SubscriptionState { updateSubscription: (id: string, data: Partial) => Promise; deleteSubscription: (id: string) => Promise; toggleSubscriptionStatus: (id: string) => Promise; + pauseSubscription: (id: string, durationDays?: number) => Promise; + resumeSubscription: (id: string) => Promise; + previewPauseAdjustment: (id: string, resumeDate?: Date) => { adjustedNextBillingDate: Date; elapsedPauseDays: number; creditAmount: number }; // new actions added previewPlanChange: ( id: string, @@ -597,6 +600,119 @@ export const useSubscriptionStore = create()( } }, + pauseSubscription: async (id: string, durationDays: number = 30) => { + set({ isLoading: true, error: null }); + try { + const now = new Date(); + const pausedUntil = new Date(now.getTime() + durationDays * 86400 * 1000); + set((state) => ({ + subscriptions: state.subscriptions.map((sub) => + sub.id === id + ? { + ...sub, + isActive: false, + isPaused: true, + pausedAt: now, + pauseDurationDays: durationDays, + pausedUntil, + updatedAt: now, + } + : sub + ), + isLoading: false, + })); + + get().calculateStats(); + await syncRenewalReminders(get().subscriptions); + const updatedSubscription = get().subscriptions.find((sub) => sub.id === id); + if (updatedSubscription) { + await useCalendarStore.getState().syncSubscriptionToCalendars(updatedSubscription); + } + } catch (error) { + const appError = errorHandler.handleError(error as Error, { + action: 'pauseSubscription', + subscriptionId: id, + }); + set({ error: appError, isLoading: false }); + } + }, + + resumeSubscription: async (id: string) => { + set({ isLoading: true, error: null }); + try { + const sub = get().subscriptions.find((s) => s.id === id); + if (!sub) throw new Error('Subscription not found'); + + const now = new Date(); + let pauseMs = 0; + if (sub.pausedAt) { + const pausedAtTime = new Date(sub.pausedAt).getTime(); + const elapsed = now.getTime() - pausedAtTime; + const maxPauseMs = (sub.pauseDurationDays || 30) * 86400 * 1000; + pauseMs = Math.max(0, Math.min(elapsed, maxPauseMs)); + } + + const oldNextBilling = new Date(sub.nextBillingDate).getTime(); + const adjustedNextBillingDate = new Date(oldNextBilling + pauseMs); + + set((state) => ({ + subscriptions: state.subscriptions.map((s) => + s.id === id + ? { + ...s, + isActive: true, + isPaused: false, + pausedAt: undefined, + pauseDurationDays: undefined, + pausedUntil: undefined, + nextBillingDate: adjustedNextBillingDate, + updatedAt: now, + } + : s + ), + isLoading: false, + })); + + get().calculateStats(); + await syncRenewalReminders(get().subscriptions); + const updatedSubscription = get().subscriptions.find((s) => s.id === id); + if (updatedSubscription) { + await useCalendarStore.getState().syncSubscriptionToCalendars(updatedSubscription); + } + } catch (error) { + const appError = errorHandler.handleError(error as Error, { + action: 'resumeSubscription', + subscriptionId: id, + }); + set({ error: appError, isLoading: false }); + } + }, + + previewPauseAdjustment: (id: string, resumeDate?: Date) => { + const sub = get().subscriptions.find((s) => s.id === id); + if (!sub) throw new Error('Subscription not found'); + + const now = resumeDate || new Date(); + let pauseMs = 0; + if (sub.pausedAt) { + const pausedAtTime = new Date(sub.pausedAt).getTime(); + const elapsed = now.getTime() - pausedAtTime; + const maxPauseMs = (sub.pauseDurationDays || 30) * 86400 * 1000; + pauseMs = Math.max(0, Math.min(elapsed, maxPauseMs)); + } + + const elapsedPauseDays = Math.round(pauseMs / (86400 * 1000)); + const adjustedNextBillingDate = new Date(new Date(sub.nextBillingDate).getTime() + pauseMs); + const dailyRate = sub.price / 30; + const creditAmount = Math.round(dailyRate * elapsedPauseDays * 100) / 100; + + return { + adjustedNextBillingDate, + elapsedPauseDays, + creditAmount, + }; + }, + recordBillingOutcome: async (id: string, outcome: 'success' | 'failed') => { const sub = get().subscriptions.find((s) => s.id === id); if (!sub) return; diff --git a/src/types/subscription.ts b/src/types/subscription.ts index f213bb08..55604e95 100644 --- a/src/types/subscription.ts +++ b/src/types/subscription.ts @@ -8,6 +8,11 @@ export interface Subscription { billingCycle: BillingCycle; nextBillingDate: Date; isActive: boolean; + isPaused?: boolean; + pausedAt?: Date; + pauseDurationDays?: number; + pausedUntil?: Date; + billingAdjustmentAmount?: number; /** When false, skip renewal reminders and charge alerts for this subscription */ notificationsEnabled?: boolean; isCryptoEnabled: boolean;