From 450890221813624a111d525f53165ad98165c153 Mon Sep 17 00:00:00 2001 From: Deedee Date: Sat, 29 Aug 2026 03:18:47 +0100 Subject: [PATCH] feat: implement mid-cycle proration engine --- backend/services/billing/proration.ts | 6 +- contracts/subscription/src/proration.rs | 46 ++++++++++ docs/subscription-proration-calculator.md | 20 ++++ pnpm-workspace.yaml | 12 +++ src/utils/__tests__/proration.test.ts | 37 ++++++++ src/utils/proration.ts | 106 +++++++++++++++++----- 6 files changed, 201 insertions(+), 26 deletions(-) create mode 100644 pnpm-workspace.yaml diff --git a/backend/services/billing/proration.ts b/backend/services/billing/proration.ts index 6f72c0f0..3154ee10 100644 --- a/backend/services/billing/proration.ts +++ b/backend/services/billing/proration.ts @@ -4,6 +4,7 @@ import { getPeriodDays, getRemainingDays, previewProration as clientPreviewProration, + calculateMidCycleProration, generateCreditMemo as clientGenerateCreditMemo, applyCreditMemo as clientApplyCreditMemo, } from '../../../src/utils/proration'; @@ -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; diff --git a/contracts/subscription/src/proration.rs b/contracts/subscription/src/proration.rs index 8d5f9106..f34f11ce 100644 --- a/contracts/subscription/src/proration.rs +++ b/contracts/subscription/src/proration.rs @@ -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 diff --git a/docs/subscription-proration-calculator.md b/docs/subscription-proration-calculator.md index 57d346f4..174269ca 100644 --- a/docs/subscription-proration-calculator.md +++ b/docs/subscription-proration-calculator.md @@ -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 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 00000000..8e345152 --- /dev/null +++ b/pnpm-workspace.yaml @@ -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 diff --git a/src/utils/__tests__/proration.test.ts b/src/utils/__tests__/proration.test.ts index 0152a657..74dd1629 100644 --- a/src/utils/__tests__/proration.test.ts +++ b/src/utils/__tests__/proration.test.ts @@ -5,6 +5,7 @@ import { generateCreditMemo, applyCreditMemo, calculateNetProration, + calculateMidCycleProration, getPeriodDays, getRemainingDays, } from '../proration'; @@ -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'); + }); }); diff --git a/src/utils/proration.ts b/src/utils/proration.ts index ca5f9f0f..290eba06 100644 --- a/src/utils/proration.ts +++ b/src/utils/proration.ts @@ -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 */ @@ -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; @@ -191,7 +247,7 @@ export function calculateNetProration( return { amount, isCredit, - remainingDays: getRemainingDays(currentSubscription), + remainingDays, periodDays: getPeriodDays(currentSubscription.billingCycle), oldDailyRate: 0, newDailyRate: 0,