Skip to content

Commit f97badf

Browse files
committed
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).
1 parent ae6ac39 commit f97badf

34 files changed

Lines changed: 32400 additions & 104 deletions

‎apps/sim/app/workspace/[workspaceId]/settings/components/general/components/delete-account-modal.tsx‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { createLogger } from '@sim/logger'
66
import { sleep } from '@sim/utils/helpers'
77
import { formatQuotedNameList, normalizeEmail } from '@sim/utils/string'
88
import { signOut } from '@/lib/auth/client'
9+
import { useDeploymentShape } from '@/lib/core/config/deployment-shape'
910
import { useAccountDeletionPlan, useDeleteAccount } from '@/hooks/queries/account-deletion'
1011
import { clearUserData } from '@/stores'
1112

@@ -42,6 +43,7 @@ function names(workspaces: { name: string }[]): string {
4243
*/
4344
export function DeleteAccountModal({ open, onOpenChange, email }: DeleteAccountModalProps) {
4445
const [confirmEmail, setConfirmEmail] = useState('')
46+
const { billingEnabled } = useDeploymentShape()
4547
const { data: plan, isFetching: isPlanFetching, error: planError } = useAccountDeletionPlan(open)
4648
const deleteAccount = useDeleteAccount()
4749

@@ -128,6 +130,12 @@ export function DeleteAccountModal({ open, onOpenChange, email }: DeleteAccountM
128130
workflows, chats, files, knowledge bases and credentials.{' '}
129131
<span className='text-[var(--text-error)]'>This cannot be undone.</span>
130132
</p>
133+
{billingEnabled && (
134+
<p className='text-[var(--text-secondary)] text-sm'>
135+
Free credits are granted once per person. A new account on this email or sign-in
136+
starts without them for a year.
137+
</p>
138+
)}
131139
{toDelete.length > 0 && (
132140
<p className='text-[var(--text-secondary)] text-sm'>
133141
{toDelete.length === 1 ? 'The workspace ' : 'The workspaces '}

‎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.ts‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1515,10 +1515,11 @@ export async function grantDashboardUserBalance(
15151515
.from(member)
15161516
.where(eq(member.userId, userId))
15171517
.limit(1)
1518+
// Only inserts when signup never created the row; that row keeps the column's default eligibility.
15181519
const initialUsageLimit =
15191520
initialMembership || isOrgScopedSubscription(initialSubscription, userId)
15201521
? null
1521-
: getPerUserMinimumLimit(initialSubscription).toString()
1522+
: getPerUserMinimumLimit(initialSubscription, true).toString()
15221523
await tx
15231524
.insert(userStats)
15241525
.values({
@@ -1533,6 +1534,7 @@ export async function grantDashboardUserBalance(
15331534
.select({
15341535
creditBalance: userStats.creditBalance,
15351536
currentUsageLimit: userStats.currentUsageLimit,
1537+
freeTierEligible: userStats.freeTierEligible,
15361538
})
15371539
.from(userStats)
15381540
.where(eq(userStats.userId, userId))
@@ -1566,7 +1568,9 @@ export async function grantDashboardUserBalance(
15661568
}
15671569

15681570
const grantDollarDelta = toDecimal(amountDollars).toString()
1569-
const usageLimitFallback = toDecimal(getPerUserMinimumLimit(billingSubscription))
1571+
const usageLimitFallback = toDecimal(
1572+
getPerUserMinimumLimit(billingSubscription, stats.freeTierEligible)
1573+
)
15701574
.plus(toDecimal(stats.creditBalance))
15711575
.toString()
15721576
const nextUsageLimit =

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

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ import {
9292
syncSubscriptionPlan,
9393
writeBillingInterval,
9494
} from '@/lib/billing/core/subscription'
95-
import { handleNewUser } from '@/lib/billing/core/usage'
95+
import { handleNewSignInAccount, handleNewUser } from '@/lib/billing/core/usage'
9696
import {
9797
ensureOrganizationForTeamSubscription,
9898
syncSubscriptionUsageLimits,
@@ -404,7 +404,7 @@ export const auth = betterAuth({
404404
}
405405

406406
try {
407-
await handleNewUser(user.id)
407+
await handleNewUser(user.id, context)
408408
} catch (error) {
409409
logger.error('[databaseHooks.user.create.after] Failed to initialize user stats', {
410410
userId: user.id,
@@ -655,6 +655,15 @@ export const auth = betterAuth({
655655
})
656656
}
657657

658+
try {
659+
await handleNewSignInAccount(account, context)
660+
} catch (error) {
661+
logger.error('[databaseHooks.account.create.after] Deleted-identity check failed', {
662+
userId: account.userId,
663+
error,
664+
})
665+
}
666+
658667
try {
659668
const [{ value: accountCount }] = await db
660669
.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

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

Lines changed: 92 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,21 @@ vi.mock('@/lib/messaging/email/unsubscribe', () => emailUnsubscribeMock)
4949

5050
vi.mock('@sim/platform-authz/workspace', () => workspaceAuthzMock)
5151

52+
const { mockIsDeletedAccountIdentity, mockUserHasDeletedAccountIdentity } = vi.hoisted(() => ({
53+
mockIsDeletedAccountIdentity: vi.fn(),
54+
mockUserHasDeletedAccountIdentity: vi.fn(),
55+
}))
56+
57+
vi.mock('@/lib/users/deleted-identity', () => ({
58+
isDeletedAccountIdentity: mockIsDeletedAccountIdentity,
59+
userHasDeletedAccountIdentity: mockUserHasDeletedAccountIdentity,
60+
}))
61+
5262
import {
63+
ensureUserStatsExists,
5364
getUserUsageLimit,
65+
handleNewSignInAccount,
66+
handleNewUser,
5467
maybeSendUsageThresholdEmail,
5568
syncUsageLimitsFromSubscription,
5669
} from '@/lib/billing/core/usage'
@@ -108,7 +121,7 @@ describe('getUserUsageLimit', () => {
108121

109122
it('heals a null limit to the free-tier default for free users', async () => {
110123
dbChainMockFns.limit.mockResolvedValueOnce([
111-
{ currentUsageLimit: null, creditBalance: '0.006' },
124+
{ currentUsageLimit: null, creditBalance: '0.006', freeTierEligible: true },
112125
])
113126
dbChainMockFns.returning.mockResolvedValueOnce([{ currentUsageLimit: '10.006' }])
114127
mockGetFreeTierLimit.mockReturnValue(10)
@@ -129,15 +142,17 @@ describe('getUserUsageLimit', () => {
129142
})
130143

131144
it('heals a null limit to the plan minimum for paid personal subscriptions', async () => {
132-
dbChainMockFns.limit.mockResolvedValueOnce([{ currentUsageLimit: null, creditBalance: '1.25' }])
145+
dbChainMockFns.limit.mockResolvedValueOnce([
146+
{ currentUsageLimit: null, creditBalance: '1.25', freeTierEligible: true },
147+
])
133148
dbChainMockFns.returning.mockResolvedValueOnce([{ currentUsageLimit: '41.25' }])
134149
mockHasPaidSubscriptionStatus.mockReturnValue(true)
135150
mockGetPerUserMinimumLimit.mockReturnValue(40)
136151

137152
const limit = await getUserUsageLimit('user-1', PRO_SUBSCRIPTION)
138153

139154
expect(limit).toBe(41.25)
140-
expect(mockGetPerUserMinimumLimit).toHaveBeenCalledWith(PRO_SUBSCRIPTION)
155+
expect(mockGetPerUserMinimumLimit).toHaveBeenCalledWith(PRO_SUBSCRIPTION, true)
141156
expect(dbChainMockFns.set).toHaveBeenCalledWith({
142157
currentUsageLimit: '41.25',
143158
usageLimitUpdatedAt: expect.any(Date),
@@ -221,15 +236,18 @@ describe('syncUsageLimitsFromSubscription', () => {
221236
})
222237
mockIsOrgScopedSubscription.mockReturnValue(true)
223238
mockGetPerUserMinimumLimit.mockReturnValue(minimum)
224-
dbChainMockFns.limit.mockResolvedValueOnce([{ currentUsageLimit: '80', creditBalance: '1' }])
239+
dbChainMockFns.limit.mockResolvedValueOnce([
240+
{ currentUsageLimit: '80', creditBalance: '1', freeTierEligible: true },
241+
])
225242

226243
await syncUsageLimitsFromSubscription('user-1')
227244

228-
expect(mockGetHighestPriorityPersonalSubscription).toHaveBeenCalledExactlyOnceWith('user-1', {
229-
onError: 'throw',
230-
})
245+
expect(mockGetHighestPriorityPersonalSubscription).toHaveBeenCalledExactlyOnceWith(
246+
'user-1',
247+
expect.objectContaining({ onError: 'throw' })
248+
)
231249
expect(mockGetHighestPrioritySubscription).not.toHaveBeenCalled()
232-
expect(mockGetPerUserMinimumLimit).toHaveBeenCalledWith(personalSubscription)
250+
expect(mockGetPerUserMinimumLimit).toHaveBeenCalledWith(personalSubscription, true)
233251
const update = dbChainMockFns.set.mock.calls[0]?.[0]
234252
expect(update?.currentUsageLimit).not.toBeNull()
235253
expect(JSON.stringify(update?.currentUsageLimit)).toContain('greatest')
@@ -264,7 +282,7 @@ describe('syncUsageLimitsFromSubscription', () => {
264282
mockGetHighestPriorityPersonalSubscription.mockResolvedValue(null)
265283
mockGetPerUserMinimumLimit.mockReturnValue(10)
266284
dbChainMockFns.limit.mockResolvedValueOnce([
267-
{ currentUsageLimit: null, creditBalance: '0.006' },
285+
{ currentUsageLimit: null, creditBalance: '0.006', freeTierEligible: true },
268286
])
269287

270288
await syncUsageLimitsFromSubscription('user-1')
@@ -280,7 +298,7 @@ describe('syncUsageLimitsFromSubscription', () => {
280298
mockGetHighestPriorityPersonalSubscription.mockResolvedValue(null)
281299
mockGetPerUserMinimumLimit.mockReturnValue(10)
282300
dbChainMockFns.limit.mockResolvedValueOnce([
283-
{ currentUsageLimit: '100', creditBalance: '0.006' },
301+
{ currentUsageLimit: '100', creditBalance: '0.006', freeTierEligible: true },
284302
])
285303

286304
await syncUsageLimitsFromSubscription('user-1')
@@ -376,3 +394,67 @@ describe('maybeSendUsageThresholdEmail', () => {
376394
])
377395
})
378396
})
397+
398+
/**
399+
* A returning deleted-account identity loses the free allowance by having
400+
* `free_tier_eligible` cleared. These guard the two edges where that went wrong:
401+
* a stats row created by the fallback after signup failed, and a first sign-in
402+
* linked to a user that was not created in the same request.
403+
*/
404+
describe('withholding the free allowance from a returning deleted identity', () => {
405+
const GITHUB = { userId: 'user-1', providerId: 'github', accountId: 'gh-1' }
406+
407+
const forfeited = () =>
408+
dbChainMockFns.set.mock.calls.some(([values]) => values?.freeTierEligible === false)
409+
410+
beforeEach(() => {
411+
resetDbChainMock()
412+
mockIsOrgScopedSubscription.mockReturnValue(false)
413+
mockGetHighestPriorityPersonalSubscription.mockResolvedValue(null)
414+
mockIsDeletedAccountIdentity.mockReset().mockResolvedValue(true)
415+
mockUserHasDeletedAccountIdentity.mockReset().mockResolvedValue(false)
416+
queueTableRows(schemaMock.userStats, [
417+
{ currentUsageLimit: '5', creditBalance: '0', freeTierEligible: false },
418+
])
419+
queueTableRows(schemaMock.account, [{ value: 1 }])
420+
})
421+
422+
it('withholds it for a deleted sign-in that is the first account of a user created in the same request', async () => {
423+
const signupRequest = {}
424+
await handleNewUser('user-1', signupRequest)
425+
426+
await handleNewSignInAccount(GITHUB, signupRequest)
427+
428+
expect(forfeited()).toBe(true)
429+
})
430+
431+
it('never withholds it when the sign-in is linked to a user created in another request', async () => {
432+
await handleNewUser('user-1', {})
433+
434+
await handleNewSignInAccount(GITHUB, {})
435+
await handleNewSignInAccount(GITHUB, null)
436+
437+
expect(forfeited()).toBe(false)
438+
})
439+
440+
it('checks a stats row only the fallback created, and withholds a returning identity’s allowance', async () => {
441+
mockUserHasDeletedAccountIdentity.mockResolvedValue(true)
442+
dbChainMockFns.returning.mockResolvedValueOnce([])
443+
444+
await ensureUserStatsExists('user-1')
445+
expect(mockUserHasDeletedAccountIdentity).not.toHaveBeenCalled()
446+
447+
dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'stats-1' }])
448+
await ensureUserStatsExists('user-1')
449+
450+
expect(forfeited()).toBe(true)
451+
})
452+
453+
it('never fails the caller when withholding from a fallback row fails', async () => {
454+
mockUserHasDeletedAccountIdentity.mockResolvedValue(true)
455+
dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'stats-1' }])
456+
dbChainMockFns.transaction.mockRejectedValueOnce(new Error('db unavailable'))
457+
458+
await expect(ensureUserStatsExists('user-1')).resolves.toBeUndefined()
459+
})
460+
})

0 commit comments

Comments
 (0)