Back to Feed
article 3m read

Credit wallet had nowhere to write down that a customer owed me

A refunded pack whose credits were already spent had nowhere to go, so accounts got a separate debt counter and every incoming credit now pays that debt before it becomes spendable.

Article DepthPractice

Flow

The pieces of debt-first credits

  1. 1A debt number

    Starts at zero next to the wallet and never goes negative.

  2. 2One split rule

    Incoming credits pay debt first; the rest is spendable.

  3. 3Every refund path uses it

    One database function, plus one matching API method for purchases and grants.

  4. 4Spending needs zero debt

    Checked in the same update that takes the credits.

  5. 5Old versions get stopped

    A trigger refuses old-style writes while an account owes.

Saying a customer owed credits wasn't something my scheduling app could do. There was no place in the database to put that.

Credits are paid for up front. A customer buys a pack, and each schedule, break plan or imported sheet uses a few. They sit in one wallet number that never drops below zero, because a job won't start if the wallet can't cover it. That works for spending. It breaks when a pack is refunded after its credits are used up. There's nothing left to take back, and no other place to write down the gap.

The worse part was how many spots added credits to that wallet. Buying, admin grants, refunds for failed jobs in two different services, account and user deletion, webhooks and imports each did their own math. With debt in the picture, every one of them would have put new credits straight into the wallet while the customer still owed.

Debt gets its own number

Each account now has a debt number next to the wallet. It starts at zero and can't go negative. Each ledger row records what went to the wallet, what went to debt, and both totals afterward.

Any credits coming in follow one rule. Pay off debt first, and only the rest can be spent.

The amount repaid is the smaller of what's owed and what's coming in.

That rule lives in one database function, and every refund path calls it. Purchases and admin grants use one API method that does the same split and rolls back if its own math doesn't match what got stored. A retry with the same ID gets back the first result, and the same ID with a different amount is refused. I'd rather have the rule in one place than two, but two beats the pile I had.

Spending while you owe

A billable job now needs zero debt and enough credits, checked in the same update that takes the credits. Reading your schedules and recovering work you already paid for still work while you owe.

Old versions during a deploy

While a deploy rolls out, an older version may still write rows that don't know about debt. A database trigger fills in the missing fields, and if the account owes anything, it refuses the write. A test on a real Postgres checks that case, plus the normal one where a payment bigger than the debt clears it and the rest lands in the wallet. Other tests scan every refund path and fail if one goes back to adding credits by hand.

One gap is still open. This change only does the paying-back half. Nothing in it ever adds debt to an account, so for now the debt check has nothing to catch.

Keep reading

Pick up a connected idea or branch into a nearby one.

5 paths forward
Related idea

Paying customers matched by email, even the addresses the billing code made up itself

The lookup that decided whether an organization already had a payment customer searched by an email my code sometimes guessed or generated, and could hand one org's billing record to another. I moved identity onto the identity provider's org ID and tied retries to it, but the same commit left an ignored retry flag, a delete check that reads error wording, a gutted seat counter, and no tests.

via #billing · #idempotency