This is a bit TMI but I’m sketching out the spec for how to make premium upgrades and downgrades exquisitely fair since we make a big deal about that and I figure I might as well do it it publicly in case y’all want to kibitz or just to have more eyes on it. But for almost everyone it suffices to know that, as we say in the fine print, we just do the opposite of what your phone company would do. The fundamental principle goes like this:
The only way to regret any premium purchase is to buy more than you actually wanted, like buying a year of Bee Plus when you only wanted a month of it. Or to buy Beemium when Bee Plus would’ve sufficed. Or buying lifetime and then dying within 7 years. (Technically you could replace “dying” with, say, solving your akrasia for good or something.) If any of those things happen, talk to us and we’ll make sure you (or your heirs) are happy.
But, mismatched expectations or poor planning aside, the point is that we want everything to be exquisitely fair automatically. With no need to talk to pesky humans. Or even nice humans like us.
For example, if you get yearly Infinibee and then downgrade to core Beeminder, you can do that immediately but you’ll still have Infinibee for a year until what you paid for runs out. Or if you get yearly Bee Plus and then immediately upgrade to monthly Beemium, that’s fine too.
You’ll just pay $16 less each month for Beemium because that’s the nominal monthly value of the Bee Plus you already paid for (even though you paid less by buying a year of it). After that year runs out you’ll automatically start paying the normal price for the plan you’re on. You’ll get a credit for the unused portion of your old plan. (If the old plan was lifetime, you’re credited the full amount you paid!) You’ll just draw from that credit when paying for the higher plan until it’s used up.
Ok, everyone but the uebernerds can stop reading now.
Ooh, Discourse can mercifully hide gory details till you click here
First some handy constants:
DIY = 365.25 # days in year
SID = 86400 # seconds in day
DIM = DIY/12 # days in month
SIM = DIM*SID # seconds in month
R = 0.03 # monthly interest rate aka discount rate for the discount slider
(The discount rate and some of the relevant math is discussed in our post on the exquisitely fair discount slider.)
Users have 2 new fields:
credX is a number of dollars from which to pay for premium plans before actually charging
credT is the timestamp that the credit was last adjusted
Whenever the credit is adjusted you compute the current up-to-the-second credit amount with the standard equation for continuously compounded interest:
credX * exp(R*(now-credT)/SIM)
Let’s make a handy function out of that to give the net present value as of time t2 of what was $x at time t, assuming our global monthly discount rate
npv(x, t, t2) := x * exp(R*(t2-t)/SIM)
So a user’s up-to-the-second amount of credit is
npv(credX, credT, now()). Then you adjust it (add new credit, subtract credit being used) and store the new amount in
credX and set
credT to the current timestamp.
We can make a function to add $x to user u’s credit like so:
# Add $x to user's credit balance at time t (default now)
addcred(u, x, t=nil) :=
if t==nil then t = now() # unixtime in seconds
# this works even if t happens to be earlier than u.credT -- mathemagic!
u.credX = npv(u.credX, u.credT, t) + x
u.credT = t
There are Plan objects that have the following fields:
name is one of: “core”, “infinibee”, (“beelite”), “beeplus”, (“planbee”), “beemium”
nom is the nominal monthly amount of the plan ($0 for core thru $32 for beemium)
freq is 1 for monthly, 6 for semiannually, 12 for yearly, 24 for biyearly, etc, and 1000 for lifetime
cmult is the coupon multiplier, like 0.90 for 10% off (default: 1)
paidtil is the timestamp of the next charge (updated as
+=freq*SIM when you pay)
chamt is the charge amount each
chamt field is in parens because it’s cached and recomputable from the other fields like so:
chamt = nom * cmult * fmult(freq)
fmult function is defined like so, taking the payment frequency as a number of months:
fmult(n) := (exp(R) - exp(R - n*R)) / (exp(R) - 1)
fmult(1000) := exp(R) / (exp(R) - 1) # limit as n goes to infinity
But, to make it easier to special-case that ugly infinity=1000 thing, we actually use this version with an optional second argument:
# Frequency multiplier for n months of a plan, with fraction p remaining
fmult(n, p=1) := (exp(R) - exp(R - n*p*R)) / (exp(R) - 1)
fmult(1000, p=1) := exp(R) / (exp(R) - 1) # limit as n -> infinity, p irrelevant
For example, if your payment frequency is every 2 months then
fmult(2) yields 1.97, slightly less than paying twice as much as the monthly amount every other month. If you were paying every 100 months,
fmult(100) yields 32 instead of 100, which is a 68% discount. Note that calling, say,
fmult(12, .5) is the same as
fmult(6) so that seems dumb to bother with the second argument. We do it so that when we call, say,
fmult(1000, .1) we can notice the special case that that’s actually lifetime, not paying every 100 months.
(Yes, we myopically are using “1000” instead of “infinity” for lifetime plans. But don’t worry, we are so on it! That’s 83 years and we have it on our calendar to reimplement Beeminder in a more mathy language that handles infinity nicer by 2096 August 12. Of course we already special-case it so what you pay for a lifetime plan is exactly the NPV of the perpetuity of the nominal amount monthly forever.)
Every user has a Plan object,
daplan (say it with a Chicago accent). If you downgrade then a second Plan object,
downto, is created for you which waits to take effect when
daplan runs out. We ignore
downto until it’s time to charge for
daplan again, at time
daplan.paidtil. Then we notice that there’s a
downto plan and we replace
downto and start charging for it immediately.
When an upgrade from
upto happens you just add the value of the remainder of
daplan to the user’s credit before replacing
upto. First define a function to compute the remaining value in a plan at time t:
valat(p, t) := p.nom * p.cmult * fmult(p.freq, (p.paidtil-t)/(p.freq*SIM))
And then effect the upgrade:
t = now()
addcred(u, valat(daplan, t), t)
daplan = upto
Whenever it’s time to actually charge the user, including immediately after doing the above for the upgrade, we use this:
# Charge user u $x, first applying as much credit as possible
premiumcharge(u, x) :=
t = now()
c = npv(u.credX, u.credT, t) # up-to-the-second credit amount
if c >= x
c -= x # and don't actually charge anything
elsif x - c < 1 # $1 being the least we can charge a credit card
c += 1 - x
else # wipe out the credit and actually charge the rest
actually_charge_user(x - c)
c = 0
u.credX = c
u.credT = t
And call it like this:
Philosophical and Implementation Notes
If credit is nonzero then display it in the UI as
npv(credX, credT, now()). If credit ever goes negative somehow then airhorn ourselves. Probably a bug.
The UI shouldn’t let you downgrade from a lifetime plan. For non-lifetime, the plan being downgraded to should say “Downgrading to this in __ days. [CANCEL]” and the current plan should indicate it’s the current plan like usual. Everything but the cancel button should be grayed out. You have to cancel the downgrade if you want to downgrade or upgrade to anything else.
Actually, I think we can simplify the implementation and the consistency and the user learningness by having no special case for lifetime except to say “infinity days” instead of the live countdown. So you can downgrade but it will end up being a no-op since it will never take effect and the user will understand that because of the “in infinity days” and because there’s no other allowed action than to cancel that. Elegant! And I think that means there’s no lifetime-related cruft in any of the code except for tweaks to some displayed strings in the UI and the limiting case in the
Credit balances are only for premium payments, never pledges. No sting-dilution!
Credit balances pay an exorbitantly generous interest rate – the same one that the discount slider uses, 3%/month or 36%/year. Interestingly (tee hee) you could, say, spend $541 for lifetime Bee Plus, upgrade to monthly Beemium and cancel it, leaving yourself a credit of $509 which would earn you $15/month in interest. (Compounding continuously, of course.) Which means you could re-add Bee Plus, paid every 7 months, and basically live off the interest, not touching the principal. Or maybe it doesn’t quite work because you lose a chunk of the principal by paying for 7 months at once. But it’s a pretty sweet deal regardless! But not, I think, so sweet that Beeminder’s leaving too much money on the table. To exploit any of this you have to have lots of faith in us and cough up a lot of money up front. Which is genuinely valuable to us. So it should all still be exquisitely fair.
Fun fact: If you got 7 years of Bee Plus, that would cost $498. If you waited 3.5 years and then upgraded, $388 of that $498 would be left as a credit. Why so much more than half? Because when you pay for 7 years up front, the second 3.5 years are more valuable than the first 3.5 years. Time-value of money. In general you always get back more than the simple arithmetic would suggest. Like if you buy a year and upgrade to something else after a month, you get back as credit slightly more than 11/12 of what you paid.
Translogging: CRE for addcredit, PLA for up/downgrade (including free plan)
Big UI problem mostly orthogonal to all this: reassuring the user that if they have a lifetime or yearly plan that it’s ok to upgrade to a higher plan at a lower frequency. Maybe: “X months free by applying credit from your current plan”. Without credits the idea is to just quote a cheaper price but people’s loss aversion kicks in and they’re still paranoid about losing what they have. With credits they actually do lose it but maybe the trade-in value, if made explicit, will be compelling? This wants some more thought with popup confirmations/explanations so that users can fearlessly be like “sure, I’ll try a bump to Beemium for a month”.
What if the upgrade button said something slightly cryptic but intriguing/non-scary like “apply magic upgrade credit” and that popped up a popup that spelled it all out, including how many months free you can get of the newer plan by applying the remaining credit from your current plan?
The X in “X months free” is calculated as follows. First define a function to compute how long you can get a new plan for with a give amount of credit:
# How long can you get a plan with nominal monthly cost $m for if you spend $x now?
upfree(m, x) :=
pow = m / (m*exp(R) - x*exp(R) + x)
if pow <= 0 then return 1000 # $x is enough for lifetime
return (R + log(pow)) / R # show with 2 sigfigs
That’s roughly the inverse of
fmult in that
fmult gives the cost of buying n months at once while
upfree gives the number of months you can get by spending a given amount of money. So now we can compute the X in “X months free”, given a nominal monthly cost m of a new plan and coupon multiplier (from the query string) c like so:
upfree(m*c, valat(daplan, now()))
How I previously spec'd this before deciding it was all wrong
If you upgrade then a new Plan is created and
daplan is set to it (upgrades take effect immediately) and your previous Plan is stored as
upfrom. It affects the price of your new plan until it runs out.
Seeming monkey wrench that’s not actually that bad: To do this version right,
upfrom needs to be a list of plans. When you upgrade you add your current plan to the list. When it’s time to charge for the new current plan (
daplan.paidtil == now), you first remove from the list any plans with paidtil in the past (
p.paidtil <= daplan.paidtil). Then you pick the maximal
p.nom*p.cmult*fmult(daplan.freq) over all the plans
p and reduce the payment for
daplan.chamt, by that amount.
If there were just one
upfrom plan then the amount to charge at time
daplan.paidtil would be, assuming
upfrom.paidtil > daplan.paidtil:
daplan.nom * daplan.cmult * fmult(daplan.freq) -
upfrom.nom * upfrom.cmult * fmult(daplan.freq) [eq1]
daplan.freq in both places. We use the new plan’s frequency to determine how much credit you get from the lower plan you’re upgrading from.)
upfrom runs out then the user only has
daplan and we go back to charging simply
nom * cmult * fmult(freq) as stored in
(It shouldn’t be possible for the amount to charge to be negative, as long as you never “upgrade” to a plan that’s cheaper. But the code should have an assertion and airhorn us just in case.)
How to actually decide what to charge when…
downto are nil then just charge
daplan.chamt at time
(Else assert that exactly one of
downto are nil.)
- charge the amount in [eq1] at time
daplan.paidtil += daplan.freq*SIM
daplan.paidtil >= upfrom.paidtil then set
upfrom = nil
Wait, yikes, this has gotten hairy to do Really Right. Consider the use case of getting yearly Infinibee, then upgrading to semiannual Plan Bee, then upgrading again to monthly Beemium. [There were other, less cornery cases we were getting wrong too but this is the one that convinced me that there was no patching the spaghetti and we had to rethink it.] We either have to convey in the UI that you can’t do that or else store a list of plans you’re upgrading from [not as bad as it seemed – I think this scratch area has the right spec for that now, but @bee says it’s still going to lead to spaghetti] so that while on Beemium you can be credited with the Bee Plus plan you’ve got for the first 6 months and then the Infinibee plan you have for the next 6 months.
I’m hashing this out on paper until I figure it out.
UPDATE: I’m pretty confident now in the credit-based version above!