UPDATE: This is wrong. See more recent comment below. Specifically the part about upgrades and downgrades is wrong, though much of the pseudocode may still be useful. It describes a better way to represent premium plans and a useful way to track premium credit if we have other reasons to do that.
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:
You can jump on any plan with any payment frequency at any time. If you change your mind and upgrade or downgrade you wonât have wasted any money by doing so.
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.
(I think this partâs obvious but to spell it out again: By opting to pay for longer periods, like yearly or lifetime, youâre committing to having that plan (or higher) for that long. Thatâs the point of the slider â a discount in exchange for committing to a longer period. You can ask for a refund if the perks feel disappointing and weâll say yes.)
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.
Corollary: If lifetime Infinibee would be a no-brainer if there werenât those bigger, shinier plans off to the right, then first grab the lifetime Infinibee. Now any other plan will be $4/mo cheaper for you forever. You can always get the full amount back as a credit to apply to any other plans if you change your mind.
Ok, everyone but the uebernerds can stop reading now.
Algorithms and data structures and pseudocode, oh my!
Ooh, Discourse can mercifully hide gory details till you click here
(The first part of this, the general case for premium subscription credits, now lives at Premium Credits)
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 eachfreq
months)
The chamt
field is in parens because itâs cached and recomputable from the other fields like so:
chamt = nom * cmult * fmult(freq)
where the 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.)
Downgrades
[UPDATE: ignore this section]
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 daplan
with downto
and start charging for it immediately.
Upgrades
When an upgrade from daplan
to upto
happens you just add the value of the remainder of daplan
to the userâs credit before replacing daplan
with 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:
(Moved to Premium Credits)
And call it like this:
premiumcharge(u, daplan.chamt)
Philosophical and Implementation Notes
-
(Moved to Premium Credits)
-
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
fmult
function. -
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 given amount of credit:
# How long can you get a plan w/ 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()))
Scratch area
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
, i.e., 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]
(Note that fmult
takes 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.)
When upfrom
runs out then the user only has daplan
and we go back to charging simply nom * cmult * fmult(freq)
as stored in daplan
.
(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âŚ
If upfrom
and downto
are nil then just charge daplan.chamt
at time daplan.paidtil
.
(Else assert that exactly one of upfrom
and downto
are nil.)
If upfrom
then:
- charge the amount in [eq1] at time
daplan.paidtil
daplan.paidtil += daplan.freq*SIM
- if
daplan.paidtil >= upfrom.paidtil
then setupfrom = nil
If downto
then:
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!
UPDATE2: No, it was still wrong. The 1000-boxes algorithm is really for realsies right though!