Premium Credits

By: dreev and bee
Spec level: STATUS: LIVE IN PRODUCTION
Last updated: 2021-02-04
Gissue: #1170 (internal link)

In our most recent post on dogfood bounties, “Dogfood Binge”, we promised that you’ll be able to cash in on workerbees’ derailments via premium subscription time, among other ways. Premium credit is obviously the most efficient from Beeminder’s point of view, so let’s make it be true!


 

*drum roll*


 

Being us, we’ve done this the exquisitely correct mathy way. (Also we’d already done all the math and implemented this for the exquisitely fair discount slider and so it was actually barely harder to do it fully correctly with proper time-discounting.) That means, yes, when we give you premium credit to be redeemed the next time you have a premium payment due, you literally earn interest on your balance. How much interest is a parameter. Tentatively we picked 2%/year but Mary is the decider on these things and may choose to set it to anything, including 0%. (Small technical thing: if we change the interest rate we need to update everyone’s balances at the moment we change it. That will make sense when we get to the technical parts below.)

This is also useful for promotions or coupons or referral programs. Like if we want to be like “listeners of this podcast can get a month of free premium by going to such-and-such link” this is a much nicer way to do that. There’s additional discussion of use cases in Section 10.

OK, here’s the buzziness logic! To improve the exposition we generally refer to an arbitrary user as “you”.

1. Constants and Database Fields

These constants are handy. If we were really ridiculous we might allow the interest rate (AKA discount rate) to be customized per user, but we’re just normal-ridiculous. So the monthly interest rate is one of the global constants.

DIY = 365.25   # days in year
SID = 86400    # seconds in day
DIM = DIY/12   # days in month
SIM = DIM*SID  # seconds in month
# NB: if we change the interest rate we must simultaneously update all balances!
R   = 0.02/12  # monthly interest rate (x% per year = x/12 % per month)

There are three new fields in the User table:

2. The Math of Time-Discounting

We compute your 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 t) of what was $x at time t0, assuming our global monthly discount rate R:

# Net Present Value at time t (epoch time in seconds) of what was $x at time t0
npv(x, t0, t) := x * exp(R*(t-t0)/SIM)

So your up-to-the-second amount of credit is npv(credX, credT, now()) where now() gives the current epoch time (unixtime) in seconds. Let’s make it even handier with a version of npv() that just takes a user as a parameter along with an optional time, defaulting to now:

# Net Present Value of user u's balance at time t (epoch time in seconds)
npv(u, t=nil) := 
  if t==nil then t = now()
  return u.credX * exp(R*(t-u.credT)/SIM)

3. Adjusting Your Credit Balance

So user u’s up-to-the-second amount of credit is simply npv(u). (Again, your credit will be very slightly more — or less, if it’s negative — every time we ask for it, as interest continuously accrues.) When we show it to you, or to ourselves in our admin interface, that’s what we use. To adjust your credit, we first use npv(u) for what your credit is at the current moment, then do the adjustment: Add or subtract from the up-to-the-second credit balance, store the new amount in credX, and set credT to the current timestamp.

Here’s the pseudocode for that, a function to add $x (positive or negative) to a user’s credit balance:

# Add $x to user u's credit balance at time t, return new balance.
# The last parameter is a string giving the reason for the credit adjustment.
# This works whether x is positive or negative and even if t happens to be 
# earlier than u.credT -- mathemagic!
iou(t, x, u, reason) := 
  u.credX = npv(u, t) + x
  u.credT = t
  translog("IOU #{t} #{x} bmndr #{u.username} #{reason} [bal=#{u.credX}]")
  credLog << ["IOU", t, x, "bmndr", u.username, reason, u.credX]
  return u.credX

(Note how we translog every change to the user’s credit balance in standard ledger format — now also part of the translog documentation — so there’s a full, if inconvenient, audit trail.)

4. Charging You Money

Your balance will never go negative

This is the part where we take your money. In the real world, not just as IOUs. Dollar-signs-in-eyes emoji! Per our payment processors — Stripe and PayPal — we can’t charge you less than $1. And in fact, for accounting/auditing purposes, we always charge that minimum amount even if your credit balance is enough to fully cover the charge. That can be weird but bear with us. For example, if you have $100 of credit, when it’s time to charge you your $16 for Bee Plus or whatever, we actually for-real charge your credit card $1 and dip into your credit for the remaining $15. That way there’s a full paper trail for every charge.

(There are technical reasons too, like how a Charge object in our database has a $1 minimum. They might not be good technical reasons but the accounting reasons make that moot.)

(Also one Beemindery reason: By charging a token amount periodically, we ensure that your payment method still works and that Beeminder is still a credible threat.)

To nail that down, let’s start with a function that takes an amount, $x, that we want to charge you, and decides, based on your credit balance, how much to actually charge to your credit card. Also we’ll generalize the $1 minimum as a parameter m.

# Given that user u owes us $x at time t, with minimum charge amount $m, how 
# much will we actually charge their credit card? It will always be at least $m!
# (Also we round this to the nearest penny since we can't charge fractional
# cents. We assume m doesn't need rounding.)
splitch(t, x, u, m) := return max(m, round(x - npv(u, t), 0.01))

So if we let a = splitch(t, x, u, m) be the amount we charge your credit card when you owe $x then $a - $x is the remainder — the amount we’ll debit your account by. Reassuringly, $a - $x mathematically can’t be greater than your balance so your balance will never go negative.

(Proof: Let c be your initial credit, x the amount owed, and a = max(m, x - c) the amount to charge. The delta on the credit balance is a - x = max(m, x - c) - x. If m > x - c then there exists a positive d such that m = x - c + d. Which means the delta is x - c + d - x = d - c and the final balance is c + d - c = d > 0. If instead m <= x - c then the delta is a - x = x - c - x = -c and the final balance is 0. QED. But, you know what, it’s totally fine to just trust us on this. Or stare at it a little and you’ll probably find a much more intuitive way to convince yourself that this is all fine.)

To be clear, it’s specifically using the splitch() function (which stands for “split charge”, btw) for how much to charge that won’t let your premium credit balance go negative. If we explicitly go into the admin interface — see Section 9 — and add a negative amount to your balance, then it could go negative. More on that in Section 6.

4.1 The Heart of the Business Logic

Here’s a stylized version of how we use this:

x = subscription_amt - discount   # or whatever, how much you owe us
t = now()                         # do this once so it's all atomic/simultaneous
b = npv(u, t)                     # initial credit balance (new balance'll be c)
a = splitch(t, x, u, m)           # amt to actually charge your credit card
actually_charge_user(a)
c = iou(t, a-x, u, "user owed us #{x} and we charged their credit card #{a}")
email += "using #{b-c} of your #{b} credit, charging #{a} to your credit card"

Notice how we pass the desired delta on your balance to the iou() function. (We say “delta” because it will be a negative amount if we’re debiting your balance and positive if we’re adding to your balance.) Then we tell you that the negative of that delta is how much of your credit we’re using. We compute that delta as your new balance minus your old balance — c-b — and in the production code we add an assertion that c-b is within epsilon of a-x. Mathematically they’ll be exactly equal but in doing c-b we’re adding and then re-subtracting npv(u, t), so, y’know, floating-pointiness. It’s a nice sanity check to make sure they’re very close to equal.

Next, there are two details we’re glossing over in generating the IOU and constructing the string for the user about how much of their balance we’re using. See the next section on $0 balances.

Speaking of displaying strings to users, we always wrap dollar amounts in our centsible() function. It turns floats like 3.14159265358979 into strings like “$3.14”.

Finally, in case it’s clearer, the iou() can be thought of conceptually (or even actually, if we wanted) as being in two pieces:

iou(t, -x, u, "user owed us #{x} and this debits their balance by that amt")
iou(t,  a, u, "user paid us #{a} so this credits their balance by that amt")

5. Zero Dollar Balances

If you have a positive balance then all the right things happen — you use up as much of it as possible. If you have a negative balance, that’s fine too — see Section 10. What about the extremely common case of having no credit? In that case the above algorithm amounts to debiting your balance by $x, which puts you at -$x, then charging $x to your credit card and adding a corresponding $x back to your balance, bringing it back to $0.

While the balance winds up correct, doing that iou transaction clutters up the translog and the user’s credLog, as well as updating their credT. It doesn’t make sense to do that for the 99+% of users who will never interact with this feature at all. Also it doesn’t leave us with an easy way to see if a user has ever received credit from us, and it muddies up their history with a bunch of transactions that aren’t meaningful to them. So if the user’s balance is 0 and the IOU will be a no-op we skip the call to iou in the pseudocode above.

if b == 0 && a-x == 0 then
  # suppress the IOU: old balance and amount to debit/credit are both exactly 0
  c = 0
else
  c = iou(t, a-x, u, "user owed us #{x} and we charged their credit card #{a}")

We’re being conservative there in doing a floating point comparison. If there’s any weirdness that makes those values not precisely zero then we go ahead and log the IOU.

What about the email string that says “using $0 of your $0 credit”? For users who’ve never used the credit feature at all, we definitely don’t want to include that when we charge them. That would just be weird. And so for consistency we suppress the “using $x of your $c credit” string whenever round(100*x) == 0 && round(100*c) == 0. That is, we never say “using $0 of your $0 credit” to anyone.

6. Negative Charges and Other Tortured Use Cases and Boundary Cases

There’s a funny thing about the splitch() function (besides the name, which, again, stands for “split charge”) and the above code. Say we run it with $x being a negative amount, like -$100. Not that we’d allow that, but what would happen if we did? Your -$100 that you owe, being less than the $1 charge minimum, means we’d actually charge you $1 and give you the remainder, $1 minus -$100, i.e., +$101, as credit. Which is the perfectly correct interpretation of charging you a negative amount, i.e., giving you (owing you) money.

As another sanity check, consider the case of running the above code with an amount owed of $0. And say your credit balance is also $0. We’d charge you the $1 minimum and your credit would go to +$1. I.e., we’d take a dollar from you but owe it back to you, netting the intended $0. It’s just the extreme case of, say, charging you a penny: We’d take a full dollar and owe you back 99 cents. (Which could totally make sense if we wanted to, say, support amounts less than a dollar in the “charge me money” endpoint of the API. There may not be much demand for that but it’s now a thing we could easily do!)

Which is just all to say that everything here is exquisitely mathematically right and elegant and robust and yay.

7. Changing the Global Interest Rate

In case we decide to change the interest rate (AKA time discount rate) we just need a one-line script for the following database update query:

t = now()
for each user u:
  u.credX = npv(u, t)
  u.credT = t

8. Are there Floating Point Concerns?

Naw. You may end up with a balance like $3.00000000000028 and we’re fine with that. We just need to not ever do something like “if (credX == 0) ...” (but see Section 5 where we do!) and always round what we show you to the nearest penny and everything’s fine (enough).

9. Updating Users’ Credit in the Admin Interface

This lives at /admin/users/:username and shows the current credit amount — npv(u) — when the page was loaded. It would be nice to be able to see the whole history of credits and debits without trawling the translog but that’s not a Mendoza. The credit/debit UI is like so:

Add credit of $__x__ to user's balance because ____r____ [SUBMIT]

That will do an iou(now(), x, u, r) (which also translogs it). We require the admin to specify a reason. Audit trails!

The admin interface makes the user’s premium level and frequency obvious near where we adjust the credit. We want it to be clear especially if someone has a lifetime plan because that means they’re never going to actually use any credit we give them, unless they upgrade plans, if there is a higher plan. Caveat administrator.

10. One More Thing (IOUs & Refunds & Shıt)

It turns out there’s another potentially useful thing we can do with this. If for some reason (and this kind of thing happens periodically, for various convoluted support + life + technical reasons) you insist on paying us more money than we’ve charged you so far but don’t want that to happen till your next premium payment is due, it all works fine to just put a negative number in the above form. Say you had no balance and we put a -$90 in the “how much credit to give you” field, meaning you owe us $90. When it’s time to charge you your $8 for Infinibee or whatever, we’re like “ok, we’ll charge you $8 minus your balance, which, your balance being negative, is $98” and the amount to add to your balance is $98 - $8 = $90, which brings you to $0. I sure love how doing things all mathily makes use cases you never originally considered just magically work!

PS (thanks to Mary and Nicky): Also we can potentially use this as an easier/cheaper way of doing refunds if you have a non-legit derailment and miss the window for having us cancel the charge. Instead of doing a credit card refund, which sucks for all involved, we could, if the user’s ok with it, give them premium credit. You’ll even earn interest on it while waiting for it to be used up!

11. Open Questions

  1. Do we need a way to remove / reset the credit amount in case we screw up somehow? Is it fine to leave that as a “bug Bee” step? (Which way will create more work, admins messing up how much credit they add or admins messing up how they reset/adjust the credit field?) Default answer: Bug Bee.
     
    PS: That was a good call since this seems to never come up in practice anyway. Especially since you can always add a chunk of credit with opposite sign to undo a previous chunk.

  2. Was all this necessary? I don’t know, man. Now that it’s done it sure seems like overkill. But it doesn’t get much simpler by taking out the obvious candidates for over-the-top-ness, like the time discounting. A lot of ink got spilled on the little monkey wrench of enforcing a minimum charge. There may yet be a much simpler version of all that.

  3. Are we sure there aren’t Floating Point Concerns? No, but this feature is already a bit out of hand. Maybe we should store the dollar amounts as integer numbers of cents or something. But so far we don’t anticipate this implementation cheating anyone out of more than like a millionth of a penny, so, yeah.

  4. If we want to do promotions or use this to comp people free premium or something it might be too weird or rude or whatever to actually still charge $1 per month. We could augment the splitch function to round up to a dollar if you owe less than that but also if, given your amount of credit, you owe $0 then let it be $0 and don’t actually charge your credit card anything. That would be a one-line change to splitch and it’s just a matter of deciding whether we care more about the paper trail or about not seeming like money-grubbing cheats charging you $1 when it may seem like it ought to be free. (See the top of Section 4 for why we currently always charge you at least $1.)
     
    PS: An example of how weird this is: A user used a “second month of Infinibee free” coupon which we implement by giving them $8 of credit. So they pay $8 for month 1 like normal. So far so good. For month 2 they get charged $1 and have $1 left as credit. For month 3 they get charged $7, using their last $1 of credit. For month 4+ they get charged $8/mo like normal. It could seem pretty byzantine/buggy (“why am i being charged $1 when you said the 2nd month was free?”, “why was i charged $7 instead of $8 this month?”) unless you read like 3 chapters of this spec.
     
    PPS: It’s seeming more reasonable for us to keep it as it is. For one thing it’s nice to have the reassurance that the payment method still works, and $1 is truly a token amount that it’s hard to reasonably object to. We just have to be clear that this is how it works and that’s proving to be not too onerous to do.

  5. How funny is it that we off-handedly said in the Dogfood Binge post that we could pay users via premium credit because this feature seemed so trivial that we didn’t even think of it as requiring any code and then we thought about it slightly harder and were like “this is super easy we can just have a “credit” field and then add one line of code where we currently do “charge(x)” to instead do “if (credit > x) then credit -= x else charge(x-credit); credit = 0 endif” and then we nodded sagely and turned it into a handful of little checklist items to throw it together (as an aside in another gissue, no less) and then … this happened. Tentative answer: pretty funny.

  6. If we ever wanted to let users be the beneficiaries of each other’s derailments, we have all the machinery in place now. Like Alice derails and wants the $90 to go to Bob, we just credit Bob $90 and credit Alice -$90 (i.e., debit $90) and voila. Bob’s next premium charges will be waived till he’s used up his $90, and Alice will pay $90 extra next time she has a premium payment due.

12. Translogging Everything as IOUs

Added 2020 July — draft of a new gissue

Charge events and refund events should generate IOU translog entries

Whenever we charge a user we translog it, and as of gissue#1477 we translog refunds too.

We also translog changes to users’ premium credit, in the infrequent cases where we use that, as IOU entries in standard ledger format.

This gissue is to do that with all charges and refunds as well. That way we have a very reassuring sanity check for the accounting. Summing up all the IOUs for a user must equal zero (technically whatever their credit balance is, zero for almost all users).

So specifically that means that when a user derails or adds a premium plan or whatever we should translog it like so:

IOU t x u bmndr r

where t is the timestamp, x is the dollar amount, u is the username, and r is the reason (TBD how to format the reason).

When we charge the user’s payment method we add another IOU with the negative of the amount. Conceptually: the user owed us and then immediately paid us, so the IOUs cancel. And technically the derailment happens the day before so the IOU should be post-dated to t + 24 hours.

When we do a refund we should also generate a pair of IOUs. One to indicate that we owe the money back to the user (typically because the derailment wasn’t legit) and the negative of that when we actually give the money back.

(I know this all seems tedious or over-engineered but it’s how double-entry accounting works and I’ve thought about this kind of thing a ridiculous amount and it’s the right way to do it.)

When we hit cancel on a charge, that’s also translogged already and should generate an IOU that’s the negative of the IOU for the derailment, pre-dated so the dates match. (The translog’s own timestamp records when the charge was actually canceled.)

Open question: Should these IOU entries be in addition to the existing translog entries or can the IOUs subsume them, maybe with suitable info machine-readably packed into the reason part of the IOU?