Commit b61ef28
committed
Fix free-tier credit overdraw and consumeFromOrderedGrants debt accounting
Root-cause fix in consumeFromOrderedGrants (packages/billing/src/balance-calculator.ts):
- Removed buggy "repay debt" first pass that treated consumption as credit addition
(grant.balance + repayAmount), shrinking debt during spending. This caused every
other post-exhaustion message to get free compute.
- Mutate grant.balance in-memory in the consume loop so the overflow check sees
post-consumption state (previously stale, dropped overflow credits silently).
- Unconditionally create/extend debt on the last grant when remainingToConsume > 0
(previously guarded by lastGrant.balance <= 0 using stale in-memory value).
Hard gate (defense-in-depth): added shouldBlockFreeUserOverdraw() and wired it into
consumeCreditsAndAddAgentStep. Free-tier users (no purchase/subscription grant) with
netBalance < credits are refused before consume/message-insert. Throws typed
InsufficientCreditsError (netBalance, chargeAmount fields) inside the advisory-lock
tx so it rolls back cleanly and the outer catch returns failure(error).
These two layers are complementary, not redundant:
- Root-cause fix = correct accounting (debt deepens monotonically)
- Hard gate = policy enforcement (free tier can't go negative; only paying users
can accumulate debt via the fixed consume path)
Debt-settlement model is split: consume path only deepens debt; grant path
(executeGrantCreditOperation in grant-credits.ts:134-154) is the ONLY place debt is
cleared, via the existing negativeGrants-zeroing logic that runs on every credit
addition (Stripe purchases, monthly resets, referrals, admin grants). Added
cross-reference comments in both files documenting this invariant.
Tests:
- 9 unit tests for shouldBlockFreeUserOverdraw (exhausted, insufficient, sufficient,
subscription/purchase bypass, zero-charge, referral-only, debt, multi-grant)
- 6 regression tests for consumeFromOrderedGrants using write-capture mock tx:
debt deepening, drain-and-overflow, no debt forgiveness, happy path, multi-grant
priority, consumed tracks overflow
- 2 tests for InsufficientCreditsError class (instance + barrel export)
- Fixed createMockGrant type (added org_id, stripe_subscription_id, extended union)
- Updated local copy of consumeFromOrderedGrants in the real-DB integration test
and renamed/rewrote the 'should repay debt...' test to 'should not forgive
debt...' — the old test was codifying the bug as correct behavior.
Validation: typecheck clean on packages/billing; 28/28 balance-calculator unit
tests pass; 14/14 integration tests pass against real Postgres; 128/128 full
billing test suite green.
Impact: Apr-16 credit-farming cohort of 10 freshly-created accounts consumed
~\$18.4k of API compute (74% of daily burn) off 500-credit free grants. With this
fix, those accounts would have been refused after message ~6.1 parent 984e868 commit b61ef28
File tree
4 files changed
+544
-109
lines changed- packages/billing/src
- __tests__
4 files changed
+544
-109
lines changedLines changed: 38 additions & 61 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
65 | 65 | | |
66 | 66 | | |
67 | 67 | | |
68 | | - | |
69 | | - | |
70 | | - | |
71 | | - | |
72 | | - | |
73 | | - | |
74 | | - | |
75 | | - | |
76 | | - | |
77 | | - | |
78 | | - | |
79 | | - | |
80 | | - | |
81 | | - | |
82 | | - | |
83 | | - | |
84 | | - | |
85 | | - | |
86 | | - | |
87 | | - | |
88 | | - | |
89 | | - | |
90 | | - | |
91 | | - | |
92 | | - | |
93 | | - | |
| 68 | + | |
94 | 69 | | |
95 | 70 | | |
96 | 71 | | |
| |||
113 | 88 | | |
114 | 89 | | |
115 | 90 | | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
116 | 95 | | |
117 | 96 | | |
118 | | - | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
119 | 101 | | |
120 | 102 | | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
121 | 115 | | |
122 | | - | |
123 | | - | |
124 | | - | |
| 116 | + | |
| 117 | + | |
125 | 118 | | |
126 | | - | |
| 119 | + | |
| 120 | + | |
127 | 121 | | |
128 | | - | |
129 | | - | |
130 | | - | |
131 | | - | |
132 | | - | |
133 | | - | |
134 | | - | |
135 | | - | |
136 | | - | |
137 | | - | |
138 | | - | |
139 | | - | |
140 | | - | |
141 | | - | |
142 | | - | |
143 | | - | |
144 | | - | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
145 | 126 | | |
146 | 127 | | |
147 | 128 | | |
| |||
789 | 770 | | |
790 | 771 | | |
791 | 772 | | |
792 | | - | |
| 773 | + | |
793 | 774 | | |
794 | 775 | | |
795 | 776 | | |
| |||
820 | 801 | | |
821 | 802 | | |
822 | 803 | | |
823 | | - | |
824 | | - | |
825 | | - | |
826 | | - | |
827 | | - | |
828 | | - | |
829 | | - | |
830 | | - | |
| 804 | + | |
| 805 | + | |
| 806 | + | |
| 807 | + | |
831 | 808 | | |
832 | 809 | | |
833 | 810 | | |
| |||
842 | 819 | | |
843 | 820 | | |
844 | 821 | | |
845 | | - | |
846 | | - | |
847 | | - | |
848 | | - | |
| 822 | + | |
| 823 | + | |
| 824 | + | |
| 825 | + | |
849 | 826 | | |
850 | 827 | | |
851 | 828 | | |
| |||
0 commit comments