By: dreev and bee
Spec level: STATUS: LIVE IN PRODUCTION
Last updated: 2021-02-04
Gissue: #1170 (internal link)
This is mainly an internal spec, but may be of interest to some sufficiently nerdy nerds. See also our user-facing help doc about how premium credit works.
In our blog post on dogfood bounties, “Dogfood Binge”, we promised that you’d 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!
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”.
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:
credX
is a number of dollars from which to pay for premium plans before actually charging your credit cardcredT
is the timestamp that we last adjusted your creditcredLog
is an array that captures the edit history for your credit balanceWe 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)
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.)
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, anda = max(m, x - c)
the amount to charge. The delta on the credit balance isa - x = max(m, x - c) - x
. Ifm > x - c
then there exists a positived
such thatm = x - c + d
. Which means the delta isx - c + d - x = d - c
and the final balance isc + d - c = d > 0
. If insteadm <= x - c
then the delta isa - 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.
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")
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.
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.
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
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).
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.
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!
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.
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.
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.
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.
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.
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.
PS: We had an addendum here about translogging everything as IOUs that evolved into the Honey Money spec.