Skip to content

Commit fb74b4b

Browse files
authored
fix(billing): withhold the free allowance from Gmail aliases of an existing account (#9418)
* fix(billing): withhold the free allowance from Gmail aliases of an existing account * fix(billing): count only an earlier account as the Gmail inbox holder * fix(billing): count only a verified earlier account as the Gmail inbox holder * fix(billing): count any earlier account as the Gmail inbox holder, verified or not
1 parent 8563d25 commit fb74b4b

8 files changed

Lines changed: 32087 additions & 14 deletions

File tree

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,10 @@ vi.mock('@/lib/users/deleted-identity', () => ({
5959
userHasDeletedAccountIdentity: mockUserHasDeletedAccountIdentity,
6060
}))
6161

62+
vi.mock('@/lib/users/gmail-alias', () => ({
63+
userHasGmailAliasAccount: vi.fn(async () => false),
64+
}))
65+
6266
import {
6367
ensureUserStatsExists,
6468
getUserUsageLimit,

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

Lines changed: 29 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ import {
5050
isDeletedAccountIdentity,
5151
userHasDeletedAccountIdentity,
5252
} from '@/lib/users/deleted-identity'
53+
import { userHasGmailAliasAccount } from '@/lib/users/gmail-alias'
5354

5455
const logger = createLogger('UsageManagement')
5556

@@ -191,6 +192,22 @@ function calculateOrgUsageLimit(
191192
*/
192193
const usersCreatedInAuthRequest = new WeakMap<object, string>()
193194

195+
/**
196+
* Whether a new user's identity already had the free allowance: its email or a
197+
* linked sign-in (with `includeSignIns`) belonged to a recently deleted account,
198+
* or another account already holds its Gmail inbox.
199+
*/
200+
async function identityHadFreeAllowance(
201+
userId: string,
202+
{ includeSignIns }: { includeSignIns: boolean },
203+
executor: DbOrTx = db
204+
): Promise<boolean> {
205+
return (
206+
(await userHasDeletedAccountIdentity(userId, { includeSignIns }, executor)) ||
207+
(await userHasGmailAliasAccount(userId, executor))
208+
)
209+
}
210+
194211
function isUserCreatedInAuthRequest(
195212
userId: string,
196213
authRequest: object | null | undefined
@@ -202,20 +219,18 @@ function isUserCreatedInAuthRequest(
202219
* Handle new user setup when they join the platform.
203220
* Creates the userStats record with the free allowance, which is zero when the
204221
* user's email or an already-linked social sign-in belongs to an account
205-
* deleted within the retention window. Every user Better Auth creates comes
206-
* through here, including SCIM- and SSO-provisioned users, so their email is
207-
* always checked at creation.
222+
* deleted within the retention window, or when another account holds its Gmail
223+
* inbox. Every user Better Auth creates comes through here, including SCIM- and
224+
* SSO-provisioned users, so their email is always checked at creation.
208225
*
209226
* @param authRequest Better Auth's endpoint context for the signup request, when there is one
210227
*/
211228
export async function handleNewUser(userId: string, authRequest?: object | null): Promise<void> {
212229
if (authRequest) usersCreatedInAuthRequest.set(authRequest, userId)
213230
try {
214-
const freeTierEligible = !(await userHasDeletedAccountIdentity(userId, {
215-
includeSignIns: true,
216-
}))
231+
const freeTierEligible = !(await identityHadFreeAllowance(userId, { includeSignIns: true }))
217232
if (!freeTierEligible) {
218-
logger.info('Withholding the free allowance from a returning deleted-account identity', {
233+
logger.info('Withholding the free allowance from an identity that already had it', {
219234
userId,
220235
})
221236
}
@@ -315,7 +330,7 @@ export async function createUserStatsIfMissing(
315330
if (created.length === 0) return false
316331

317332
try {
318-
if (await userHasDeletedAccountIdentity(userId, { includeSignIns }, executor)) {
333+
if (await identityHadFreeAllowance(userId, { includeSignIns }, executor)) {
319334
await forfeitFreeTierAllowance(executor, userId)
320335
}
321336
} catch (error) {
@@ -704,18 +719,18 @@ export async function checkUsageStatus(userId: string): Promise<{
704719
}
705720

706721
/**
707-
* Withholds the free allowance from a new account found to reuse the sign-in
708-
* identity of a recently deleted one after its stats row was created. The flag
709-
* and the limit it implies commit together (a transaction on `db`, a savepoint
710-
* inside a caller's transaction), so a failed re-derivation can never leave the
711-
* account flagged but still holding the free limit; repeating the call is harmless.
722+
* Withholds the free allowance from a new account found, after its stats row was
723+
* created, to reuse an identity that already had it. The flag and the limit it
724+
* implies commit together (a transaction on `db`, a savepoint inside a caller's
725+
* transaction), so a failed re-derivation can never leave the account flagged
726+
* but still holding the free limit; repeating the call is harmless.
712727
*/
713728
async function forfeitFreeTierAllowance(executor: DbOrTx, userId: string): Promise<void> {
714729
await executor.transaction(async (tx) => {
715730
await tx.update(userStats).set({ freeTierEligible: false }).where(eq(userStats.userId, userId))
716731
await syncUsageLimitsFromSubscription(userId, tx)
717732
})
718-
logger.info('Withheld the free allowance from a returning deleted-account identity', { userId })
733+
logger.info('Withheld the free allowance from an identity that already had it', { userId })
719734
}
720735

721736
/**
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
/**
2+
* A dot, `+tag`, or `googlemail.com` variant of an existing Gmail address must not get a second
3+
* free allowance. Runs against real PostgreSQL through the production signup-stats and
4+
* fallback-stats paths, and checks the lookup is served by `user_gmail_mailbox_idx`.
5+
*/
6+
import { db } from '@sim/db'
7+
import { gmailMailbox, user, userStats } from '@sim/db/schema'
8+
import { authMock } from '@sim/testing/mocks/auth.mock'
9+
import { envFlagsMock } from '@sim/testing/mocks/env-flags.mock'
10+
import { generateId } from '@sim/utils/id'
11+
import { eq, inArray, sql } from 'drizzle-orm'
12+
import { afterAll, describe, expect, it, vi } from 'vitest'
13+
import { ensureUserStatsExists, handleNewUser } from '@/lib/billing/core/usage'
14+
import { getFreeTierLimit } from '@/lib/billing/subscriptions/utils'
15+
16+
vi.mock('@/lib/core/config/env-flags', () => ({
17+
...envFlagsMock,
18+
isHosted: true,
19+
isBillingEnabled: true,
20+
}))
21+
vi.mock('@/lib/auth', () => authMock)
22+
23+
const userIds: string[] = []
24+
25+
async function insertUser(
26+
email: string,
27+
{ banned = false, emailVerified = true, createdAt = new Date() } = {}
28+
): Promise<string> {
29+
const id = generateId()
30+
userIds.push(id)
31+
await db.insert(user).values({
32+
id,
33+
name: 'Gmail alias fixture',
34+
email,
35+
emailVerified,
36+
banned,
37+
createdAt,
38+
updatedAt: createdAt,
39+
})
40+
return id
41+
}
42+
43+
async function signUp(
44+
email: string,
45+
options?: { banned?: boolean; emailVerified?: boolean }
46+
): Promise<string> {
47+
const id = await insertUser(email, options)
48+
await handleNewUser(id, {})
49+
return id
50+
}
51+
52+
async function statsOf(userId: string) {
53+
const [row] = await db
54+
.select({
55+
freeTierEligible: userStats.freeTierEligible,
56+
currentUsageLimit: userStats.currentUsageLimit,
57+
})
58+
.from(userStats)
59+
.where(eq(userStats.userId, userId))
60+
return { freeTierEligible: row?.freeTierEligible, limit: Number(row?.currentUsageLimit) }
61+
}
62+
63+
/** A Gmail local part no other test run can collide with. */
64+
function uniqueLocalPart(): string {
65+
return `alias${generateId().replaceAll('-', '')}`
66+
}
67+
68+
afterAll(async () => {
69+
if (userIds.length) await db.delete(user).where(inArray(user.id, userIds))
70+
})
71+
72+
describe('Gmail inboxes and the free allowance', () => {
73+
it('withholds the allowance from dot, +tag, and googlemail variants of a live account', async () => {
74+
const local = uniqueLocalPart()
75+
const original = await signUp(`${local}@gmail.com`)
76+
77+
const dotted = await signUp(`${local.slice(0, 4)}.${local.slice(4)}@gmail.com`)
78+
const tagged = await signUp(`${local}+farm@gmail.com`)
79+
const googlemail = await signUp(`${local.toUpperCase()}@GoogleMail.com`)
80+
81+
expect(await statsOf(original)).toEqual({ freeTierEligible: true, limit: getFreeTierLimit() })
82+
for (const variant of [dotted, tagged, googlemail]) {
83+
expect(await statsOf(variant)).toEqual({ freeTierEligible: false, limit: 0 })
84+
}
85+
})
86+
87+
it('withholds it when the inbox belongs to a banned account', async () => {
88+
const local = uniqueLocalPart()
89+
await signUp(`${local}@gmail.com`, { banned: true })
90+
91+
const returning = await signUp(`${local}+again@gmail.com`)
92+
93+
expect(await statsOf(returning)).toEqual({ freeTierEligible: false, limit: 0 })
94+
})
95+
96+
it('withholds it from aliases registered before any of them verified the inbox', async () => {
97+
const local = uniqueLocalPart()
98+
const first = await signUp(`${local}@gmail.com`, { emailVerified: false })
99+
const second = await signUp(`${local}+two@gmail.com`, { emailVerified: false })
100+
await db
101+
.update(user)
102+
.set({ emailVerified: true })
103+
.where(inArray(user.id, [first, second]))
104+
105+
const third = await signUp(`${local.slice(0, 5)}.${local.slice(5)}@gmail.com`)
106+
107+
expect(await statsOf(first)).toEqual({ freeTierEligible: true, limit: getFreeTierLimit() })
108+
expect(await statsOf(second)).toEqual({ freeTierEligible: false, limit: 0 })
109+
expect(await statsOf(third)).toEqual({ freeTierEligible: false, limit: 0 })
110+
})
111+
112+
it('grants it to a different Gmail inbox and never folds addresses outside Gmail', async () => {
113+
const local = uniqueLocalPart()
114+
await signUp(`${local}@gmail.com`)
115+
await signUp(`${local}+one@gmail-alias.test`)
116+
117+
const otherInbox = await signUp(`${local}x@gmail.com`)
118+
const otherDomain = await signUp(`${local}@gmail-alias.test`)
119+
120+
expect(await statsOf(otherInbox)).toEqual({ freeTierEligible: true, limit: getFreeTierLimit() })
121+
expect(await statsOf(otherDomain)).toEqual({
122+
freeTierEligible: true,
123+
limit: getFreeTierLimit(),
124+
})
125+
})
126+
127+
it('withholds it from a stats row the fallback creates after signup failed to', async () => {
128+
const local = uniqueLocalPart()
129+
await signUp(`${local}@gmail.com`)
130+
const alias = await insertUser(`${local.slice(0, 3)}.${local.slice(3)}@gmail.com`)
131+
132+
await ensureUserStatsExists(alias)
133+
134+
expect(await statsOf(alias)).toEqual({ freeTierEligible: false, limit: 0 })
135+
})
136+
137+
it('keeps the allowance with the inbox’s first account when its stats row comes after an alias’s', async () => {
138+
const local = uniqueLocalPart()
139+
const original = await insertUser(`${local}@gmail.com`, {
140+
createdAt: new Date(Date.now() - 60_000),
141+
})
142+
const alias = await signUp(`${local}+later@gmail.com`)
143+
144+
await ensureUserStatsExists(original)
145+
146+
expect(await statsOf(original)).toEqual({ freeTierEligible: true, limit: getFreeTierLimit() })
147+
expect(await statsOf(alias)).toEqual({ freeTierEligible: false, limit: 0 })
148+
})
149+
150+
it('withholds from exactly one of two accounts created at the same instant', async () => {
151+
const local = uniqueLocalPart()
152+
const createdAt = new Date()
153+
const first = await insertUser(`${local}@gmail.com`, { createdAt })
154+
const second = await insertUser(`${local.slice(0, 2)}.${local.slice(2)}@gmail.com`, {
155+
createdAt,
156+
})
157+
158+
await ensureUserStatsExists(first)
159+
await ensureUserStatsExists(second)
160+
161+
const eligible = [await statsOf(first), await statsOf(second)].map(
162+
(stats) => stats.freeTierEligible
163+
)
164+
expect(eligible.sort()).toEqual([false, true])
165+
})
166+
167+
it('finds the inbox through user_gmail_mailbox_idx rather than a scan of user', async () => {
168+
const plan = await db.transaction(async (tx) => {
169+
await tx.execute(sql`SET LOCAL enable_seqscan = off`)
170+
const rows = await tx.execute<{ 'QUERY PLAN': string }>(
171+
sql`EXPLAIN SELECT ${user.id} FROM ${user} WHERE ${gmailMailbox(user.email)} = ${`${uniqueLocalPart()}@gmail.com`} LIMIT 1`
172+
)
173+
return rows.map((row) => row['QUERY PLAN']).join('\n')
174+
})
175+
176+
expect(plan).toContain('user_gmail_mailbox_idx')
177+
})
178+
})

‎apps/sim/lib/users/gmail-alias.ts‎

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { db } from '@sim/db'
2+
import { gmailMailbox, user } from '@sim/db/schema'
3+
import { createLogger } from '@sim/logger'
4+
import { and, eq, lt, or, sql } from 'drizzle-orm'
5+
import { isBillingEnabled } from '@/lib/core/config/env-flags'
6+
import type { DbOrTx } from '@/lib/db/types'
7+
8+
/**
9+
* One free allowance per Gmail inbox. Gmail delivers every dot and `+tag`
10+
* variant of an address, and its `googlemail.com` twin, to the same inbox, so
11+
* without this one person could register `a.b@gmail.com`, `ab+1@gmail.com`, …
12+
* for a fresh allowance each time. The later account is created normally but
13+
* without the free allowance, like a returning deleted-account identity.
14+
*
15+
* Only meaningful where billing is enabled.
16+
*/
17+
18+
const logger = createLogger('GmailAlias')
19+
20+
/**
21+
* Whether an earlier account, banned or not, already holds the Gmail inbox this
22+
* user's address delivers to. Only an account created before this one counts
23+
* (the id breaks a creation-time tie), so the inbox's first account keeps the
24+
* allowance however late its own stats row is created. The earlier account
25+
* counts whether or not it verified the address: the allowance is granted at
26+
* signup, before verification, and where verification is off a password
27+
* account never verifies, so a verified-only rule would let every alias
28+
* registered unverified keep its allowance. Pass the caller's transaction as
29+
* `executor` to read on it; the read then runs in a savepoint, so a failed
30+
* lookup never aborts that transaction. Fails open: a lookup error returns
31+
* `false` and is logged, because refusing a legitimate person's allowance is
32+
* worse than one missed match.
33+
*/
34+
export async function userHasGmailAliasAccount(
35+
userId: string,
36+
executor: DbOrTx = db
37+
): Promise<boolean> {
38+
if (!isBillingEnabled) return false
39+
const read = async (reader: DbOrTx) => {
40+
const [self] = await reader
41+
.select({ mailbox: gmailMailbox(user.email), createdAt: user.createdAt })
42+
.from(user)
43+
.where(eq(user.id, userId))
44+
.limit(1)
45+
if (!self?.mailbox) return false
46+
const [other] = await reader
47+
.select({ id: user.id })
48+
.from(user)
49+
.where(
50+
and(
51+
sql`${gmailMailbox(user.email)} = ${self.mailbox}`,
52+
or(
53+
lt(user.createdAt, self.createdAt),
54+
and(eq(user.createdAt, self.createdAt), lt(user.id, userId))
55+
)
56+
)
57+
)
58+
.limit(1)
59+
return Boolean(other)
60+
}
61+
try {
62+
return await (executor === db ? read(db) : executor.transaction(read))
63+
} catch (error) {
64+
logger.error('Gmail alias lookup failed; granting the free allowance', { userId, error })
65+
return false
66+
}
67+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
COMMIT;--> statement-breakpoint
2+
SET lock_timeout = 0;--> statement-breakpoint
3+
-- migration-safe: this new additive index has no deployed readers; remove any unjournaled prior build before rebuilding concurrently on replay.
4+
DROP INDEX CONCURRENTLY IF EXISTS "user_gmail_mailbox_idx";--> statement-breakpoint
5+
CREATE INDEX CONCURRENTLY IF NOT EXISTS "user_gmail_mailbox_idx" ON "user" USING btree ((CASE WHEN split_part(lower(btrim("email")), '@', 2) IN ('gmail.com', 'googlemail.com') THEN replace(split_part(split_part(lower(btrim("email")), '@', 1), '+', 1), '.', '') || '@gmail.com' END)) WHERE (CASE WHEN split_part(lower(btrim("user"."email")), '@', 2) IN ('gmail.com', 'googlemail.com') THEN replace(split_part(split_part(lower(btrim("user"."email")), '@', 1), '+', 1), '.', '') || '@gmail.com' END) IS NOT NULL;--> statement-breakpoint
6+
SET lock_timeout = '5s';

0 commit comments

Comments
 (0)