Premium upgrades and downgrades


#1

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

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 R:

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

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:

# 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
    actually_charge_user(1)
    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:

premiumcharge(u, daplan.chamt)

Philosophical and Implementation Notes

  1. 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.

  2. 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.

  3. 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.

  4. Credit balances are only for premium payments, never pledges. No sting-dilution!

  5. 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.

  6. 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.

  7. Translogging: CRE for addcredit, PLA for up/downgrade (including free plan)

  8. 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?

  9. 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()))

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 set upfrom = 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!


Mega Feature List (and Premium Revamp Discussion)
#2

Wowzers!

:heart:

I sent an email on this very subject less than 12 hours ago, but I hope you didn’t write this all up just for me :open_mouth: In any case I’m going to trundle off and buy it.

Looks like the variable has two different names. Similarly for freq and n.

“actually charge is” ?

My gut instinct is that because you adopt the hiplan's frequency there ought to be values of nom and freq which lead to an upgrade path which makes everything beyond the cheapest lifetime plan free. But I’m too lazy / not brazen enough to actually see if the real values Beeminder offers permit such an exploit. :slight_smile:


#3

no, that was coincidence. :slight_smile: but thanks for asking good questions!

fixed!

fixed!

PS: I’m treating the above as a wiki and changing all sorts of things about it.


#4

UPDATE: This was all a big conversation with myself. I worked it all out and I’ve incorporated all the conclusions here into the wiki above. So no need to read this post anymore! I’ll use Discourse’s nifty hiding widget to hide it…

Conversation with myself, for you to click on if you're very bored

Update: To do this exquisitely right (which I’m now obsessed with doing) is getting really messy. I’m now taking @bee’s sage advice to design this with an explicit dollar amount that you get as a credit when you upgrade. (Otherwise it got unwieldy to deal with – and convey in the UI – cases like starting with yearly Infinibee, then upgrading to semiannual Bee Plus, then upgrading again to monthly Beemium. You have to store the whole chain to know the perfectly fair amount to charge for the plan you ended up upgrading to.)

So here’s the new plan for upgrades: Say you pay $135 for lifetime Infinibee and use it for 6 months and then decide to upgrade to monthly Bee Plus. That means your lifetime Infinibee disappears but you get a credit equal to the current value of what remains of your Infinibee plan. That’s always $135 no matter how long you’ve had it. Infinity is funny that way.

I think that’s even nicer/fairer – or in any case no less fair – than what I originally described, where you’d get $4/mo off of your new higher plan forever, that being the nominal value of the lower plan you’d already paid for. (See 1st note to self below.)

Now we just need the equation for how much credit you get when you have n months of a plan remaining: nom*fmult(n) or, using the 2-argument version of fmult:

nom * fmult(freq, (paidtil-now) / (freq/12*365.25*86400))

with now and paidtil being unixtimes. Note that if the frequency is lifetime then the credit for upgrading is a constant nom*fmult(1000).

Interestingly, 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.

Notes to self:

  1. Here’s a scenario where you could pay slightly more with the credit model than the original model of deducting the nominal amount of the lower plan you’re upgrading from. Buy lifetime Infinibee, then buy a month of Beemium and drop back to lifetime Infinibee. In the original model you’d pay $28 for that month. In the credit model you get a $135 credit when you upgrade which drops to $103 when you get your month of Beemium, and then you spend another $135 – $103 of which comes from your credit, so $32 actually spent – to get your lifetime Infinibee back again. So you’ve effectively paused your lifetime Infinibee to get a month of Beemium at the normal price. Which is pretty reasonable. To be ueberfair we could actually make your credit earn the same 3% per month interest that the discount slider uses. Or maybe a more reasonable interest rate would suffice? If we did go with the exorbitantly generous interest rate then, interestingly, 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!

  2. For downgrades you have to do the messier thing where you store both the new lower plan and the current higher plan because when you pay for N months of a plan there’s no downgrading before the N months are up. You’ve paid for it and you’re getting it. (The downgrade will happen automatically after N months.) We’ve made a big deal out of this for the trick of subscribing and immediately canceling as a way to buy a single month of higher-plan perks. … Or, maybe this should be revisited? As long as we’re doing credits it’d be pretty nice of us to just let you downgrade immediately and credit you with the prorated amount. So you could spend $4 for a month of Infinibee, quickly create some goals, and cancel it and have a $3.99 credit waiting around for you. That’s a pretty gaping loophole though, where you spend $4 one time and then just dip into it a penny at a time whenever you want more goals. On the other hand it’s the kind of loophole you have to be pretty price-sensitive to bother exploiting so maybe that’s ok?
    Conclusion: No, downgrading happens when the N months you paid for runs out.

  3. Special case for fmult(1000) i.e. fmult(infinity):
    exp(r) / (exp(r) - 1)

  4. Credit should be for premium plans only, not pledges. Especially if we use the exorbitantly generous interest rate.

  5. I think we need to always charge at least a dollar so when applying credit we’ll want to check if the amount to charge minus the credit is less than a dollar, in which case charge $1 and adjust the credit accordingly…

# charge user u $x, first applying as much credit as possible
premiumcharge(u, x) := 
  if u.credit >= x
    u.credit -= x  # and don't actually charge anything
  elsif x - credit < 1  # $1 being the least we can charge a credit card
    credit += 1 - x
    actually_charge_user(1)
  else  # wipe out the credit and actually charge the rest
    actually_charge_user(x - credit)
    credit = 0

#5

Language-wise, to be clear, it’s not a credit in the sense of a credit balance. It’s just us taking account of existing unexpired subscription at the point of changing plans.


#6

I think I want to implement it as a literal credit balance, just that it can only be used for future premium payments. You can’t withdraw it or use it to pay pledges.

(Btw, this is all in mass flux at the moment as I work out the right way to do it.)


#7

Implementation detail. Talking about credit balances sounds too much like holding client funds to me.

Also, exquisite fairness to one side, remember that somebody has to implement and maintain the code to run this scheme. If elegant simplicity falls out the other side of this flux, then I’m all for it.

What else could you obsess over that might improve Beeminder more than getting these edge cases perfectly and automatically right?


#8

Speaking of edge cases, I’m one of them, on a lifetime subscription to Plan Bee at the original price-point.

I don’t remember what I paid all that time ago, though I can guess, look up, or calculate it if I cared. It seemed like a good deal at the time, so I went for it.

Likewise, when I go to the premium page now, I get offered a price to change plans. It’s either a price that I like, or a price that I don’t, in relation to my perceived value for the feature difference and my qualitative level of love for the bee.

The actual numbers now vs the numbers then mostly don’t come into it. It’s either going to seem about right (or not) and feel fair (or not).


#9

Speaking of Exquisite Fairness, this is a very fair question! :slight_smile: But I think I just figured it out and it’s going to make things simultaneously simpler and better than the status quo. Also I feel honor-bound to make sure we’re following through after my talking up all the Exquisite Fairness. Also I got obsessed / nerd-sniped.


#10

How we’re handling this: We’ve already done the refactoring and update queries in the database to store the nominal amount of Plan Bee when you got it, plus any promotional discount you may have used (these were implicit but confusing before). You’ll now (ish) be able to upgrade and get the full original amount you paid as a credit (since you’ve used, technically, 0% so far of the infinite amount of Plan Bee you paid for). On your new fancier plan you’d just not pay anything until that credit was used up.

Grandfatheriness aside, if you were on lifetime Bee Plus and wanted to just try a month of the $32/mo Beemium plan you could do that by upgrading to monthly Beemium and immediately downgrading back to lifetime Bee Plus. That would cost exactly $32 because you’d use up $32 of your upgrade credit and then have to supplement $32 to get lifetime Bee Plus again.

That’s not quite as nice as originally where the lifetime plan is permanent and just reduces the cost of any upgrades. To make up for that, credit balances grow exponentially. As they should, per exquisite fairness.

Hopefully this will all be more intuitive in the UI than it is in prose!


#11

OK, so just to let you all know, the current implementation is not entirely broken or anything.

Here are things that work:

  • lifetime upgrades: if you upgrade to a higher plan after purchasing a lifetime plan at a lower level we reduce the monthly price of the higher plan by the nominal monthly price of the lifetime plan you purchased. So, e.g. with a lifetime infinibee plan, an upgrade to Bee Plus would use $12 (rather than $16) for the nominal monthly price. Then any coupons or slider discounts would be applied to that base price.

  • downgrades and frequency changes: we let the current plan run out, and then make the changes. So if you had purchased a month of Beemium, and then a week later downgraded to Infinibee, we’d wait until that month ran out, and then we’d drop you down to Infinibee and charge you whatever price / frequency you had purchased at.

  • upgrades: Upgrades happen instantaneously. We give you a discount on the first payment by prorating the remainder of the latest payment from your current plan. I.e. if you paid for $4 for 1 month of infinibee and then 3 weeks later upgrade, you’d get (4 * 1/4) = $1 discount on your first payment for the upgrade (which happens right then). Or if you paid $16 for one month of Bee Plus and then immediately upgrade to Beemium, you’d get (16 * 1) = $16 discount of your upgrade price.

Here is what is broken:

These are not perfectly mathematically general and all elegant and stuff, but they are, I believe, sufficiently Fair, except for one bug case, with upgrades. If your prorated discount is greater than your upgrade price, we discount the first payment to $0, but then you lose the remainder of that credit.

Here’s an example of that bug in action: you buy 4 months of BeePlus for $61 / 4 months, and then immediately upgrade to monthly Beemium, i.e. Beemium for $32 / 1 month. Then you’d get the first month for free, but you’d lose that extra $29 of discount credit.

So Danny’s spec fixes this, but the broken case is sufficiently rare at this point in time that there’s a much lazier immediate solution which is to blast off a big airhorn and throw an exception for ourselves when the discount > payment, and then we can manually intercede to make it fair.


#12

Dang, @bee, that was an impressive feat of yanking me out of that rabbit hole by the ears! Seriously, very elegant(ly expedient) stopgap. I’m still super enamored with my spec and want to implement it later [1] but thanks to your wisdom and pragmatism (I’m still being entirely serious!) there’s no longer any urgency to do it Right Now before we inadvertently cheat someone. I mean, relative to the Exquisite Fairness we’ve been crowing about. It’s all so insanely more fair and generous than any other company in existence [2] that it’s pretty funny to be quibbling at all.

But now we’ve got the best of all worlds!


[1] Also it eliminates so much cruft and spaghetti with special cases for lifetime plans and stuff.

[2] Well, I guess companies who are smart enough to not create rabbit holes like this in first place by following KISS from the start can be equally Exquisitely Fair just by not having any messy upgrade/downgrade/discount/frequency corner cases. (That discount slider is sooo sexy though…)


#13

(It just occurred to me that @bee’s solution here is an exquisite example of shirking and turking.)