By: dreev and bsoule
Spec level:
Gissue: #4463Changelog
2023-10-20: Reorg, change some cat names, fixes 2023-10-18: Improve IOU categories 2023-10-18: Switch to yootles style: honeyBal/Tee just for caching 2023-10-12: Notes on sting immediacy and deadbeating 2023-10-10: Flesh out the category field for IOUs 2023-10-09: Reorganizing, putting more in the sidebar 2023-10-09: Dispatch some To-Dos, add IPSO_FACTO_SUCCESS state 2023-10-02: More open questions and other tweaks 2023-10-02: Notes about `tee` and `lag` in "crazy usury" parenthetical 2023-09-08: Appendix for shirk-n-turking mana <-> honey 2023-09-05: Nix magic string stuff in favor of explicit charge status 2023-09-03: Onomastic fussery (fro/yon) 2023-09-01: Lots of careful rewriting and expounding 2023-08-31: Business logic becoming coherent 2023-08-30: Bee and dreev keep braindumping ideas into this 2020-07-31: Predecessor of this appeneded to the credit spec 2020-03-25: Premium credit launched
(See doc.bmndr.co/honeygrams.)
Alright, so how are we implementing this?
It’s crucial to always do double-entry accounting (see sidebar) and that means Beeminder itself has a honey money balance. But nothing special is needed for that. We just use the meta user.
Every user has fields for their honey balance:
honeyBal
(default: 0)honeyTee
(default: 0 aka the Unix epoch, 1970 January 1)But! Those are only for caching purposes.
If you leave them both zero, everything works fine.
A user’s balance is computed in real time by starting at time honeyTee
and walking forward to each new IOU since then, recomputing the balance.
The
credit spec defines the core function that does that, turning an existing honeyBal
/ honeyTee
into an updated honeyBal
/ honeyTee
in light of a new IOU.
See the iou
function there.
And if you want a user’s balance as of Right Now, you first make sure you’ve computed it as of the most recent IOU and then call an NPV() function — also defined in the credit spec — to compute the interest as of any past, present, or future time.
We’ll talk about IOUs momentarily.
First, one more user field:
honeyholder
— whether the user wants sting immediacy, i.e., the user does not want to debit their honey for derailmentsWe’re ignoring for now the generalization to multiple credit cards. There’s only honey plus at most one physical payment method. The generalization to a list of payment methods will still work later (cf. black-box generalized splitch). In the meantime, being a honeyholder means honey is used last for derailments. (For premium, honey is always used first — there’s no choice about that.) If the user opts to not be a honeyholder then honey is used first for everything.
This is the core thing for honey money. It’s a separate table with the following fields:
tee
— the timestamp (unixtime in seconds)amt
— how much honey is being transferred (float)fro
— user or account that the IOU is from (pointer to user table)yon
— user or account that the IOU is to (pointer to user table)why
— note or reason for the IOU (string)xid
— the external ID of the spawning/related payment that this IOU corresponds to (PaymentIntent ID from Stripe, Managram ID from Manifold) or nilst8
— the status of the PaymentIntent from Stripe, or our own status (string)pre
— the ID of another IOU that precipitated this onenxt
— the ID of another IOU that this one precipitatescat
— the category (enum, see list below)Some of those will make more sense with the examples in the next section but we’ll run through them all briefly now.
The first five are the standard ledger entry fields, starting with the date/time and amount of the IOU.
For our purposes we expect either the fro
(aka from) or the yon
(aka to) to always be Beeminder — the house account — but since that’s just another user, nothing prevents honey transfers between arbitrary users whenever we may want to support that.
(Manifold calls this feature “managrams”; ours is “honeygrams”.)
And of course the why
(aka memo or comment) field is a human-readable explanation.
This brings us to xid
which connects an IOU to a corresponding external object.
Mostly that means a Stripe charge or PaymentIntent, as Stripe calls it.
More rarely but conceptually the same: If someone does a transfer from honey to mana or vice versa, there’ll be a Managram on Manifold to move the mana (see the appendix for details of that) and xid
stores the Managram ID for that.
(In the future we may have Derail objects and want the IOU to point to the corresponding derailment.
Probably we’d add a new did
field if so, if we don’t want to overload xid
.)
Focusing on Stripe, it’s a key invariant that any charge we make to a user’s payment method needs a corresponding IOU of the same amount, with the why
field referring specifically to the charge.
See again the sidebar on double-entry accounting and equal-and-opposite IOUs.
We also track the status of the PaymentIntent with the st8
field and use that field more generally to track the status on the Beeminder side.
For example, the charge for an IOU may start in the “SCHEDULED” state, when we’re in the 24-hour window between the derailment happening and the user’s credit card being charged.
In the next section we’ll see how all that plays out, like when we charge a user for less than the derailment amount because some of the amount was covered by the user’s honey balance.
The pre
and nxt
fields allow IOUs to point to each other.
If we have a $5 IOU — call it D — for a derailment and then a $5 IOU called P for the actual charge, then D’s nxt
field is the ID of P and P’s pre
field is the ID of D.
Finally, the cat
field is just for our own analytics, to keep track of the reasons for payments.
Here are the possible categories:
derail
— the IOU debiting the full pledge when you derailunderail
— an equal-and-opposite IOU reversing a derail IOUpremium
— the IOU debiting what you owe for a month or however long of a premium plantopup
— an IOU corresponding to a Stripe charge precipitated by / auxiliary to another IOUhoneybuy
— an IOU corresponding to a Stripe charge for manually / voluntarily purchased honeyhoneygram
— an IOU that effects a transfer of honey from one user to anotherhoneysell
— aka a refund, effectively a purchase of negative amount of honeylove
— honey given from Beeminder to a user as a bug bounty or thank-you or apology or as part of some incentive scheme or because they got a Beeminder tattoo or somethingThe underail
category is related to refunds but, as we’ll see in the business logic section, there’s usually no IOU for the refund itself.
We just reverse the derail IOU and if the IOU for the actual charge hasn’t happened yet, we delete it.
If it did already happen, it’s effectively/implicitly reclassified as a purchase of additional honey.
An IOU with category topup
should have a pre
pointing to the IOU that precipitated it.
A topup IOU corresponds to the actual charge to pay what you owe for a derailment or for premium or whatever.
It might be for less than the IOU that precipitated it if the user had a positive honey balance.
If the user’s honey balance was greater than or equal to the amount they owed then there’d be no topup IOU at all.
The topup
and honeybuy
categories are special in that they need to have (or be scheduled to have — see the business logic section) an xid
from somewhere.
And some honeygram
type IOUs will also have an xid
pointing to their corresponding Managram ID.
[TODOBEE: is the previous sentence correct? Maybe a honey/mana transfer is just a purchase of or sale of honey?]
A couple conventions:
The value of the st8
field is in all caps to indicate it’s one of our own charge states, not one we’re mirroring from Stripe.
And the “SCHEDULED” state means that it’s awaiting a charge sweeper which periodically queries for such IOUs that also have a timestamp tee
up to and including now.
The sweeper charges the charge on Stripe and updates the xid
and st8
to what Stripe tells us.
The current Charge table has the following fields:
user
— (Reference) the user to whom the charge belongscontract
— (Reference) the goal that derailed, if the charge is because of a derailcharity
— (Reference) the charity that benefits, if applicabledonation
— (Reference) the actual donation, if one has been made (only created once charge is successful; removed if charge is refunded)amount
— (Float) the amount of the charge (corresponds to IOU amt
)charge_at
— (Time) the time that the charge is initially scheduled to occur (corresponds to IOU tee
)note
— (String) the description or reason for the charge (corresponds to IOU why
)chargedit
— (Boolean) whether or not the charge succeeded; semi-obsoletestatus
— (Enum) describes the status of the charge, ∈ {pending, succeeded, canceled, failed, refunded} (corresponds to IOU st8
)noalert
— (Boolean) whether or not we send a “charge alert” email on creation of the charge (default: false)created_by
— (String) a reference to the user or API client that created the charge; can be nilchargeids
— (Array) external ids that correspond to attempts to collect on this charge; semi-obsoleteintentId
— (String) the id of the Stripe::PaymentIntent
that corresponds to this charge; replacement for chargeids
(corresponds to IOU xid
)kind
— (String) corresponds to IOU cat
; not in usepaytype
— (String ∈ {stripe, paypal, honey}) the payment type to use when collecting on this chargeIf the paytype
is nil
at collection time we fetch and use the default_paytype
from the user
.
When the charge is created it sends an email alert to the user letting them know they’re about to be charged.
By default the charge_at
is initialized to now + 1 day.
The charge starts with a status of “pending”.
Once we’ve tried it, it moves to either “succeeded” or “failed”.
At any point before it hits “succeeded” it can be canceled by an admin.
Once it reaches “succeeded” the only action allowed is to refund it.
Let’s start with the simplest thing that can happen: A user directly purchases honey money from us. This means an IOU corresponding to a specific charge on Stripe and the only reason for it is for the user to make their honey balance go up. Call it $X of honey purchased at time T by user U. Or “you” which is how we’ll refer to the user from now on. So we credit your honey and debit Beeminder’s with the following IOU:
tee: T
amt: X
fro: bmndr
yon: U
why: "bought ${X} of honey money"
xid: (whatever Stripe gives us)
st8: (ditto)
pre: nil
nxt: nil
cat: "honeybuy"
What if you derail, or any event happens for which you owe us money — call it $X at time T for reason R, and you’re user U. First we debit your honey and credit Beeminder’s:
tee: T
amt: X
fro: U
yon: bmndr
why: R (typically something like "derailed usr/gol at $X")
xid: nil
st8: "IPSO_FACTO_SUCCESS"
pre: nil
nxt: (ID of other IOU created, if created per next step)
cat: "derail"
Great, you’ve officially paid your debt on the ledger (equivalently: your debt is logged on the ledger).
The “IPSO_FACTO_SUCCESS” state is because your debt is paid by virtue of this IOU existing.
We proceed to immediately compute the amount to physically charge your credit card, which we’ll call $Y.
By “immediately” we mean that the derailment officially happened at time T and any delay in processing doesn’t matter.
It’s computed as of time T and we know honeyTee
will equal T because we just logged the IOU for the derailment and that includes updating honeyBal
and honeyTee
.
(Again, see the
credit spec for details on updating honeyBal
and honeyTee
.
TODO: rewrite this in light of the fact that honeyBal/Tee are just for caching now, not that it changes anything conceptually.)
In this case we don’t care what your honey balance is — we’re just charging your credit card the full $X, fully reversing the IOU from the derailment so your honey balance won’t change:
Y = X
-1 < honeyBal < 0
In this case you have a tiny negative balance below the threshold for credit cards (that “$1” might want to be a constant somewhere?) and so we just charge the $1:
Y = 1
(Spoiler: Y can be anything and it doesn’t actually matter. Whatever we charge your credit card we give back as honey so everything will be fine and fair no matter what.)
honeyBal < 0
In this case you also have negative balance but, since we’re not in case 2, it has big enough absolute value that you can pay exactly what you owe:
Y = -honeyBal
For example, consider the common case that you had a zero honey balance when you derailed at $5. That derailment will have put you at -$5 and so the amount, Y, that we’ll be charging your credit card is (drum roll) $5. Phew.
honeyBal
>= 0Y = 0
No charge happens at all in this case. You had enough honey to pay what you owed!
Now that we know how much we’re charging you, we make a postdated IOU:
tee: T2
amt: Y
fro: bmndr
yon: U
why: R + "(charging ${Y} to payment method {PMID} and deducting ${Y-X}
from your honey money balance)"
xid: nil
st8: "SCHEDULED"
pre: (precipitating IOU ID)
nxt: nil
cat: "topup"
Again, this is a topup IOU because you spent some honey by derailing and now we’re replenishing all or some of that honey. The time T2 is T plus whatever delay we use between derailing and getting charged — typically 24 hours.
(What about our crazy usury?
Won’t that negative balance have accumulated interest between the derailment and the charge?
Answer: Yes, but for typical amounts and typical charge delays, that will round to zero cents.
In any case, we’re just biting the bullet on this.
Technically when you derail at $X you pay $X plus interest.
It’s fine.
But if we really wanted to spit this bullet back out, it’s not hard.
We’d store both T and T2 with the IOU for the charge, using T as the IOU’s official timestamp but waiting until T2 to effect the charge.
Basically a nominal time and an actual time.
Since obviously we’re hard-committed on these 3-letter fields, we’d have tee
and lag
so tee
is the nominal time and tee+lag
is how long we wait to effect the charge in the real world.
Only tee
matters for the math and lag
is just for scheduling.)
Recall that R was the reason for the first IOU at time T, like derailing a goal. So we’re appending a parenthetical to the original reason explaining how much we’re taking from your honey balance vs charging to your payment method. We identify that payment method with PMID, e.g., the last 4 digits.
Special case: if Y-X is exactly 0 then omit the “and deducting…” part of why
string.
As mentioned in the previous section, the charge sweeper queries for IOUs with times ≤ now and with st8
set to “SCHEDULED”.
For each one it finds, it does the actual charge, gets a charge ID from Stripe, and updates xid
and st8
with whatever Stripe tells us.
Stripe has a number of states that a PaymentIntent passes through, from creation to fully-executed-and-funds-in-our-account. We will give Stripe a webhook to listen for PaymentIntent or Charge events. When we get an event like “charge.succeeded” we can look up the corresponding IOU on our side using metadata we added to the Stripe object and update the status accordingly.
[TODOBEE: nail down the details of that metadata]
Stripe has the full list of PaymentIntent states in their documentation. Not all of the possible states are relevant for us, as we currently only charge credit cards. In the future we may support other payment types which might require us to listen for other events.
Here are ones that are relevant for us:
requires_payment_method
— this is the initial state when the PaymentIntent is created, and it returns to this state if the card is declinedprocessing
— in practice we don’t see this because credit cards process instantly and spend no time in this statesucceeded
— yay!canceled
— boo.Unfortunately “refunded” is not noted in the status of the PaymentIntent itself.
Stripe creates a refund as a separate object in their API, and they don’t change the status of the associated Charge or PaymentIntent.
To create a refund you call Stripe::Refund.create()
and pass either the charge_id, or the payment_intent_id of the associated payment you wish to refund.
Subsequently the Charge and the PaymentIntent status will still be succeeded
, but the refunded
attribute on the Charge will be populated.
So to check refund status of a charge, or a PaymentIntent, you query charge.refunded
, or payment_intent.latest_charge.refunded
.
The refund information is not reflected directly in the PaymentIntent itself.
For fastidiousness and debuggability we transition the IOU state like so:
Again, if you have enough honey then we just debit it by logging the IOU and that’s a successful “charge”. The status of that IOU will only ever by “IPSO_FACTO_SUCCESS”: successful by virtue of the IOU existing.
[TODOBEE: If we’re relying on webhooks from Stripe then maybe we want to keep track of whether we actually submitted the charge and are waiting for Stripe or what.]
If it’s before the charge goes through at time T2 or so:
tee: T
amt: Y
fro: U
yon: bmndr
why: "non-legit: {R}"
xid: nil
st8: nil
pre: the ID of the original derail IOU
nxt: nil
cat: "underail"
So we’re prefixing the original reason for the IOU (like “derailed usr/gol at $X”) with “non-legit”.
Also update the original derail IOU so its nxt
field points to this one.
[TODOBEE: say something about how to find the original IOU?]
If the charge already went through it’s just as easy:
That’s it, you’ve been refunded in honey!
Maybe you’re a whiny-whinypants and need your actual credit card refunded? That’s just doing the two steps above plus a purchase of negative honey, which is not really even a special case. We first refund $Y via Stripe (probably the same Y as above but it doesn’t matter — do a partial refund, whatever). And then it’s exactly like the case at the top of this section — purchasing honey money — but you’re buying a negative amount of it:
tee: T
amt: Y
fro: U
yon: bmndr
why: "non-legit derail"
xid: TODOBEE
st8: TODOBEE
pre: [TODOBEE: does this point to precipitating derail or underail?]
nxt: nil
cat: "honeysell"
That IOU is in one sense cashing out $Y of honey, which is emphatically not allowed. But if the refund is only happening because of something that was Beeminder’s fault and not at the user’s discretion then it’s ok. We can refund the purchase of honey money like any other purchase if there’s a specific reason the purchase was in error or shouldn’t have happened.
Tracking charitable donations is much nicer in this new world order. It involves creating a dummy Beeminder account for each possible charity. If the user has an active Beemium plan at the time they pay us (on the ledger) for a derailment and has opted for a charity then we log a chain of IOUs:
Using the pre
and nxt
fields, we record that 1 precipitates 2 which precipitates…
[TODOBEE: decide what’s most useful for those pointers, compare to status quo]
In conclusion, it’s all much cleaner and nicer having honey accounts for the charities!
As currently spec’d, anyone opting to hold on to their honey is still going to have their honey debited when they derail, just that we’re then going to fully replenish it 24 hours later. Will that seem buggy?
(This is making me think even more that honeyholding should be buried in advanced settings, so to speak. We could also frame it as “auto-replenish any honey I lose from derailments”. I kind of like that. At the extreme we could say there’s no such thing as honeyholding but as a workaround if you want sting immediacy, give away your honey or loan it out or store it as mana.)
If we want honeyholding but don’t like the thing where your honey balance is negative for 24 hours, we could do away with the 24-hour delay. As in, intentionally decide to trade-off 24-hour processing delay in exchange for honeyholding.
Currently when we charge your credit card, we insert a 24-hour delay between when you actually derail and when we debit the money from your card. We get a few things from this delay:
If we do refunds only to honey (except when Extenuating Circumstances) then it’s actually kind of in our financial interest to do the charge right away… Which is a strong argument for not doing away with the delay, since we are always trying to be extra assiduous about financial stuff because of the “wait! you make money when your users fail?” perception.
Arguments for doing away with the 24-hour delay include:
Things we could do (TODOBEE: brainstorm a list, then pick one and try running with it)
More technical details about the implementation of IOUs.
Each user has a honeypot
with the following fields:
The honeypot is a model that is embedded in the User, and it serves as a way to collect the logic and data associated with the user’s honey. It has the following fields:
bal
— current / cached honey balance (instantiated to 0 at the time of user creation)tee
— time which bal
was last calculated / cached (instantiated to NOW at time of user creation)holder
— whether the user wants to hold onto their honey or not (Boolean; default=true)And the following methods:
scope
argument is optional and defaults to all ious associated with this user. The scoope adjusts which IOUs are returned, in {:mine, :from, :yond}. (returns a Mongoid Query object)honeyLog
.Mapping existing HoneyMoney functionality into this implementation:
yoo.honeyX
=> yoo.honeypot.bal
yoo.honeyT
=> yoo.honeypot.tee
yoo.honeyLog
=> yoo.honeypot.to_a
yoo.iou(args..)
=> you.honeypot.iou(args..)
pre
and nxt
fields on the IOU overkill?
Does one direction suffice, like where an IOU points to a spawned IOU but not vice versa?
Or is it underkill and an IOU might spawn or be spawned by multiple IOUs?st8
field, or
(b) an additional table recording pending charges where we move it to the IOU table only once it succeeds.[TODO: update this with what we ended up doing]
Here’s how, as a trial, we can easily and shirk-n-turk-ily implement the ability to cash out one’s mana (Manifold’s play currency) as honey money and vice versa.
This assumes we implement a honeygram feature to let users send honey money to each other, which should be easy given how we’re implementing this.
Then we need to two dummy/house accounts, which we can think of as a “mana supply” account on Manifold and a “honey supply” account on Beeminder:
Say that Manifold user Marvin decides to transfer/convert 100 mana to $1 of honey money. (That’s the exchange rate on Manifold: you can buy 100 mana for $1.) Marvin makes a managram, as they call it, to send 100 mana to the bee@manifold account, specifying his Beeminder username (say it’s “marvinminder”) in the comment.
We at Beeminder get the notification that we received mana.
We transfer $1 of honey from the man@beeminder account to user marvinminder. Aaaand, that’s it. Marvin has turned his 100 mana into $1 of honey! Beeminder has that mana in its bee@manifold account.
It’s all very similar in the other direction. User bonnie on Beeminder wants to cash out her honey as mana. She sends a honeygram to the man@beeminder account, specifying her Manifold username, say “bonniefold”. We (at Beeminder) notify ourselves and go send the managram from bee@manifold to bonniefold on Manifold.
A potential concern is that this could be expensive for Beeminder if a lot of people cash out their mana as honey. Beeminder gets a lot of mana, which can’t be turned into real money, but forgoes a lot of real revenue.
It’s a fair concern and we’d reserve the right to pull the plug on it, but we predict that this is money-making from Beeminder’s perspective. The biggest effect we expect is Manifold users creating Beeminder accounts in order to take advantage of it. Or just out of curiosity or just because it’s how they learn Beeminder exists in the first place.
Hopefully it’s similarly beneficial for Manifold! It’s another bullet item in the growing list of reasons to have value for mana, along with bounties and market subsidies and cashing out to charity.