Skip to content

Commit 24380ea

Browse files
committed
fix(billing,deployments): recover silently dropped post-event writes
1 parent 4bafd14 commit 24380ea

7 files changed

Lines changed: 455 additions & 40 deletions

File tree

apps/sim/app/api/cron/reconcile-billing-seats/route.ts

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger'
22
import { toError } from '@sim/utils/errors'
33
import { type NextRequest, NextResponse } from 'next/server'
44
import { verifyCronAuth } from '@/lib/auth/internal'
5+
import { reconcileEntitlementLimitDrift } from '@/lib/billing/entitlement-drift'
56
import { reconcileTeamSeatDrift } from '@/lib/billing/organizations/seat-drift'
67
import { OUTBOX_EVENT_TYPES } from '@/lib/billing/webhooks/outbox-handlers'
78
import { findDeadLetteredEvents } from '@/lib/core/outbox/service'
@@ -18,11 +19,12 @@ const BILLING_SYNC_EVENT_TYPES = [
1819
]
1920

2021
/**
21-
* Periodic billing-seat reconciliation. Self-heals Team organizations whose
22-
* stored seat count drifted from their member count, and reports any
23-
* dead-lettered Stripe seat/cancel sync events so a member who has access but
24-
* whose seat charge never synced is surfaced for manual remediation rather than
25-
* silently under-billed.
22+
* Periodic billing projection reconciliation. Self-heals Team organizations
23+
* whose stored seat count drifted from their member count, restores paying
24+
* users whose usage limit was left at the free-tier default by a failed
25+
* subscription webhook, and reports any dead-lettered Stripe seat/cancel sync
26+
* events so a member who has access but whose seat charge never synced is
27+
* surfaced for manual remediation rather than silently under-billed.
2628
*
2729
* Scheduled in helm/sim/values.yaml under cronjobs.jobs.reconcileBillingSeats.
2830
*/
@@ -36,6 +38,14 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
3638

3739
try {
3840
const drift = await reconcileTeamSeatDrift()
41+
const entitlementDrift = await reconcileEntitlementLimitDrift()
42+
43+
if (entitlementDrift.drifted > 0) {
44+
logger.warn(
45+
'Paying users were found at the free-tier usage limit — a subscription webhook failed to write their entitlement',
46+
{ requestId, ...entitlementDrift }
47+
)
48+
}
3949

4050
const deadLettered = await findDeadLetteredEvents(BILLING_SYNC_EVENT_TYPES)
4151
if (deadLettered.length > 0) {
@@ -54,20 +64,22 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
5464
)
5565
}
5666

57-
logger.info('Billing seat reconciliation completed', {
67+
logger.info('Billing reconciliation completed', {
5868
requestId,
5969
...drift,
70+
entitlementDrift,
6071
deadLetteredBillingSyncs: deadLettered.length,
6172
})
6273

6374
return NextResponse.json({
6475
success: true,
6576
requestId,
6677
drift,
78+
entitlementDrift,
6779
deadLetteredBillingSyncs: deadLettered.length,
6880
})
6981
} catch (error) {
70-
logger.error('Billing seat reconciliation failed', {
82+
logger.error('Billing reconciliation failed', {
7183
requestId,
7284
error: toError(error).message,
7385
})
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import {
5+
queueTableRows,
6+
resetDbChainMock,
7+
resetEnvFlagsMock,
8+
schemaMock,
9+
setEnvFlags,
10+
} from '@sim/testing'
11+
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
12+
13+
const { mockSyncUsageLimitsFromSubscription } = vi.hoisted(() => ({
14+
mockSyncUsageLimitsFromSubscription: vi.fn(),
15+
}))
16+
17+
vi.mock('@/lib/billing/core/usage', () => ({
18+
syncUsageLimitsFromSubscription: mockSyncUsageLimitsFromSubscription,
19+
}))
20+
21+
import { reconcileEntitlementLimitDrift } from '@/lib/billing/entitlement-drift'
22+
23+
afterAll(resetEnvFlagsMock)
24+
25+
describe('reconcileEntitlementLimitDrift', () => {
26+
beforeEach(() => {
27+
vi.clearAllMocks()
28+
resetDbChainMock()
29+
setEnvFlags({ isBillingEnabled: true })
30+
mockSyncUsageLimitsFromSubscription.mockResolvedValue(undefined)
31+
})
32+
33+
afterAll(resetDbChainMock)
34+
35+
it('heals a paying user whose limit never left the free-tier default', async () => {
36+
queueTableRows(schemaMock.subscription, [{ userId: 'user-1' }])
37+
queueTableRows(schemaMock.userStats, [{ currentUsageLimit: '30' }])
38+
39+
const result = await reconcileEntitlementLimitDrift()
40+
41+
expect(mockSyncUsageLimitsFromSubscription).toHaveBeenCalledWith('user-1')
42+
expect(result).toEqual({ drifted: 1, healed: 1, unresolved: 0 })
43+
})
44+
45+
/**
46+
* The canonical sync resolves entitlement from its own read of
47+
* `subscription`. If that read disagrees with the row this sweep matched it
48+
* writes nothing — the exact silent no-op that made the original incident
49+
* invisible — so the sweep must report it rather than count it as healed.
50+
*/
51+
it('reports a user the sync declined to lift off the floor', async () => {
52+
queueTableRows(schemaMock.subscription, [{ userId: 'user-1' }])
53+
queueTableRows(schemaMock.userStats, [{ currentUsageLimit: '5' }])
54+
55+
const result = await reconcileEntitlementLimitDrift()
56+
57+
expect(result).toEqual({ drifted: 1, healed: 0, unresolved: 1 })
58+
})
59+
60+
it('leaves an org-covered member alone once the sync clears their personal limit', async () => {
61+
queueTableRows(schemaMock.subscription, [{ userId: 'user-1' }])
62+
queueTableRows(schemaMock.userStats, [{ currentUsageLimit: null }])
63+
64+
const result = await reconcileEntitlementLimitDrift()
65+
66+
expect(result).toEqual({ drifted: 1, healed: 0, unresolved: 0 })
67+
})
68+
69+
it('counts a failing sync as unresolved without aborting the sweep', async () => {
70+
queueTableRows(schemaMock.subscription, [{ userId: 'user-1' }, { userId: 'user-2' }])
71+
queueTableRows(schemaMock.userStats, [{ currentUsageLimit: '30' }])
72+
mockSyncUsageLimitsFromSubscription.mockRejectedValueOnce(new Error('db unavailable'))
73+
74+
const result = await reconcileEntitlementLimitDrift()
75+
76+
expect(mockSyncUsageLimitsFromSubscription).toHaveBeenCalledTimes(2)
77+
expect(result).toEqual({ drifted: 2, healed: 1, unresolved: 1 })
78+
})
79+
80+
it('does nothing when billing is disabled', async () => {
81+
setEnvFlags({ isBillingEnabled: false })
82+
83+
const result = await reconcileEntitlementLimitDrift()
84+
85+
expect(mockSyncUsageLimitsFromSubscription).not.toHaveBeenCalled()
86+
expect(result).toEqual({ drifted: 0, healed: 0, unresolved: 0 })
87+
})
88+
})
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import { db } from '@sim/db'
2+
import { subscription, userStats } from '@sim/db/schema'
3+
import { createLogger } from '@sim/logger'
4+
import { toError } from '@sim/utils/errors'
5+
import { and, eq, inArray, isNotNull, like, or, sql } from 'drizzle-orm'
6+
import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage'
7+
import { ENTITLED_SUBSCRIPTION_STATUSES, getFreeTierLimit } from '@/lib/billing/subscriptions/utils'
8+
import { isBillingEnabled } from '@/lib/core/config/env-flags'
9+
10+
const logger = createLogger('EntitlementDriftSweep')
11+
12+
/** Max users healed per sweep run so a mass-drift event can't run away. */
13+
const MAX_RECONCILES_PER_RUN = 100
14+
15+
export interface EntitlementDriftSweepResult {
16+
/** Paying users found sitting at or below their free-tier floor. */
17+
drifted: number
18+
/** How many of those the canonical sync lifted off the floor this run. */
19+
healed: number
20+
/** Drifted users the sync declined to lift — always a bug, never expected. */
21+
unresolved: number
22+
}
23+
24+
/**
25+
* Periodic backstop for the personal usage-limit projection.
26+
*
27+
* `user_stats.current_usage_limit` is written inline by the Stripe
28+
* subscription webhook (`onSubscriptionComplete` → `syncSubscriptionUsageLimits`),
29+
* behind five other statements that can each throw. There is no outbox event
30+
* and no retry: if any of them fails, a customer who paid for Pro keeps the
31+
* $5 free-tier limit indefinitely, with a perfectly correct `subscription`
32+
* row next to it. That is exactly what a `jsonb_typeof`-on-a-`json`-column
33+
* regression in the shared idempotency service did between 2026-08-05 and
34+
* 2026-08-10, and nothing surfaced it — it took manual forensics weeks later.
35+
*
36+
* The detector is deliberately narrow: a Pro-plan user whose limit is at or
37+
* below their free-plus-prepaid floor cannot be a legitimate state, because
38+
* every paid tier's minimum is strictly above the free tier and
39+
* `canEditUsageLimit` only ever lets a user raise their own limit. Partial
40+
* drift (stuck at a lower *paid* tier after an upgrade) is not covered here —
41+
* catching that would mean restating the per-tier dollar math in SQL, and
42+
* this file must not become a second source of truth for it.
43+
*
44+
* Healing routes through `syncUsageLimitsFromSubscription`, which re-derives
45+
* everything canonically — org-over-personal priority included, so a member
46+
* covered by a paid org is correctly left alone rather than given a personal
47+
* limit. That makes a false-positive candidate harmless, which is why the
48+
* cheap filter is safe. The entitled branch of that sync raises with
49+
* `greatest(...)`, so a limit a user raised themselves is never lowered.
50+
*/
51+
export async function reconcileEntitlementLimitDrift(): Promise<EntitlementDriftSweepResult> {
52+
if (!isBillingEnabled) {
53+
return { drifted: 0, healed: 0, unresolved: 0 }
54+
}
55+
56+
const freeTierFloor = getFreeTierLimit().toString()
57+
58+
/**
59+
* Joining `user_stats` on the subscription's `reference_id` also does the
60+
* personal-vs-org scoping for free: an org-referenced subscription's
61+
* `reference_id` is an organization id and matches no `user_stats` row.
62+
*/
63+
const driftedRows = await db
64+
.select({ userId: subscription.referenceId })
65+
.from(subscription)
66+
.innerJoin(userStats, eq(userStats.userId, subscription.referenceId))
67+
.where(
68+
and(
69+
inArray(subscription.status, ENTITLED_SUBSCRIPTION_STATUSES),
70+
or(eq(subscription.plan, 'pro'), like(subscription.plan, 'pro\\_%')),
71+
isNotNull(userStats.currentUsageLimit),
72+
sql`${userStats.currentUsageLimit} <= ${freeTierFloor}::numeric + coalesce(${userStats.creditBalance}, 0)`
73+
)
74+
)
75+
.orderBy(sql`random()`)
76+
77+
const batch = driftedRows.slice(0, MAX_RECONCILES_PER_RUN)
78+
if (driftedRows.length > batch.length) {
79+
logger.warn('Entitlement drift exceeded the per-run cap; remainder deferred to later runs', {
80+
drifted: driftedRows.length,
81+
cap: MAX_RECONCILES_PER_RUN,
82+
deferred: driftedRows.length - batch.length,
83+
})
84+
}
85+
86+
let healed = 0
87+
let unresolved = 0
88+
89+
for (const row of batch) {
90+
try {
91+
await syncUsageLimitsFromSubscription(row.userId)
92+
93+
const [after] = await db
94+
.select({ currentUsageLimit: userStats.currentUsageLimit })
95+
.from(userStats)
96+
.where(eq(userStats.userId, row.userId))
97+
.limit(1)
98+
99+
/**
100+
* A null limit means the sync resolved this user as org-scoped and
101+
* cleared their personal limit — correct, not drift.
102+
*/
103+
if (after?.currentUsageLimit == null) continue
104+
105+
if (Number(after.currentUsageLimit) > Number(freeTierFloor)) {
106+
healed++
107+
logger.info('Healed a paying user stuck at the free-tier limit', {
108+
userId: row.userId,
109+
currentUsageLimit: after.currentUsageLimit,
110+
})
111+
continue
112+
}
113+
114+
unresolved++
115+
/**
116+
* The canonical sync ran and still left a paying user on the floor. It
117+
* resolves entitlement from its own read of `subscription`, so this
118+
* means that read disagrees with the row this sweep just matched — the
119+
* sync is refusing, not the webhook. Loud because it is unreachable by
120+
* design.
121+
*/
122+
logger.error('Usage-limit sync left a paying user at the free-tier limit', {
123+
userId: row.userId,
124+
currentUsageLimit: after.currentUsageLimit,
125+
})
126+
} catch (error) {
127+
unresolved++
128+
logger.error('Failed to reconcile entitlement drift for user', {
129+
userId: row.userId,
130+
error: toError(error).message,
131+
})
132+
}
133+
}
134+
135+
return { drifted: driftedRows.length, healed, unresolved }
136+
}

0 commit comments

Comments
 (0)