Skip to content

Commit 8fc4c4c

Browse files
icecrasher321claude
andcommitted
fix(billing): verify entitlement healing against the credit-adjusted floor
The sweep selects drift as `limit <= freeTier + creditBalance` but scored the post-sync result against the bare `freeTier`. A user holding prepaid credits whom the sync left on their raised floor therefore satisfied the drift condition and was still counted and logged as healed, silencing the one signal the sweep exists to raise. Re-read `creditBalance` alongside the limit and compare against the same floor the query selected on. Cover both directions: still on a raised floor reports unresolved, above it reports healed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 24380ea commit 8fc4c4c

2 files changed

Lines changed: 38 additions & 2 deletions

File tree

apps/sim/lib/billing/entitlement-drift.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,29 @@ describe('reconcileEntitlementLimitDrift', () => {
5757
expect(result).toEqual({ drifted: 1, healed: 0, unresolved: 1 })
5858
})
5959

60+
/**
61+
* Prepaid credits raise the floor, so a user with a balance who is still on
62+
* it lands above the bare free-tier limit. Verifying against that bare limit
63+
* would score them healed and silence the signal.
64+
*/
65+
it('reports a credit-holding user the sync left on their raised floor', async () => {
66+
queueTableRows(schemaMock.subscription, [{ userId: 'user-1' }])
67+
queueTableRows(schemaMock.userStats, [{ currentUsageLimit: '25', creditBalance: '20' }])
68+
69+
const result = await reconcileEntitlementLimitDrift()
70+
71+
expect(result).toEqual({ drifted: 1, healed: 0, unresolved: 1 })
72+
})
73+
74+
it('heals a credit-holding user once the sync clears their raised floor', async () => {
75+
queueTableRows(schemaMock.subscription, [{ userId: 'user-1' }])
76+
queueTableRows(schemaMock.userStats, [{ currentUsageLimit: '50', creditBalance: '20' }])
77+
78+
const result = await reconcileEntitlementLimitDrift()
79+
80+
expect(result).toEqual({ drifted: 1, healed: 1, unresolved: 0 })
81+
})
82+
6083
it('leaves an org-covered member alone once the sync clears their personal limit', async () => {
6184
queueTableRows(schemaMock.subscription, [{ userId: 'user-1' }])
6285
queueTableRows(schemaMock.userStats, [{ currentUsageLimit: null }])

apps/sim/lib/billing/entitlement-drift.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,10 @@ export async function reconcileEntitlementLimitDrift(): Promise<EntitlementDrift
9191
await syncUsageLimitsFromSubscription(row.userId)
9292

9393
const [after] = await db
94-
.select({ currentUsageLimit: userStats.currentUsageLimit })
94+
.select({
95+
currentUsageLimit: userStats.currentUsageLimit,
96+
creditBalance: userStats.creditBalance,
97+
})
9598
.from(userStats)
9699
.where(eq(userStats.userId, row.userId))
97100
.limit(1)
@@ -102,7 +105,16 @@ export async function reconcileEntitlementLimitDrift(): Promise<EntitlementDrift
102105
*/
103106
if (after?.currentUsageLimit == null) continue
104107

105-
if (Number(after.currentUsageLimit) > Number(freeTierFloor)) {
108+
/**
109+
* Verified against the same credit-adjusted floor the query selected
110+
* on. Prepaid credits are additive headroom, so a user holding a
111+
* balance is still on the floor at `free + balance`; checking the bare
112+
* free-tier limit here would score that as healed and silence the one
113+
* signal this sweep exists to raise.
114+
*/
115+
const floor = Number(freeTierFloor) + Number(after.creditBalance ?? 0)
116+
117+
if (Number(after.currentUsageLimit) > floor) {
106118
healed++
107119
logger.info('Healed a paying user stuck at the free-tier limit', {
108120
userId: row.userId,
@@ -122,6 +134,7 @@ export async function reconcileEntitlementLimitDrift(): Promise<EntitlementDrift
122134
logger.error('Usage-limit sync left a paying user at the free-tier limit', {
123135
userId: row.userId,
124136
currentUsageLimit: after.currentUsageLimit,
137+
floor,
125138
})
126139
} catch (error) {
127140
unresolved++

0 commit comments

Comments
 (0)