Proration done properly
Charging full price for a mid-cycle upgrade forfeits what the customer already paid. People notice once and never forgive it.
Somebody three days into a month upgrades their plan. What do you charge?
The lazy answer is the full new price. The customer has then paid twice for those three days and lost the remainder of what they bought. They notice, once, and they remember — and they tell people, because being quietly overcharged is a story worth telling.
The rule
Credit what is unused, charge the difference.
That is easy to say and has one subtlety that bites: what is the unused part worth?
The naive answer is the list price of the plan, prorated by days remaining. That is wrong, and it is wrong in a way that can be farmed.
Consider: somebody upgrades with a credit applied, so their new period cost them less than list. If you then value that period at *list* price when they downgrade, you hand them more credit than they put in. Do that in a loop — up, down, up, down — and you have invented money. Somebody will find it. Somebody always finds it.
What we do
A period is worth what was actually paid for it, plus any credit rolled into it. We store that figure on the subscription as period_value at the moment of purchase, and every later calculation reads it rather than recomputing from the price list.
Value is conserved across any chain of switches. You can hop between plans as much as you like and you can never end up with more credit than you have paid in, nor less than you are owed.
Single-use credit
The second trap is the same credit being spent twice — two tabs open, two upgrades confirmed, one period's worth of credit applied to both.
A quote carries a fingerprint of the period it was computed against. At the moment of charging, the fingerprint is recomputed from the live subscription. If it no longer matches, the period has already changed, the credit has already been spent, and the charge is refused rather than granting it again.
That check costs one comparison and closes a hole you would otherwise find in your revenue figures rather than your logs.
Show the number before you take it
Whatever your maths, the figure on the confirmation screen must be the figure that hits the card.
Ours comes from the same function on both paths. It did not always: the dialog once computed the price in JavaScript by multiplying out the interval, which was the same arithmetic the server did — right up until the server grew a rounding rule the client did not have. The dialog said $89.64. The card was charged $89.
We were lucky that it was small and in the customer's favour. The lesson stands: anywhere a number is shown and then acted on, there must be exactly one place it is computed. Not two that agree — one.