@@ -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