Skip to content

Commit 0e228cb

Browse files
authored
Merge pull request #1080 from deedee-code/feature/mid-cycle-proration-engine
feat: implement mid-cycle proration engine
2 parents 23bf379 + 4508902 commit 0e228cb

6 files changed

Lines changed: 201 additions & 26 deletions

File tree

backend/services/billing/proration.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
getPeriodDays,
55
getRemainingDays,
66
previewProration as clientPreviewProration,
7+
calculateMidCycleProration,
78
generateCreditMemo as clientGenerateCreditMemo,
89
applyCreditMemo as clientApplyCreditMemo,
910
} from '../../../src/utils/proration';
@@ -122,7 +123,10 @@ export class ProrationService {
122123
}
123124
}
124125

125-
const preview = clientPreviewProration(subscription, newPrice, effectiveType);
126+
const preview =
127+
effectiveDate instanceof Date || effectiveType === 'immediate'
128+
? calculateMidCycleProration(subscription, newPrice, effectiveDate)
129+
: clientPreviewProration(subscription, newPrice, effectiveType);
126130

127131
if (config.method === 'hourly') {
128132
const hoursRemaining = preview.remainingDays * 24;

contracts/subscription/src/proration.rs

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,52 @@ pub fn preview_proration(
120120
calculate_proration(env, subscription, old_price, new_price, effective_date)
121121
}
122122

123+
/// Calculate a plan-change proration using the actual remaining time until the
124+
/// next charge, which is the exact mid-cycle behavior required by billing.
125+
pub fn calculate_mid_cycle_proration(
126+
env: &Env,
127+
subscription: &Subscription,
128+
old_price: i128,
129+
new_price: i128,
130+
effective_at: u64,
131+
) -> ProrationResult {
132+
let now = env.ledger().timestamp();
133+
let period_seconds = subscription
134+
.next_charge_at
135+
.saturating_sub(subscription.last_charged_at)
136+
.max(1);
137+
let period_days = period_seconds / 86400;
138+
let effective_ts = effective_at.max(now).min(subscription.next_charge_at);
139+
let remaining_seconds = subscription.next_charge_at.saturating_sub(effective_ts);
140+
let remaining_days = remaining_seconds / 86400;
141+
142+
let amount = if new_price == old_price || remaining_days == 0 {
143+
0
144+
} else {
145+
(new_price - old_price) * remaining_days as i128 / period_days as i128
146+
};
147+
148+
let is_credit = amount < 0;
149+
let abs_amount = amount.abs();
150+
let description = if is_credit {
151+
String::from_str(env, "Prorated credit for mid-cycle downgrade")
152+
} else if amount > 0 {
153+
String::from_str(env, "Prorated charge for mid-cycle upgrade")
154+
} else {
155+
String::from_str(env, "No proration required")
156+
};
157+
158+
ProrationResult {
159+
amount: abs_amount,
160+
remaining_days,
161+
period_days,
162+
old_daily_rate: old_price / period_days as i128,
163+
new_daily_rate: new_price / period_days as i128,
164+
is_credit,
165+
description,
166+
}
167+
}
168+
123169
/// Generate a credit memo for downgrade credits
124170
///
125171
/// Credit memos are stored on-chain and can be applied to future invoices

docs/subscription-proration-calculator.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,26 @@ If $\text{Net Adjustment} < 0$, the customer receives an account credit.
5959
5. **Proration API**: Server-side service (`ProrationApiService`) exposing REST endpoints for backend integration.
6060
6. **State Management & UI**: Persistent Zustand store (`useProrationStore`), React hook (`useProrationCalculator`), and React Native screen component (`ProrationCalculatorScreen`).
6161

62+
## Mid-cycle proration engine
63+
64+
When a customer changes plans before the next renewal date, the engine computes the adjustment from the exact number of remaining days in the active cycle:
65+
66+
$$
67+
\text{Adjustment} = \frac{(\text{newPrice} - \text{oldPrice}) \times \text{remainingDays}}{\text{periodDays}}
68+
$$
69+
70+
- If the result is positive, the customer is charged the difference immediately.
71+
- If the result is negative, a credit memo is created for the unused portion of the old plan.
72+
- If the change is scheduled for the end of the cycle, the adjustment is zero.
73+
74+
Example: a $30 plan changes to $60 when 15 of 30 days remain in the cycle.
75+
76+
$$
77+
\frac{(60 - 30) \times 15}{30} = 15
78+
$$
79+
80+
The customer is charged $15 immediately.
81+
6282
## Usage
6383

6484
### React Hook Example

pnpm-workspace.yaml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
allowBuilds:
2+
bufferutil: true
3+
detox: false
4+
dtrace-provider: false
5+
es5-ext: false
6+
keccak: false
7+
secp256k1: false
8+
unrs-resolver: false
9+
utf-8-validate: false
10+
web3: false
11+
web3-bzz: false
12+
web3-shh: false

src/utils/__tests__/proration.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
generateCreditMemo,
66
applyCreditMemo,
77
calculateNetProration,
8+
calculateMidCycleProration,
89
getPeriodDays,
910
getRemainingDays,
1011
} from '../proration';
@@ -136,4 +137,40 @@ describe('calculateNetProration', () => {
136137
]);
137138
expect(result.amount).toBe(0);
138139
});
140+
141+
it('computes a mid-cycle upgrade based on exact remaining days', () => {
142+
const sub = makeSub({
143+
price: 30,
144+
nextBillingDate: new Date(Date.now() + 15 * 24 * 60 * 60 * 1000),
145+
});
146+
147+
const result = calculateMidCycleProration(
148+
sub,
149+
60,
150+
new Date(Date.now() + 5 * 24 * 60 * 60 * 1000)
151+
);
152+
153+
expect(result.effectiveDate).toBe('immediate');
154+
expect(result.isCredit).toBe(false);
155+
expect(result.amount).toBeGreaterThan(0);
156+
expect(result.remainingDays).toBeGreaterThan(0);
157+
expect(result.periodDays).toBe(30);
158+
});
159+
160+
it('tracks a downgrade as a credit for the unused portion of the cycle', () => {
161+
const sub = makeSub({
162+
price: 60,
163+
nextBillingDate: new Date(Date.now() + 10 * 24 * 60 * 60 * 1000),
164+
});
165+
166+
const result = calculateMidCycleProration(
167+
sub,
168+
30,
169+
new Date(Date.now() + 3 * 24 * 60 * 60 * 1000)
170+
);
171+
172+
expect(result.isCredit).toBe(true);
173+
expect(result.amount).toBeGreaterThan(0);
174+
expect(result.description).toContain('credit');
175+
});
139176
});

src/utils/proration.ts

Lines changed: 81 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -46,51 +46,105 @@ export function getRemainingDays(subscription: Subscription): number {
4646
}
4747

4848
/**
49-
* Preview proration before confirming plan change
49+
* Resolve the effective proration date for a plan change.
5050
*
51-
* Formula: (newRate - oldRate) * remainingDays / periodDays
51+
* If a specific date is provided, only immediate changes that happen before the
52+
* next billing date are prorated. Future-dated changes at or after the next bill
53+
* are treated as end-of-period changes.
5254
*/
53-
export function previewProration(
55+
export function resolveProrationEffectiveDate(
56+
currentSubscription: Subscription,
57+
effectiveDate: 'immediate' | 'end_of_period' | Date = 'immediate'
58+
): 'immediate' | 'end_of_period' {
59+
if (effectiveDate === 'end_of_period') {
60+
return 'end_of_period';
61+
}
62+
63+
if (effectiveDate instanceof Date) {
64+
const nextBilling = new Date(currentSubscription.nextBillingDate);
65+
const now = new Date();
66+
if (
67+
effectiveDate.getTime() > now.getTime() &&
68+
effectiveDate.getTime() <= nextBilling.getTime()
69+
) {
70+
return 'immediate';
71+
}
72+
return 'end_of_period';
73+
}
74+
75+
return 'immediate';
76+
}
77+
78+
/**
79+
* Calculate a prorated adjustment against the exact days remaining in the cycle.
80+
* This is the explicit mid-cycle engine used for plan upgrades and downgrades.
81+
*/
82+
export function calculateMidCycleProration(
5483
currentSubscription: Subscription,
5584
newPrice: number,
56-
effectiveDate: 'immediate' | 'end_of_period' = 'immediate'
85+
effectiveDate: 'immediate' | 'end_of_period' | Date = 'immediate'
5786
): ProrationPreview {
87+
const resolvedEffectiveDate = resolveProrationEffectiveDate(currentSubscription, effectiveDate);
5888
const periodDays = getPeriodDays(currentSubscription.billingCycle);
59-
const remainingDays =
60-
effectiveDate === 'end_of_period' ? 0 : getRemainingDays(currentSubscription);
6189

62-
const oldRate = currentSubscription.price;
63-
const oldDailyRate = oldRate / periodDays;
64-
const newDailyRate = newPrice / periodDays;
90+
if (resolvedEffectiveDate === 'end_of_period' || currentSubscription.price === newPrice) {
91+
return {
92+
amount: 0,
93+
isCredit: false,
94+
remainingDays: 0,
95+
periodDays,
96+
oldDailyRate: Math.round((currentSubscription.price / periodDays) * 100) / 100,
97+
newDailyRate: Math.round((newPrice / periodDays) * 100) / 100,
98+
description: 'No proration required',
99+
effectiveDate: 'end_of_period',
100+
};
101+
}
65102

66-
const rawAmount =
67-
effectiveDate === 'end_of_period' ? 0 : ((newPrice - oldRate) * remainingDays) / periodDays;
103+
const now = new Date();
104+
const nextBilling = new Date(currentSubscription.nextBillingDate);
105+
const chosenDate = effectiveDate instanceof Date ? effectiveDate : now;
106+
const targetDate = new Date(
107+
Math.min(Math.max(chosenDate.getTime(), now.getTime()), nextBilling.getTime())
108+
);
109+
const remainingMs = Math.max(0, nextBilling.getTime() - targetDate.getTime());
110+
const remainingDays = Math.max(0, Math.ceil(remainingMs / (1000 * 60 * 60 * 24)));
68111

69-
// Round to 2 decimal places for currency
112+
const rawAmount = ((newPrice - currentSubscription.price) * remainingDays) / periodDays;
70113
const amount = Math.round(Math.abs(rawAmount) * 100) / 100;
71114
const isCredit = rawAmount < 0;
72115

73-
let description: string;
74-
if (amount === 0) {
75-
description = 'No proration required';
76-
} else if (isCredit) {
77-
description = `Prorated credit of ${amount} for plan downgrade (${remainingDays} days remaining)`;
78-
} else {
79-
description = `Prorated charge of ${amount} for plan upgrade (${remainingDays} days remaining)`;
80-
}
116+
const description =
117+
amount === 0
118+
? 'No proration required'
119+
: isCredit
120+
? `Prorated credit of ${amount} for plan downgrade (${remainingDays} days remaining)`
121+
: `Prorated charge of ${amount} for plan upgrade (${remainingDays} days remaining)`;
81122

82123
return {
83124
amount,
84125
isCredit,
85126
remainingDays,
86127
periodDays,
87-
oldDailyRate: Math.round(oldDailyRate * 100) / 100,
88-
newDailyRate: Math.round(newDailyRate * 100) / 100,
128+
oldDailyRate: Math.round((currentSubscription.price / periodDays) * 100) / 100,
129+
newDailyRate: Math.round((newPrice / periodDays) * 100) / 100,
89130
description,
90-
effectiveDate,
131+
effectiveDate: resolvedEffectiveDate,
91132
};
92133
}
93134

135+
/**
136+
* Preview proration before confirming plan change
137+
*
138+
* Formula: (newRate - oldRate) * remainingDays / periodDays
139+
*/
140+
export function previewProration(
141+
currentSubscription: Subscription,
142+
newPrice: number,
143+
effectiveDate: 'immediate' | 'end_of_period' = 'immediate'
144+
): ProrationPreview {
145+
return calculateMidCycleProration(currentSubscription, newPrice, effectiveDate);
146+
}
147+
94148
/**
95149
* Calculate immediate upgrade with prorated charge
96150
*/
@@ -175,14 +229,16 @@ export function calculateNetProration(
175229
}[]
176230
): ProrationPreview {
177231
let netAmount = 0;
232+
let remainingDays = getRemainingDays(currentSubscription);
178233

179234
for (const change of priceChanges) {
180-
const result = previewProration(
235+
const result = calculateMidCycleProration(
181236
{ ...currentSubscription, price: change.oldPrice },
182237
change.newPrice,
183238
change.effectiveDate
184239
);
185240
netAmount += result.isCredit ? -result.amount : result.amount;
241+
remainingDays = Math.max(remainingDays, result.remainingDays);
186242
}
187243

188244
const isCredit = netAmount < 0;
@@ -191,7 +247,7 @@ export function calculateNetProration(
191247
return {
192248
amount,
193249
isCredit,
194-
remainingDays: getRemainingDays(currentSubscription),
250+
remainingDays,
195251
periodDays: getPeriodDays(currentSubscription.billingCycle),
196252
oldDailyRate: 0,
197253
newDailyRate: 0,

0 commit comments

Comments
 (0)