Skip to content
Open
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
6 changes: 5 additions & 1 deletion backend/services/billing/proration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
getPeriodDays,
getRemainingDays,
previewProration as clientPreviewProration,
calculateMidCycleProration,
generateCreditMemo as clientGenerateCreditMemo,
applyCreditMemo as clientApplyCreditMemo,
} from '../../../src/utils/proration';
Expand Down Expand Up @@ -122,7 +123,10 @@ export class ProrationService {
}
}

const preview = clientPreviewProration(subscription, newPrice, effectiveType);
const preview =
effectiveDate instanceof Date || effectiveType === 'immediate'
? calculateMidCycleProration(subscription, newPrice, effectiveDate)
: clientPreviewProration(subscription, newPrice, effectiveType);

if (config.method === 'hourly') {
const hoursRemaining = preview.remainingDays * 24;
Expand Down
46 changes: 46 additions & 0 deletions contracts/subscription/src/proration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,52 @@ pub fn preview_proration(
calculate_proration(env, subscription, old_price, new_price, effective_date)
}

/// Calculate a plan-change proration using the actual remaining time until the
/// next charge, which is the exact mid-cycle behavior required by billing.
pub fn calculate_mid_cycle_proration(
env: &Env,
subscription: &Subscription,
old_price: i128,
new_price: i128,
effective_at: u64,
) -> ProrationResult {
let now = env.ledger().timestamp();
let period_seconds = subscription
.next_charge_at
.saturating_sub(subscription.last_charged_at)
.max(1);
let period_days = period_seconds / 86400;
let effective_ts = effective_at.max(now).min(subscription.next_charge_at);
let remaining_seconds = subscription.next_charge_at.saturating_sub(effective_ts);
let remaining_days = remaining_seconds / 86400;

let amount = if new_price == old_price || remaining_days == 0 {
0
} else {
(new_price - old_price) * remaining_days as i128 / period_days as i128
};

let is_credit = amount < 0;
let abs_amount = amount.abs();
let description = if is_credit {
String::from_str(env, "Prorated credit for mid-cycle downgrade")
} else if amount > 0 {
String::from_str(env, "Prorated charge for mid-cycle upgrade")
} else {
String::from_str(env, "No proration required")
};

ProrationResult {
amount: abs_amount,
remaining_days,
period_days,
old_daily_rate: old_price / period_days as i128,
new_daily_rate: new_price / period_days as i128,
is_credit,
description,
}
}

/// Generate a credit memo for downgrade credits
///
/// Credit memos are stored on-chain and can be applied to future invoices
Expand Down
20 changes: 20 additions & 0 deletions docs/subscription-proration-calculator.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,26 @@ If $\text{Net Adjustment} < 0$, the customer receives an account credit.
5. **Proration API**: Server-side service (`ProrationApiService`) exposing REST endpoints for backend integration.
6. **State Management & UI**: Persistent Zustand store (`useProrationStore`), React hook (`useProrationCalculator`), and React Native screen component (`ProrationCalculatorScreen`).

## Mid-cycle proration engine

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:

$$
\text{Adjustment} = \frac{(\text{newPrice} - \text{oldPrice}) \times \text{remainingDays}}{\text{periodDays}}
$$

- If the result is positive, the customer is charged the difference immediately.
- If the result is negative, a credit memo is created for the unused portion of the old plan.
- If the change is scheduled for the end of the cycle, the adjustment is zero.

Example: a $30 plan changes to $60 when 15 of 30 days remain in the cycle.

$$
\frac{(60 - 30) \times 15}{30} = 15
$$

The customer is charged $15 immediately.

## Usage

### React Hook Example
Expand Down
12 changes: 12 additions & 0 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
allowBuilds:
bufferutil: true
detox: false
dtrace-provider: false
es5-ext: false
keccak: false
secp256k1: false
unrs-resolver: false
utf-8-validate: false
web3: false
web3-bzz: false
web3-shh: false
37 changes: 37 additions & 0 deletions src/utils/__tests__/proration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
generateCreditMemo,
applyCreditMemo,
calculateNetProration,
calculateMidCycleProration,
getPeriodDays,
getRemainingDays,
} from '../proration';
Expand Down Expand Up @@ -136,4 +137,40 @@ describe('calculateNetProration', () => {
]);
expect(result.amount).toBe(0);
});

it('computes a mid-cycle upgrade based on exact remaining days', () => {
const sub = makeSub({
price: 30,
nextBillingDate: new Date(Date.now() + 15 * 24 * 60 * 60 * 1000),
});

const result = calculateMidCycleProration(
sub,
60,
new Date(Date.now() + 5 * 24 * 60 * 60 * 1000)
);

expect(result.effectiveDate).toBe('immediate');
expect(result.isCredit).toBe(false);
expect(result.amount).toBeGreaterThan(0);
expect(result.remainingDays).toBeGreaterThan(0);
expect(result.periodDays).toBe(30);
});

it('tracks a downgrade as a credit for the unused portion of the cycle', () => {
const sub = makeSub({
price: 60,
nextBillingDate: new Date(Date.now() + 10 * 24 * 60 * 60 * 1000),
});

const result = calculateMidCycleProration(
sub,
30,
new Date(Date.now() + 3 * 24 * 60 * 60 * 1000)
);

expect(result.isCredit).toBe(true);
expect(result.amount).toBeGreaterThan(0);
expect(result.description).toContain('credit');
});
});
106 changes: 81 additions & 25 deletions src/utils/proration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,51 +46,105 @@ export function getRemainingDays(subscription: Subscription): number {
}

/**
* Preview proration before confirming plan change
* Resolve the effective proration date for a plan change.
*
* Formula: (newRate - oldRate) * remainingDays / periodDays
* If a specific date is provided, only immediate changes that happen before the
* next billing date are prorated. Future-dated changes at or after the next bill
* are treated as end-of-period changes.
*/
export function previewProration(
export function resolveProrationEffectiveDate(
currentSubscription: Subscription,
effectiveDate: 'immediate' | 'end_of_period' | Date = 'immediate'
): 'immediate' | 'end_of_period' {
if (effectiveDate === 'end_of_period') {
return 'end_of_period';
}

if (effectiveDate instanceof Date) {
const nextBilling = new Date(currentSubscription.nextBillingDate);
const now = new Date();
if (
effectiveDate.getTime() > now.getTime() &&
effectiveDate.getTime() <= nextBilling.getTime()
) {
return 'immediate';
}
return 'end_of_period';
}

return 'immediate';
}

/**
* Calculate a prorated adjustment against the exact days remaining in the cycle.
* This is the explicit mid-cycle engine used for plan upgrades and downgrades.
*/
export function calculateMidCycleProration(
currentSubscription: Subscription,
newPrice: number,
effectiveDate: 'immediate' | 'end_of_period' = 'immediate'
effectiveDate: 'immediate' | 'end_of_period' | Date = 'immediate'
): ProrationPreview {
const resolvedEffectiveDate = resolveProrationEffectiveDate(currentSubscription, effectiveDate);
const periodDays = getPeriodDays(currentSubscription.billingCycle);
const remainingDays =
effectiveDate === 'end_of_period' ? 0 : getRemainingDays(currentSubscription);

const oldRate = currentSubscription.price;
const oldDailyRate = oldRate / periodDays;
const newDailyRate = newPrice / periodDays;
if (resolvedEffectiveDate === 'end_of_period' || currentSubscription.price === newPrice) {
return {
amount: 0,
isCredit: false,
remainingDays: 0,
periodDays,
oldDailyRate: Math.round((currentSubscription.price / periodDays) * 100) / 100,
newDailyRate: Math.round((newPrice / periodDays) * 100) / 100,
description: 'No proration required',
effectiveDate: 'end_of_period',
};
}

const rawAmount =
effectiveDate === 'end_of_period' ? 0 : ((newPrice - oldRate) * remainingDays) / periodDays;
const now = new Date();
const nextBilling = new Date(currentSubscription.nextBillingDate);
const chosenDate = effectiveDate instanceof Date ? effectiveDate : now;
const targetDate = new Date(
Math.min(Math.max(chosenDate.getTime(), now.getTime()), nextBilling.getTime())
);
const remainingMs = Math.max(0, nextBilling.getTime() - targetDate.getTime());
const remainingDays = Math.max(0, Math.ceil(remainingMs / (1000 * 60 * 60 * 24)));

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

let description: string;
if (amount === 0) {
description = 'No proration required';
} else if (isCredit) {
description = `Prorated credit of ${amount} for plan downgrade (${remainingDays} days remaining)`;
} else {
description = `Prorated charge of ${amount} for plan upgrade (${remainingDays} days remaining)`;
}
const description =
amount === 0
? 'No proration required'
: isCredit
? `Prorated credit of ${amount} for plan downgrade (${remainingDays} days remaining)`
: `Prorated charge of ${amount} for plan upgrade (${remainingDays} days remaining)`;

return {
amount,
isCredit,
remainingDays,
periodDays,
oldDailyRate: Math.round(oldDailyRate * 100) / 100,
newDailyRate: Math.round(newDailyRate * 100) / 100,
oldDailyRate: Math.round((currentSubscription.price / periodDays) * 100) / 100,
newDailyRate: Math.round((newPrice / periodDays) * 100) / 100,
description,
effectiveDate,
effectiveDate: resolvedEffectiveDate,
};
}

/**
* Preview proration before confirming plan change
*
* Formula: (newRate - oldRate) * remainingDays / periodDays
*/
export function previewProration(
currentSubscription: Subscription,
newPrice: number,
effectiveDate: 'immediate' | 'end_of_period' = 'immediate'
): ProrationPreview {
return calculateMidCycleProration(currentSubscription, newPrice, effectiveDate);
}

/**
* Calculate immediate upgrade with prorated charge
*/
Expand Down Expand Up @@ -175,14 +229,16 @@ export function calculateNetProration(
}[]
): ProrationPreview {
let netAmount = 0;
let remainingDays = getRemainingDays(currentSubscription);

for (const change of priceChanges) {
const result = previewProration(
const result = calculateMidCycleProration(
{ ...currentSubscription, price: change.oldPrice },
change.newPrice,
change.effectiveDate
);
netAmount += result.isCredit ? -result.amount : result.amount;
remainingDays = Math.max(remainingDays, result.remainingDays);
}

const isCredit = netAmount < 0;
Expand All @@ -191,7 +247,7 @@ export function calculateNetProration(
return {
amount,
isCredit,
remainingDays: getRemainingDays(currentSubscription),
remainingDays,
periodDays: getPeriodDays(currentSubscription.billingCycle),
oldDailyRate: 0,
newDailyRate: 0,
Expand Down