Skip to content

Commit f4daf59

Browse files
authored
fix(billing): stop account deletion from resetting the free allowance (#9141)
* fix(billing): stop account deletion from resetting the free allowance Self-service deletion cascades the usage ledger with the user row, and signup grants every new user row the free allowance, so deleting an account and signing up again on the same email, Google, GitHub, or Microsoft identity reset the one-time free credit. Deletion at the holder's request now keeps keyed digests (HMAC-SHA256 under a purpose-bound key derived from BETTER_AUTH_SECRET) of the account's canonical email and social sign-in subjects for 365 days, and a new account on a matching identity is created without the free allowance. The SCIM provisioning rollback records nothing, and nothing is kept where billing is disabled. - The allowance is a per-user `user_stats.free_tier_eligible` fact, so plan re-syncs, the null-limit heal, usage reads, and admin grants keep it withheld while prepaid credit and paid plans are unaffected. - Signup checks the email and any sign-in already linked; a social sign-in committed after the user hook is checked only when it was created in the same auth request as its user, so linking a sign-in to an existing or provisioned user later never withholds anything. - A stats row created by the fallback (signup failed to create it) gets the same check, failing open without failing the caller. - Forfeiting sets the flag and re-derives the limit in one transaction. - The deletion dialog says free credits are granted once per person. Migration 0433 adds the `deleted_account_identity` table and the `free_tier_eligible` column (constant default, metadata-only). * fix(billing): never let the identity record block an erasure, and keep later links unchecked - Recording a deleted account's identity digests now runs in a savepoint and only logs on failure, so an anti-abuse write can never abort the holder's deletion; before this change nothing of the kind could block it. - A stats row the fallback creates gets only the check signup would have made: the email always, and the linked sign-ins only for a user created in the same auth request. A legacy user without a stats row who links a sign-in later is no longer withheld through the fallback. - Docs say exactly who is checked: every user Better Auth creates, SCIM- and SSO-provisioned ones included, is checked by email at creation; only a sign-in linked in a later request never withholds anything. * fix(billing): keep deleted identities 30 days, drop Google subjects, leave the dialog unchanged - Identity digests now expire 30 days after the deletion, inside the privacy policy's 30-day Account Data window. - Google subject ids are no longer recorded or matched (the policy keeps Google API data only 7 days after deletion); a Google re-signup is still caught by its email digest. GitHub and Microsoft subjects are kept. - The deletion dialog is unchanged from staging. - Callers added on staging pass the deletion option. * improvement(billing): skip locked rows in the identity purge, share the auth-request lookup Concurrent account deletions no longer wait on each other's purge batch. syncUsageLimitsFromSubscription reads in parallel again, and email canonicalization reuses normalizeEmail. * fix(billing): drain expired identity digests hourly, record only verified emails - Expired deleted-account digests were removed only while recording a later deletion, so with no later deletions they outlived the 30-day window. The hourly OAuth-token cleanup cron now drains them first in bounded batches (indexed expires_at order, SKIP LOCKED, batch and time caps); a failed purge is logged and never holds up the token sweep. - A deleted account's email is recorded only when it was verified, so an unverified signup can never cost the address's real owner the allowance. GitHub and Microsoft subjects are still recorded; matching is unchanged. - The admin credit grant creates a missing stats row with the same email check as every other row creation instead of assuming eligibility, looked up before its transaction.
1 parent d44cee4 commit f4daf59

38 files changed

Lines changed: 32687 additions & 110 deletions

‎apps/sim/app/api/cron/cleanup-oauth-tokens/route.ts‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { getErrorMessage } from '@sim/utils/errors'
33
import { type NextRequest, NextResponse } from 'next/server'
44
import { verifyCronAuth } from '@/lib/auth/internal'
55
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
6+
import { purgeExpiredDeletedAccountIdentities } from '@/lib/users/deleted-identity'
67
import { runCleanupOAuthTokens } from '@/background/cleanup-oauth-tokens'
78

89
export const dynamic = 'force-dynamic'
@@ -14,14 +15,22 @@ const logger = createLogger('CleanupOAuthTokensAPI')
1415
* Retention sweep for lapsed OAuth token rows. Issuance state does not gate
1516
* retention: a deployment that disables the provider must still drain rows
1617
* created while it was enabled.
18+
*
19+
* The same hourly run drains expired deleted-account identity digests first,
20+
* within its own short budget, so they never outlive their retention window. A
21+
* failed purge is logged and never holds up the token sweep.
1722
*/
1823
export const GET = withRouteHandler(async (request: NextRequest) => {
1924
const authError = verifyCronAuth(request, 'OAuth token cleanup')
2025
if (authError) return authError
2126

2227
try {
28+
const deletedAccountIdentities = await purgeExpiredDeletedAccountIdentities().catch((error) => {
29+
logger.error('Failed to purge expired deleted-account identities', { error })
30+
return null
31+
})
2332
const result = await runCleanupOAuthTokens()
24-
return NextResponse.json({ success: true, ...result })
33+
return NextResponse.json({ success: true, ...result, deletedAccountIdentities })
2534
} catch (error) {
2635
logger.error('Failed to sweep expired OAuth tokens', { error })
2736
return NextResponse.json(

‎apps/sim/ee/scim/lib/application/users/provision-user.test.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -211,7 +211,7 @@ describe('provisionScimUser', () => {
211211
expect(error.message).toContain('no available seats')
212212
expect(mocks.insertScimUser).not.toHaveBeenCalled()
213213
expect(mocks.reconcile).not.toHaveBeenCalled()
214-
expect(mocks.deleteAccount).toHaveBeenCalledWith('u-new')
214+
expect(mocks.deleteAccount).toHaveBeenCalledWith('u-new', { recordIdentity: false })
215215
expect(mocks.recordAudit).not.toHaveBeenCalled()
216216
expect(mocks.reconcileSeats).not.toHaveBeenCalled()
217217
})
@@ -221,7 +221,7 @@ describe('provisionScimUser', () => {
221221
mocks.insertScimUser.mockRejectedValue(new Error('lock timeout'))
222222
const error = await run(attributes()).catch((caught) => caught)
223223
expect(error.message).toBe('lock timeout')
224-
expect(mocks.deleteAccount).toHaveBeenCalledWith('u-new')
224+
expect(mocks.deleteAccount).toHaveBeenCalledWith('u-new', { recordIdentity: false })
225225
})
226226

227227
it('never deletes a pre-existing account when linking fails', async () => {

‎apps/sim/ee/scim/lib/application/users/provision-user.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -242,7 +242,7 @@ export const provisionScimUser = defineAuthorizedScimUseCase({
242242
* directory's retry starts from a clean slate instead of a half-state.
243243
*/
244244
if (createdAccount) {
245-
await deleteUserAccount(userId).catch((cleanupError) =>
245+
await deleteUserAccount(userId, { recordIdentity: false }).catch((cleanupError) =>
246246
logger.error('Failed to remove an account after provisioning was refused', {
247247
userId,
248248
cleanupError,

‎apps/sim/lib/admin/dashboard-credit-grant.test.ts‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ const hoistedMocks = vi.hoisted(() => ({
2121
billingSubscriptions: [] as unknown[],
2222
idempotencyCalls: [] as { namespace: string; requestFingerprint: string }[],
2323
moveWorkspace: vi.fn(),
24+
userHasDeletedAccountIdentity: vi.fn(async () => false),
2425
}))
2526

2627
vi.mock('@sim/audit', () => auditMock)
@@ -53,6 +54,9 @@ vi.mock('@/lib/billing/enterprise-outbox', () => ({
5354
resolveEnterpriseMetadataIntent: vi.fn(),
5455
}))
5556
vi.mock('@/lib/core/outbox/service', () => outboxServiceMock)
57+
vi.mock('@/lib/users/deleted-identity', () => ({
58+
userHasDeletedAccountIdentity: hoistedMocks.userHasDeletedAccountIdentity,
59+
}))
5660

5761
import { grantDashboardOrganizationBalance, grantDashboardUserBalance } from '@/lib/admin/dashboard'
5862

@@ -150,6 +154,30 @@ describe('grantDashboardUserBalance', () => {
150154
)
151155
})
152156

157+
it('creates a missing stats row without the free allowance for a returning deleted identity', async () => {
158+
mocks.userHasDeletedAccountIdentity.mockResolvedValueOnce(true)
159+
queueTableRows(user, [{ id: 'user-1' }])
160+
queueTableRows(userStats, [
161+
{ creditBalance: '0', currentUsageLimit: '0', freeTierEligible: false },
162+
])
163+
mocks.billingSubscriptions = [null, null]
164+
dbChainMockFns.returning.mockResolvedValueOnce([
165+
{ creditBalance: '0.005', currentUsageLimit: '0.005' },
166+
])
167+
168+
await grantDashboardUserBalance(
169+
'user-1',
170+
0.005,
171+
undefined,
172+
'4f0f5c4e-3a43-4f39-9d55-6b1b2f6f2a10',
173+
{ id: 'admin-1', name: 'Admin', email: 'admin@sim.ai' }
174+
)
175+
176+
expect(dbChainMockFns.values).toHaveBeenCalledWith(
177+
expect.objectContaining({ userId: 'user-1', freeTierEligible: false, currentUsageLimit: '0' })
178+
)
179+
})
180+
153181
it('adds above a higher custom limit for an entitled personal subscription', async () => {
154182
const personalSubscription = {
155183
referenceId: 'user-1',

‎apps/sim/lib/admin/dashboard.ts‎

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ import { executeTransactionallyIdempotent } from '@/lib/core/idempotency/transac
7878
import { enqueueOutboxEvent } from '@/lib/core/outbox/service'
7979
import type { DbOrTx } from '@/lib/db/types'
8080
import { deliverRunStops } from '@/lib/mothership/request/application/stop-delivery'
81+
import { userHasDeletedAccountIdentity } from '@/lib/users/deleted-identity'
8182
import { ownedAttachableWorkspacesWhere } from '@/lib/workspaces/organization-workspaces'
8283

8384
interface PaginationInput {
@@ -1497,6 +1498,14 @@ export async function grantDashboardUserBalance(
14971498
actor: AdminMutationActor
14981499
) {
14991500
const normalizedReason = reason?.trim() || null
1501+
/**
1502+
* Decides the eligibility of a stats row this grant may have to create, with
1503+
* the same email check as every other row creation. Looked up before the
1504+
* transaction so it never holds a second pooled connection inside it.
1505+
*/
1506+
const freeTierEligibleIfCreated = !(await userHasDeletedAccountIdentity(userId, {
1507+
includeSignIns: false,
1508+
}))
15001509
const outcome = await db.transaction(async (tx) => {
15011510
await acquireUserBillingIdentityLock(tx, userId)
15021511
const [account] = await tx
@@ -1518,13 +1527,14 @@ export async function grantDashboardUserBalance(
15181527
const initialUsageLimit =
15191528
initialMembership || isOrgScopedSubscription(initialSubscription, userId)
15201529
? null
1521-
: getPerUserMinimumLimit(initialSubscription).toString()
1530+
: getPerUserMinimumLimit(initialSubscription, freeTierEligibleIfCreated).toString()
15221531
await tx
15231532
.insert(userStats)
15241533
.values({
15251534
id: generateId(),
15261535
userId,
15271536
currentUsageLimit: initialUsageLimit,
1537+
freeTierEligible: freeTierEligibleIfCreated,
15281538
usageLimitUpdatedAt: new Date(),
15291539
})
15301540
.onConflictDoNothing({ target: userStats.userId })
@@ -1533,6 +1543,7 @@ export async function grantDashboardUserBalance(
15331543
.select({
15341544
creditBalance: userStats.creditBalance,
15351545
currentUsageLimit: userStats.currentUsageLimit,
1546+
freeTierEligible: userStats.freeTierEligible,
15361547
})
15371548
.from(userStats)
15381549
.where(eq(userStats.userId, userId))
@@ -1566,7 +1577,9 @@ export async function grantDashboardUserBalance(
15661577
}
15671578

15681579
const grantDollarDelta = toDecimal(amountDollars).toString()
1569-
const usageLimitFallback = toDecimal(getPerUserMinimumLimit(billingSubscription))
1580+
const usageLimitFallback = toDecimal(
1581+
getPerUserMinimumLimit(billingSubscription, stats.freeTierEligible)
1582+
)
15701583
.plus(toDecimal(stats.creditBalance))
15711584
.toString()
15721585
const nextUsageLimit =

‎apps/sim/lib/auth/auth.ts‎

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ import {
9393
syncSubscriptionPlan,
9494
writeBillingInterval,
9595
} from '@/lib/billing/core/subscription'
96-
import { handleNewUser } from '@/lib/billing/core/usage'
96+
import { handleNewSignInAccount, handleNewUser } from '@/lib/billing/core/usage'
9797
import {
9898
ensureOrganizationForTeamSubscription,
9999
syncSubscriptionUsageLimits,
@@ -405,7 +405,7 @@ export const auth = betterAuth({
405405
}
406406

407407
try {
408-
await handleNewUser(user.id)
408+
await handleNewUser(user.id, context)
409409
} catch (error) {
410410
logger.error('[databaseHooks.user.create.after] Failed to initialize user stats', {
411411
userId: user.id,
@@ -651,7 +651,7 @@ export const auth = betterAuth({
651651

652652
try {
653653
const { ensureUserStatsExists } = await import('@/lib/billing/core/usage')
654-
await ensureUserStatsExists(account.userId)
654+
await ensureUserStatsExists(account.userId, context)
655655
} catch (error) {
656656
logger.error('[databaseHooks.account.create.after] Failed to ensure user stats', {
657657
userId: account.userId,
@@ -660,6 +660,15 @@ export const auth = betterAuth({
660660
})
661661
}
662662

663+
try {
664+
await handleNewSignInAccount(account, context)
665+
} catch (error) {
666+
logger.error('[databaseHooks.account.create.after] Deleted-identity check failed', {
667+
userId: account.userId,
668+
error,
669+
})
670+
}
671+
663672
try {
664673
const [{ value: accountCount }] = await db
665674
.select({ value: count() })

‎apps/sim/lib/billing/core/billing.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
} from '@/lib/billing/plan-helpers'
2323
import {
2424
ENTITLED_SUBSCRIPTION_STATUSES,
25+
getFreeTierAllowance,
2526
getFreeTierLimit,
2627
getPlanPricing,
2728
hasPaidSubscriptionStatus,
@@ -276,6 +277,7 @@ export async function getPersonalBillingSummary(userId: string, executor: DbClie
276277
creditBalance: userStats.creditBalance,
277278
billingBlocked: userStats.billingBlocked,
278279
billingBlockedReason: userStats.billingBlockedReason,
280+
freeTierEligible: userStats.freeTierEligible,
279281
})
280282
.from(userStats)
281283
.where(eq(userStats.userId, userId))
@@ -326,7 +328,7 @@ export async function getPersonalBillingSummary(userId: string, executor: DbClie
326328
const effectiveCurrentUsage = Math.max(0, toNumber(currentUsage) - refreshDeduction)
327329
const usageLimit = stats.currentUsageLimit
328330
? toNumber(toDecimal(stats.currentUsageLimit))
329-
: getFreeTierLimit()
331+
: getFreeTierAllowance(stats.freeTierEligible)
330332
const percentUsed = usageLimit > 0 ? (effectiveCurrentUsage / usageLimit) * 100 : 0
331333
const isExceeded = effectiveCurrentUsage >= usageLimit
332334
const isWarning = !isExceeded && percentUsed >= 80

0 commit comments

Comments
 (0)