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