Skip to content

Commit 4f08a75

Browse files
committed
feat: build subscription pause and resume with billing adjustment (#961)
1 parent f6438a8 commit 4f08a75

5 files changed

Lines changed: 288 additions & 3 deletions

File tree

contracts/subscription/src/lib.rs

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,7 @@ fn check_and_resume_internal(env: &Env, sub: &mut Subscription) -> bool {
283283
let now = env.ledger().timestamp();
284284
if now >= sub.paused_at + sub.pause_duration {
285285
sub.status = SubscriptionStatus::Active;
286+
sub.next_charge_at = sub.next_charge_at.saturating_add(sub.pause_duration);
286287
sub.paused_at = 0;
287288
sub.pause_duration = 0;
288289
return true;
@@ -935,11 +936,17 @@ impl SubTrackrSubscription {
935936
);
936937

937938
let now = env.ledger().timestamp();
938-
let plan: Plan = storage_persistent_get(&env, &storage, StorageKey::Plan(sub.plan_id))
939-
.expect("Plan not found");
939+
940+
let elapsed_pause = if sub.paused_at > 0 && now > sub.paused_at {
941+
(now - sub.paused_at).min(sub.pause_duration)
942+
} else {
943+
0
944+
};
940945

941946
sub.status = SubscriptionStatus::Active;
942-
sub.next_charge_at = now + plan.interval.seconds();
947+
if elapsed_pause > 0 {
948+
sub.next_charge_at = sub.next_charge_at.saturating_add(elapsed_pause);
949+
}
943950
sub.paused_at = 0;
944951
sub.pause_duration = 0;
945952

@@ -1654,6 +1661,39 @@ pub fn preview_proration(
16541661
proration::preview_proration(&env, &sub, old_plan.price, new_plan.price, effective)
16551662
}
16561663

1664+
/// Preview pause adjustment before resuming or pausing a subscription
1665+
pub fn preview_pause_adjustment(
1666+
env: Env,
1667+
proxy: Address,
1668+
storage: Address,
1669+
subscription_id: u64,
1670+
resume_timestamp: u64,
1671+
) -> ProrationResult {
1672+
proxy.require_auth();
1673+
1674+
let sub: Subscription =
1675+
storage_persistent_get(&env, &storage, StorageKey::Subscription(subscription_id))
1676+
.expect("Subscription not found");
1677+
1678+
let plan: Plan = storage_persistent_get(&env, &storage, StorageKey::Plan(sub.plan_id))
1679+
.expect("Plan not found");
1680+
1681+
let adjustment = proration::calculate_pause_adjustment(&env, &sub, plan.price, resume_timestamp);
1682+
1683+
let period_days = (plan.interval.seconds() / 86400).max(1);
1684+
let old_daily_rate = plan.price / period_days as i128;
1685+
1686+
ProrationResult {
1687+
amount: adjustment.prorated_credit,
1688+
remaining_days: (adjustment.adjusted_next_charge_at.saturating_sub(resume_timestamp)) / 86400,
1689+
period_days,
1690+
old_daily_rate,
1691+
new_daily_rate: old_daily_rate,
1692+
is_credit: adjustment.prorated_credit > 0,
1693+
description: adjustment.description,
1694+
}
1695+
}
1696+
16571697
/// Execute a plan change with proration
16581698
pub fn change_plan(
16591699
env: Env,

contracts/subscription/src/proration.rs

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,4 +220,63 @@ pub fn is_zero_proration(result: &ProrationResult) -> bool {
220220
pub fn round_proration_amount(amount: i128, decimals: u32) -> i128 {
221221
let factor = 10i128.pow(decimals);
222222
(amount + factor / 2) / factor * factor
223+
}
224+
225+
/// Result of a pause/resume billing adjustment calculation
226+
#[derive(Clone, Debug)]
227+
pub struct PauseAdjustmentResult {
228+
/// The adjusted timestamp when the next charge will occur
229+
pub adjusted_next_charge_at: u64,
230+
/// Total duration in seconds the subscription was paused
231+
pub elapsed_pause_seconds: u64,
232+
/// Prorated credit amount credited for the paused time (if applicable)
233+
pub prorated_credit: i128,
234+
/// Description of the adjustment
235+
pub description: String,
236+
}
237+
238+
/// Calculate billing adjustment for pause and resume
239+
///
240+
/// Returns `PauseAdjustmentResult` containing the shifted `next_charge_at`
241+
/// and prorated credit details.
242+
pub fn calculate_pause_adjustment(
243+
env: &Env,
244+
subscription: &Subscription,
245+
plan_price: i128,
246+
resume_timestamp: u64,
247+
) -> PauseAdjustmentResult {
248+
let paused_at = subscription.paused_at;
249+
let max_duration = subscription.pause_duration;
250+
251+
// Effective pause time cannot exceed max pause duration or start before paused_at
252+
let raw_elapsed = if resume_timestamp > paused_at && paused_at > 0 {
253+
resume_timestamp - paused_at
254+
} else {
255+
0
256+
};
257+
258+
let elapsed_pause_seconds = if max_duration > 0 {
259+
raw_elapsed.min(max_duration)
260+
} else {
261+
raw_elapsed
262+
};
263+
264+
let adjusted_next_charge_at = subscription.next_charge_at.saturating_add(elapsed_pause_seconds);
265+
266+
// Prorated credit value of paused time based on subscription period duration
267+
let period_seconds = subscription.next_charge_at.saturating_sub(subscription.last_charged_at);
268+
let prorated_credit = if period_seconds > 0 && plan_price > 0 {
269+
(plan_price * elapsed_pause_seconds as i128) / period_seconds as i128
270+
} else {
271+
0
272+
};
273+
274+
let description = String::from_str(env, "Billing cycle adjusted for subscription pause duration");
275+
276+
PauseAdjustmentResult {
277+
adjusted_next_charge_at,
278+
elapsed_pause_seconds,
279+
prorated_credit,
280+
description,
281+
}
223282
}

docs/SUBSCRIPTION_PAUSE_RESUME.md

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
# Subscription Pause/Resume with Billing Adjustment
2+
3+
This document details the architecture, formulas, and integration patterns for subscription pause and resume functionality with billing adjustment in SubTrackr.
4+
5+
## Overview
6+
7+
SubTrackr allows subscribers to pause an active subscription for up to 30 days (`MAX_PAUSE_DURATION = 2,592,000` seconds). During the pause period:
8+
- Automatic recurring billing is suspended.
9+
- Unused prepaid service time is preserved.
10+
- 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.
11+
12+
---
13+
14+
## Technical Architecture
15+
16+
### 1. Smart Contract Implementation (`contracts/subscription/src/`)
17+
18+
- **State Fields**:
19+
- `status: SubscriptionStatus` (`Active`, `Paused`, `Cancelled`, `PastDue`)
20+
- `paused_at: u64` (ledger timestamp when pause was requested)
21+
- `pause_duration: u64` (requested pause duration in seconds)
22+
- `next_charge_at: u64` (timestamp for next charge)
23+
24+
- **Billing Adjustment Formula**:
25+
$$\text{elapsed\_pause} = \min(\text{resume\_timestamp} - \text{paused\_at}, \text{pause\_duration})$$
26+
$$\text{adjusted\_next\_charge\_at} = \text{next\_charge\_at} + \text{elapsed\_pause}$$
27+
28+
- **Contract Methods**:
29+
- `pause_subscription(env, proxy, storage, subscriber, subscription_id)`: Pauses subscription for default max duration (30 days).
30+
- `pause_by_subscriber(env, proxy, storage, subscriber, subscription_id, duration)`: Pauses subscription for custom duration $\le 30$ days.
31+
- `resume_subscription(env, proxy, storage, subscriber, subscription_id)`: Resumes subscription and adjusts `next_charge_at` by actual elapsed pause duration.
32+
- `preview_pause_adjustment(env, proxy, storage, subscription_id, resume_timestamp)`: Previews billing adjustments, credit amount, and shifted next charge date.
33+
34+
### 2. Frontend State Management (`src/store/subscriptionStore.ts`)
35+
36+
- **Store Actions**:
37+
- `pauseSubscription(id, durationDays)`: Sets `isPaused: true`, `isActive: false`, records `pausedAt`, `pauseDurationDays`, and `pausedUntil`.
38+
- `resumeSubscription(id)`: Calculates elapsed pause duration, shifts `nextBillingDate` forward by `pauseMs`, and reactivates subscription.
39+
- `previewPauseAdjustment(id, resumeDate)`: Returns `{ adjustedNextBillingDate, elapsedPauseDays, creditAmount }` preview.
40+
41+
---
42+
43+
## Events & Auditing
44+
45+
When a subscription is paused or resumed, contracts publish on-chain events:
46+
- `("subscription_paused", subscriber)`: Payload `(subscription_id, paused_at, duration)`
47+
- `("subscription_resumed", subscriber)`: Payload `subscription_id`
48+
49+
---
50+
51+
## API & Store Usage Examples
52+
53+
```typescript
54+
import { useSubscriptionStore } from '../store/subscriptionStore';
55+
56+
// Pause a subscription for 14 days
57+
await useSubscriptionStore.getState().pauseSubscription('sub_123', 14);
58+
59+
// Preview billing adjustment before resuming
60+
const preview = useSubscriptionStore.getState().previewPauseAdjustment('sub_123');
61+
console.log(`Adjusted next billing date: ${preview.adjustedNextBillingDate}`);
62+
63+
// Resume subscription
64+
await useSubscriptionStore.getState().resumeSubscription('sub_123');
65+
```

src/store/subscriptionStore.ts

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,9 @@ interface SubscriptionState {
168168
updateSubscription: (id: string, data: Partial<Subscription>) => Promise<void>;
169169
deleteSubscription: (id: string) => Promise<void>;
170170
toggleSubscriptionStatus: (id: string) => Promise<void>;
171+
pauseSubscription: (id: string, durationDays?: number) => Promise<void>;
172+
resumeSubscription: (id: string) => Promise<void>;
173+
previewPauseAdjustment: (id: string, resumeDate?: Date) => { adjustedNextBillingDate: Date; elapsedPauseDays: number; creditAmount: number };
171174
// new actions added
172175
previewPlanChange: (id: string, newPrice: number, effectiveDate: 'immediate' | 'end_of_period') => ProrationPreview;
173176
executePlanChange: (id: string, newPlanData: Partial<Subscription>, effectiveDate: 'immediate' | 'end_of_period') => Promise<void>;
@@ -395,6 +398,119 @@ export const useSubscriptionStore = create<SubscriptionState>()(
395398
}
396399
},
397400

401+
pauseSubscription: async (id: string, durationDays: number = 30) => {
402+
set({ isLoading: true, error: null });
403+
try {
404+
const now = new Date();
405+
const pausedUntil = new Date(now.getTime() + durationDays * 86400 * 1000);
406+
set((state) => ({
407+
subscriptions: state.subscriptions.map((sub) =>
408+
sub.id === id
409+
? {
410+
...sub,
411+
isActive: false,
412+
isPaused: true,
413+
pausedAt: now,
414+
pauseDurationDays: durationDays,
415+
pausedUntil,
416+
updatedAt: now,
417+
}
418+
: sub
419+
),
420+
isLoading: false,
421+
}));
422+
423+
get().calculateStats();
424+
await syncRenewalReminders(get().subscriptions);
425+
const updatedSubscription = get().subscriptions.find((sub) => sub.id === id);
426+
if (updatedSubscription) {
427+
await useCalendarStore.getState().syncSubscriptionToCalendars(updatedSubscription);
428+
}
429+
} catch (error) {
430+
const appError = errorHandler.handleError(error as Error, {
431+
action: 'pauseSubscription',
432+
subscriptionId: id,
433+
});
434+
set({ error: appError, isLoading: false });
435+
}
436+
},
437+
438+
resumeSubscription: async (id: string) => {
439+
set({ isLoading: true, error: null });
440+
try {
441+
const sub = get().subscriptions.find((s) => s.id === id);
442+
if (!sub) throw new Error('Subscription not found');
443+
444+
const now = new Date();
445+
let pauseMs = 0;
446+
if (sub.pausedAt) {
447+
const pausedAtTime = new Date(sub.pausedAt).getTime();
448+
const elapsed = now.getTime() - pausedAtTime;
449+
const maxPauseMs = (sub.pauseDurationDays || 30) * 86400 * 1000;
450+
pauseMs = Math.max(0, Math.min(elapsed, maxPauseMs));
451+
}
452+
453+
const oldNextBilling = new Date(sub.nextBillingDate).getTime();
454+
const adjustedNextBillingDate = new Date(oldNextBilling + pauseMs);
455+
456+
set((state) => ({
457+
subscriptions: state.subscriptions.map((s) =>
458+
s.id === id
459+
? {
460+
...s,
461+
isActive: true,
462+
isPaused: false,
463+
pausedAt: undefined,
464+
pauseDurationDays: undefined,
465+
pausedUntil: undefined,
466+
nextBillingDate: adjustedNextBillingDate,
467+
updatedAt: now,
468+
}
469+
: s
470+
),
471+
isLoading: false,
472+
}));
473+
474+
get().calculateStats();
475+
await syncRenewalReminders(get().subscriptions);
476+
const updatedSubscription = get().subscriptions.find((s) => s.id === id);
477+
if (updatedSubscription) {
478+
await useCalendarStore.getState().syncSubscriptionToCalendars(updatedSubscription);
479+
}
480+
} catch (error) {
481+
const appError = errorHandler.handleError(error as Error, {
482+
action: 'resumeSubscription',
483+
subscriptionId: id,
484+
});
485+
set({ error: appError, isLoading: false });
486+
}
487+
},
488+
489+
previewPauseAdjustment: (id: string, resumeDate?: Date) => {
490+
const sub = get().subscriptions.find((s) => s.id === id);
491+
if (!sub) throw new Error('Subscription not found');
492+
493+
const now = resumeDate || new Date();
494+
let pauseMs = 0;
495+
if (sub.pausedAt) {
496+
const pausedAtTime = new Date(sub.pausedAt).getTime();
497+
const elapsed = now.getTime() - pausedAtTime;
498+
const maxPauseMs = (sub.pauseDurationDays || 30) * 86400 * 1000;
499+
pauseMs = Math.max(0, Math.min(elapsed, maxPauseMs));
500+
}
501+
502+
const elapsedPauseDays = Math.round(pauseMs / (86400 * 1000));
503+
const adjustedNextBillingDate = new Date(new Date(sub.nextBillingDate).getTime() + pauseMs);
504+
const dailyRate = sub.price / 30;
505+
const creditAmount = Math.round(dailyRate * elapsedPauseDays * 100) / 100;
506+
507+
return {
508+
adjustedNextBillingDate,
509+
elapsedPauseDays,
510+
creditAmount,
511+
};
512+
},
513+
398514
recordBillingOutcome: async (id: string, outcome: 'success' | 'failed') => {
399515
const sub = get().subscriptions.find((s) => s.id === id);
400516
if (!sub) return;

src/types/subscription.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,11 @@ export interface Subscription {
88
billingCycle: BillingCycle;
99
nextBillingDate: Date;
1010
isActive: boolean;
11+
isPaused?: boolean;
12+
pausedAt?: Date;
13+
pauseDurationDays?: number;
14+
pausedUntil?: Date;
15+
billingAdjustmentAmount?: number;
1116
/** When false, skip renewal reminders and charge alerts for this subscription */
1217
notificationsEnabled?: boolean;
1318
isCryptoEnabled: boolean;

0 commit comments

Comments
 (0)