Flow
The pieces of debt-first credits
1A debt number
Starts at zero next to the wallet and never goes negative.
2One split rule
Incoming credits pay debt first; the rest is spendable.
3Every refund path uses it
One database function, plus one matching API method for purchases and grants.
4Spending needs zero debt
Checked in the same update that takes the credits.
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.