Skip to content

Commit b61ef28

Browse files
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

4 files changed

+544
-109
lines changed

packages/billing/src/__tests__/balance-calculator.integration.test.ts

Lines changed: 38 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -65,32 +65,7 @@ async function consumeFromOrderedGrants(params: {
6565
let consumed = 0
6666
let fromPurchased = 0
6767

68-
// First pass: try to repay any debt
69-
for (const grant of grants) {
70-
if (grant.balance < 0 && remainingToConsume > 0) {
71-
const debtAmount = Math.abs(grant.balance)
72-
const repayAmount = Math.min(debtAmount, remainingToConsume)
73-
const newBalance = grant.balance + repayAmount
74-
remainingToConsume -= repayAmount
75-
consumed += repayAmount
76-
77-
await updateGrantBalance({
78-
userId,
79-
grant,
80-
consumed: -repayAmount,
81-
newBalance,
82-
tx,
83-
logger,
84-
})
85-
86-
logger.debug(
87-
{ userId, grantId: grant.operation_id, repayAmount, newBalance },
88-
'Repaid debt in grant',
89-
)
90-
}
91-
}
92-
93-
// Second pass: consume from positive balances
68+
// Consume from positive balances in priority order
9469
for (const grant of grants) {
9570
if (remainingToConsume <= 0) break
9671
if (grant.balance <= 0) continue
@@ -113,35 +88,41 @@ async function consumeFromOrderedGrants(params: {
11388
tx,
11489
logger,
11590
})
91+
92+
// Mutate in-memory balance so the overflow check below sees
93+
// post-consumption state (not the stale original value).
94+
grant.balance = newBalance
11695
}
11796

118-
// If we still have remaining to consume and no grants left, create debt in the last grant
97+
// If we still have remaining to consume, create or extend debt on the
98+
// last grant. After the loop above all positive-balance grants are drained.
99+
// The "last grant" (lowest consumption priority, typically a subscription
100+
// grant that renews monthly) absorbs the overflow as debt.
119101
if (remainingToConsume > 0 && grants.length > 0) {
120102
const lastGrant = grants[grants.length - 1]
103+
const newBalance = lastGrant.balance - remainingToConsume
104+
105+
await updateGrantBalance({
106+
userId,
107+
grant: lastGrant,
108+
consumed: remainingToConsume,
109+
newBalance,
110+
tx,
111+
logger,
112+
})
113+
consumed += remainingToConsume
114+
lastGrant.balance = newBalance
121115

122-
if (lastGrant.balance <= 0) {
123-
const newBalance = lastGrant.balance - remainingToConsume
124-
await updateGrantBalance({
116+
logger.warn(
117+
{
125118
userId,
126-
grant: lastGrant,
119+
grantId: lastGrant.operation_id,
120+
requested: remainingToConsume,
127121
consumed: remainingToConsume,
128-
newBalance,
129-
tx,
130-
logger,
131-
})
132-
consumed += remainingToConsume
133-
134-
logger.warn(
135-
{
136-
userId,
137-
grantId: lastGrant.operation_id,
138-
requested: remainingToConsume,
139-
consumed: remainingToConsume,
140-
newDebt: Math.abs(newBalance),
141-
},
142-
'Created new debt in grant',
143-
)
144-
}
122+
newDebt: Math.abs(newBalance),
123+
},
124+
'Created/extended debt in grant',
125+
)
145126
}
146127

147128
return { consumed, fromPurchased }
@@ -789,7 +770,7 @@ describe('Balance Calculator - Integration Tests (Real DB)', () => {
789770
expect(grant3Balance).toBe(100) // Untouched
790771
})
791772

792-
it('should repay debt when consuming from grants with negative balance', async () => {
773+
it('should not forgive debt when consuming from a positive grant (debt stays untouched)', async () => {
793774
const db = getTestDb()
794775
const now = new Date()
795776

@@ -820,14 +801,10 @@ describe('Balance Calculator - Integration Tests (Real DB)', () => {
820801
conn: db,
821802
})
822803

823-
// Consume 80 credits
824-
// The consumption algorithm works as follows:
825-
// 1. First pass (debt repayment): Uses creditsToConsume to repay debt
826-
// - debt-grant has -50, repay 50 from the 80 requested, debt becomes 0
827-
// - remainingToConsume = 30, consumed = 50
828-
// 2. Second pass (consumption): Consumes from positive balances
829-
// - positive-grant has 100, consume 30, becomes 70
830-
// - remainingToConsume = 0, consumed = 80
804+
// Consume 80 credits.
805+
// Consumption only drains positive balances. Debt grants are untouched.
806+
// positive-grant (priority 10, consumed first): 100 - 80 = 20
807+
// debt-grant (priority 60): stays at -50 (debt is NOT "repaid" by consumption)
831808
const result = await consumeFromOrderedGrants({
832809
userId: TEST_USER_ID,
833810
creditsToConsume: 80,
@@ -842,10 +819,10 @@ describe('Balance Calculator - Integration Tests (Real DB)', () => {
842819
const debtGrantBalance = await getGrantBalance('e2e-debt-grant')
843820
const positiveGrantBalance = await getGrantBalance('e2e-positive-grant')
844821

845-
// Debt should be repaid: -50 + 50 = 0
846-
expect(debtGrantBalance).toBe(0)
847-
// Positive grant: 100 - 30 (consume after debt repayment) = 70
848-
expect(positiveGrantBalance).toBe(70)
822+
// Debt must be untouched — consumption does not repay debt
823+
expect(debtGrantBalance).toBe(-50)
824+
// Positive grant: 100 - 80 = 20
825+
expect(positiveGrantBalance).toBe(20)
849826
})
850827

851828
it('should track purchased credits consumption correctly', async () => {

0 commit comments

Comments
 (0)