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
13 changes: 10 additions & 3 deletions contracts/subscription/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;

Expand Down
65 changes: 65 additions & 0 deletions docs/SUBSCRIPTION_PAUSE_RESUME.md
Original file line number Diff line number Diff line change
@@ -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');
```
116 changes: 116 additions & 0 deletions src/store/subscriptionStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,9 @@ interface SubscriptionState {
updateSubscription: (id: string, data: Partial<Subscription>) => Promise<void>;
deleteSubscription: (id: string) => Promise<void>;
toggleSubscriptionStatus: (id: string) => Promise<void>;
pauseSubscription: (id: string, durationDays?: number) => Promise<void>;
resumeSubscription: (id: string) => Promise<void>;
previewPauseAdjustment: (id: string, resumeDate?: Date) => { adjustedNextBillingDate: Date; elapsedPauseDays: number; creditAmount: number };
// new actions added
previewPlanChange: (
id: string,
Expand Down Expand Up @@ -597,6 +600,119 @@ export const useSubscriptionStore = create<SubscriptionState>()(
}
},

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;
Expand Down
5 changes: 5 additions & 0 deletions src/types/subscription.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading