Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions apps/sim/ee/scim/lib/application/users/provision-user.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,7 @@ describe('provisionScimUser', () => {
expect(error.message).toContain('no available seats')
expect(mocks.insertScimUser).not.toHaveBeenCalled()
expect(mocks.reconcile).not.toHaveBeenCalled()
expect(mocks.deleteAccount).toHaveBeenCalledWith('u-new')
expect(mocks.deleteAccount).toHaveBeenCalledWith('u-new', { recordIdentity: false })
expect(mocks.recordAudit).not.toHaveBeenCalled()
expect(mocks.reconcileSeats).not.toHaveBeenCalled()
})
Expand All @@ -221,7 +221,7 @@ describe('provisionScimUser', () => {
mocks.insertScimUser.mockRejectedValue(new Error('lock timeout'))
const error = await run(attributes()).catch((caught) => caught)
expect(error.message).toBe('lock timeout')
expect(mocks.deleteAccount).toHaveBeenCalledWith('u-new')
expect(mocks.deleteAccount).toHaveBeenCalledWith('u-new', { recordIdentity: false })
})

it('never deletes a pre-existing account when linking fails', async () => {
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/ee/scim/lib/application/users/provision-user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -242,7 +242,7 @@ export const provisionScimUser = defineAuthorizedScimUseCase({
* directory's retry starts from a clean slate instead of a half-state.
*/
if (createdAccount) {
await deleteUserAccount(userId).catch((cleanupError) =>
await deleteUserAccount(userId, { recordIdentity: false }).catch((cleanupError) =>
logger.error('Failed to remove an account after provisioning was refused', {
userId,
cleanupError,
Expand Down
8 changes: 6 additions & 2 deletions apps/sim/lib/admin/dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1515,10 +1515,11 @@ export async function grantDashboardUserBalance(
.from(member)
.where(eq(member.userId, userId))
.limit(1)
// Only inserts when signup never created the row; that row keeps the column's default eligibility.
const initialUsageLimit =
initialMembership || isOrgScopedSubscription(initialSubscription, userId)
? null
: getPerUserMinimumLimit(initialSubscription).toString()
: getPerUserMinimumLimit(initialSubscription, true).toString()
Comment thread
waleedlatif1 marked this conversation as resolved.
Outdated
await tx
.insert(userStats)
.values({
Expand All @@ -1533,6 +1534,7 @@ export async function grantDashboardUserBalance(
.select({
creditBalance: userStats.creditBalance,
currentUsageLimit: userStats.currentUsageLimit,
freeTierEligible: userStats.freeTierEligible,
})
.from(userStats)
.where(eq(userStats.userId, userId))
Expand Down Expand Up @@ -1566,7 +1568,9 @@ export async function grantDashboardUserBalance(
}

const grantDollarDelta = toDecimal(amountDollars).toString()
const usageLimitFallback = toDecimal(getPerUserMinimumLimit(billingSubscription))
const usageLimitFallback = toDecimal(
getPerUserMinimumLimit(billingSubscription, stats.freeTierEligible)
)
.plus(toDecimal(stats.creditBalance))
.toString()
const nextUsageLimit =
Expand Down
15 changes: 12 additions & 3 deletions apps/sim/lib/auth/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ import {
syncSubscriptionPlan,
writeBillingInterval,
} from '@/lib/billing/core/subscription'
import { handleNewUser } from '@/lib/billing/core/usage'
import { handleNewSignInAccount, handleNewUser } from '@/lib/billing/core/usage'
import {
ensureOrganizationForTeamSubscription,
syncSubscriptionUsageLimits,
Expand Down Expand Up @@ -405,7 +405,7 @@ export const auth = betterAuth({
}

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

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

try {
await handleNewSignInAccount(account, context)
} catch (error) {
logger.error('[databaseHooks.account.create.after] Deleted-identity check failed', {
userId: account.userId,
error,
})
}

try {
const [{ value: accountCount }] = await db
.select({ value: count() })
Expand Down
4 changes: 3 additions & 1 deletion apps/sim/lib/billing/core/billing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
} from '@/lib/billing/plan-helpers'
import {
ENTITLED_SUBSCRIPTION_STATUSES,
getFreeTierAllowance,
getFreeTierLimit,
getPlanPricing,
hasPaidSubscriptionStatus,
Expand Down Expand Up @@ -276,6 +277,7 @@ export async function getPersonalBillingSummary(userId: string, executor: DbClie
creditBalance: userStats.creditBalance,
billingBlocked: userStats.billingBlocked,
billingBlockedReason: userStats.billingBlockedReason,
freeTierEligible: userStats.freeTierEligible,
})
.from(userStats)
.where(eq(userStats.userId, userId))
Expand Down Expand Up @@ -326,7 +328,7 @@ export async function getPersonalBillingSummary(userId: string, executor: DbClie
const effectiveCurrentUsage = Math.max(0, toNumber(currentUsage) - refreshDeduction)
const usageLimit = stats.currentUsageLimit
? toNumber(toDecimal(stats.currentUsageLimit))
: getFreeTierLimit()
: getFreeTierAllowance(stats.freeTierEligible)
const percentUsed = usageLimit > 0 ? (effectiveCurrentUsage / usageLimit) * 100 : 0
const isExceeded = effectiveCurrentUsage >= usageLimit
const isWarning = !isExceeded && percentUsed >= 80
Expand Down
121 changes: 111 additions & 10 deletions apps/sim/lib/billing/core/usage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,21 @@ vi.mock('@/lib/messaging/email/unsubscribe', () => emailUnsubscribeMock)

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

const { mockIsDeletedAccountIdentity, mockUserHasDeletedAccountIdentity } = vi.hoisted(() => ({
mockIsDeletedAccountIdentity: vi.fn(),
mockUserHasDeletedAccountIdentity: vi.fn(),
}))

vi.mock('@/lib/users/deleted-identity', () => ({
isDeletedAccountIdentity: mockIsDeletedAccountIdentity,
userHasDeletedAccountIdentity: mockUserHasDeletedAccountIdentity,
}))

import {
ensureUserStatsExists,
getUserUsageLimit,
handleNewSignInAccount,
handleNewUser,
maybeSendUsageThresholdEmail,
syncUsageLimitsFromSubscription,
} from '@/lib/billing/core/usage'
Expand Down Expand Up @@ -108,7 +121,7 @@ describe('getUserUsageLimit', () => {

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

it('heals a null limit to the plan minimum for paid personal subscriptions', async () => {
dbChainMockFns.limit.mockResolvedValueOnce([{ currentUsageLimit: null, creditBalance: '1.25' }])
dbChainMockFns.limit.mockResolvedValueOnce([
{ currentUsageLimit: null, creditBalance: '1.25', freeTierEligible: true },
])
dbChainMockFns.returning.mockResolvedValueOnce([{ currentUsageLimit: '41.25' }])
mockHasPaidSubscriptionStatus.mockReturnValue(true)
mockGetPerUserMinimumLimit.mockReturnValue(40)

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

expect(limit).toBe(41.25)
expect(mockGetPerUserMinimumLimit).toHaveBeenCalledWith(PRO_SUBSCRIPTION)
expect(mockGetPerUserMinimumLimit).toHaveBeenCalledWith(PRO_SUBSCRIPTION, true)
expect(dbChainMockFns.set).toHaveBeenCalledWith({
currentUsageLimit: '41.25',
usageLimitUpdatedAt: expect.any(Date),
Expand Down Expand Up @@ -221,15 +236,18 @@ describe('syncUsageLimitsFromSubscription', () => {
})
mockIsOrgScopedSubscription.mockReturnValue(true)
mockGetPerUserMinimumLimit.mockReturnValue(minimum)
dbChainMockFns.limit.mockResolvedValueOnce([{ currentUsageLimit: '80', creditBalance: '1' }])
dbChainMockFns.limit.mockResolvedValueOnce([
{ currentUsageLimit: '80', creditBalance: '1', freeTierEligible: true },
])

await syncUsageLimitsFromSubscription('user-1')

expect(mockGetHighestPriorityPersonalSubscription).toHaveBeenCalledExactlyOnceWith('user-1', {
onError: 'throw',
})
expect(mockGetHighestPriorityPersonalSubscription).toHaveBeenCalledExactlyOnceWith(
'user-1',
expect.objectContaining({ onError: 'throw' })
)
expect(mockGetHighestPrioritySubscription).not.toHaveBeenCalled()
expect(mockGetPerUserMinimumLimit).toHaveBeenCalledWith(personalSubscription)
expect(mockGetPerUserMinimumLimit).toHaveBeenCalledWith(personalSubscription, true)
const update = dbChainMockFns.set.mock.calls[0]?.[0]
expect(update?.currentUsageLimit).not.toBeNull()
expect(JSON.stringify(update?.currentUsageLimit)).toContain('greatest')
Expand Down Expand Up @@ -264,7 +282,7 @@ describe('syncUsageLimitsFromSubscription', () => {
mockGetHighestPriorityPersonalSubscription.mockResolvedValue(null)
mockGetPerUserMinimumLimit.mockReturnValue(10)
dbChainMockFns.limit.mockResolvedValueOnce([
{ currentUsageLimit: null, creditBalance: '0.006' },
{ currentUsageLimit: null, creditBalance: '0.006', freeTierEligible: true },
])

await syncUsageLimitsFromSubscription('user-1')
Expand All @@ -280,7 +298,7 @@ describe('syncUsageLimitsFromSubscription', () => {
mockGetHighestPriorityPersonalSubscription.mockResolvedValue(null)
mockGetPerUserMinimumLimit.mockReturnValue(10)
dbChainMockFns.limit.mockResolvedValueOnce([
{ currentUsageLimit: '100', creditBalance: '0.006' },
{ currentUsageLimit: '100', creditBalance: '0.006', freeTierEligible: true },
])

await syncUsageLimitsFromSubscription('user-1')
Expand Down Expand Up @@ -376,3 +394,86 @@ describe('maybeSendUsageThresholdEmail', () => {
])
})
})

/**
* A returning deleted-account identity loses the free allowance by having
* `free_tier_eligible` cleared. These guard the two edges where that went wrong:
* a stats row created by the fallback after signup failed, and a first sign-in
* linked to a user that was not created in the same request.
*/
describe('withholding the free allowance from a returning deleted identity', () => {
const GITHUB = { userId: 'user-1', providerId: 'github', accountId: 'gh-1' }

const forfeited = () =>
dbChainMockFns.set.mock.calls.some(([values]) => values?.freeTierEligible === false)

beforeEach(() => {
resetDbChainMock()
mockIsOrgScopedSubscription.mockReturnValue(false)
mockGetHighestPriorityPersonalSubscription.mockResolvedValue(null)
mockIsDeletedAccountIdentity.mockReset().mockResolvedValue(true)
mockUserHasDeletedAccountIdentity.mockReset().mockResolvedValue(false)
queueTableRows(schemaMock.userStats, [
{ currentUsageLimit: '5', creditBalance: '0', freeTierEligible: false },
])
queueTableRows(schemaMock.account, [{ value: 1 }])
})

it('withholds it for a deleted sign-in that is the first account of a user created in the same request', async () => {
const signupRequest = {}
await handleNewUser('user-1', signupRequest)

await handleNewSignInAccount(GITHUB, signupRequest)

expect(forfeited()).toBe(true)
})

it('never withholds it when the sign-in is linked to a user created in another request', async () => {
await handleNewUser('user-1', {})

await handleNewSignInAccount(GITHUB, {})
await handleNewSignInAccount(GITHUB, null)

expect(forfeited()).toBe(false)
})

it('checks a stats row only the fallback created, and withholds a returning identity’s allowance', async () => {
mockUserHasDeletedAccountIdentity.mockResolvedValue(true)
dbChainMockFns.returning.mockResolvedValueOnce([])

await ensureUserStatsExists('user-1')
expect(mockUserHasDeletedAccountIdentity).not.toHaveBeenCalled()

dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'stats-1' }])
await ensureUserStatsExists('user-1')

expect(forfeited()).toBe(true)
})

it('checks a fallback row’s linked sign-ins only for a user created in the same request', async () => {
mockUserHasDeletedAccountIdentity.mockImplementation(
async (_userId: string, { includeSignIns }: { includeSignIns: boolean }) => includeSignIns
)
dbChainMockFns.returning
.mockResolvedValueOnce([{ id: 'stats-1' }])
.mockResolvedValueOnce([{ id: 'stats-1' }])

await ensureUserStatsExists('user-1', {})
await ensureUserStatsExists('user-1')
expect(forfeited()).toBe(false)

const signupRequest = {}
await handleNewUser('user-1', signupRequest)
dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'stats-1' }])
await ensureUserStatsExists('user-1', signupRequest)
expect(forfeited()).toBe(true)
})

it('never fails the caller when withholding from a fallback row fails', async () => {
mockUserHasDeletedAccountIdentity.mockResolvedValue(true)
dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'stats-1' }])
dbChainMockFns.transaction.mockRejectedValueOnce(new Error('db unavailable'))

await expect(ensureUserStatsExists('user-1')).resolves.toBeUndefined()
})
})
Loading
Loading