Skip to content

Commit aaca509

Browse files
committed
fix(billing): count only an earlier account as the Gmail inbox holder
1 parent f1a9d8c commit aaca509

2 files changed

Lines changed: 55 additions & 13 deletions

File tree

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

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,18 +22,20 @@ vi.mock('@/lib/auth', () => authMock)
2222

2323
const userIds: string[] = []
2424

25-
async function insertUser(email: string, { banned = false } = {}): Promise<string> {
25+
async function insertUser(
26+
email: string,
27+
{ banned = false, createdAt = new Date() } = {}
28+
): Promise<string> {
2629
const id = generateId()
2730
userIds.push(id)
28-
const now = new Date()
2931
await db.insert(user).values({
3032
id,
3133
name: 'Gmail alias fixture',
3234
email,
3335
emailVerified: true,
3436
banned,
35-
createdAt: now,
36-
updatedAt: now,
37+
createdAt,
38+
updatedAt: createdAt,
3739
})
3840
return id
3941
}
@@ -113,6 +115,36 @@ describe('Gmail inboxes and the free allowance', () => {
113115
expect(await statsOf(alias)).toEqual({ freeTierEligible: false, limit: 0 })
114116
})
115117

118+
it('keeps the allowance with the inbox’s first account when its stats row comes after an alias’s', async () => {
119+
const local = uniqueLocalPart()
120+
const original = await insertUser(`${local}@gmail.com`, {
121+
createdAt: new Date(Date.now() - 60_000),
122+
})
123+
const alias = await signUp(`${local}+later@gmail.com`)
124+
125+
await ensureUserStatsExists(original)
126+
127+
expect(await statsOf(original)).toEqual({ freeTierEligible: true, limit: getFreeTierLimit() })
128+
expect(await statsOf(alias)).toEqual({ freeTierEligible: false, limit: 0 })
129+
})
130+
131+
it('withholds from exactly one of two accounts created at the same instant', async () => {
132+
const local = uniqueLocalPart()
133+
const createdAt = new Date()
134+
const first = await insertUser(`${local}@gmail.com`, { createdAt })
135+
const second = await insertUser(`${local.slice(0, 2)}.${local.slice(2)}@gmail.com`, {
136+
createdAt,
137+
})
138+
139+
await ensureUserStatsExists(first)
140+
await ensureUserStatsExists(second)
141+
142+
const eligible = [await statsOf(first), await statsOf(second)].map(
143+
(stats) => stats.freeTierEligible
144+
)
145+
expect(eligible.sort()).toEqual([false, true])
146+
})
147+
116148
it('finds the inbox through user_gmail_mailbox_idx rather than a scan of user', async () => {
117149
const plan = await db.transaction(async (tx) => {
118150
await tx.execute(sql`SET LOCAL enable_seqscan = off`)

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

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { db } from '@sim/db'
22
import { gmailMailbox, user } from '@sim/db/schema'
33
import { createLogger } from '@sim/logger'
4-
import { and, eq, ne, sql } from 'drizzle-orm'
4+
import { and, eq, lt, or, sql } from 'drizzle-orm'
55
import { isBillingEnabled } from '@/lib/core/config/env-flags'
66
import type { DbOrTx } from '@/lib/db/types'
77

@@ -18,12 +18,14 @@ import type { DbOrTx } from '@/lib/db/types'
1818
const logger = createLogger('GmailAlias')
1919

2020
/**
21-
* Whether another account, banned or not, already holds the Gmail inbox this
22-
* user's address delivers to. Pass the caller's transaction as `executor` to
23-
* read on it; the read then runs in a savepoint, so a failed lookup never
24-
* aborts that transaction. Fails open: a lookup error returns `false` and is
25-
* logged, because refusing a legitimate person's allowance is worse than one
26-
* missed match.
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. Pass the caller's
25+
* transaction as `executor` to read on it; the read then runs in a savepoint,
26+
* so a failed lookup never aborts that transaction. Fails open: a lookup error
27+
* returns `false` and is logged, because refusing a legitimate person's
28+
* allowance is worse than one missed match.
2729
*/
2830
export async function userHasGmailAliasAccount(
2931
userId: string,
@@ -32,15 +34,23 @@ export async function userHasGmailAliasAccount(
3234
if (!isBillingEnabled) return false
3335
const read = async (reader: DbOrTx) => {
3436
const [self] = await reader
35-
.select({ mailbox: gmailMailbox(user.email) })
37+
.select({ mailbox: gmailMailbox(user.email), createdAt: user.createdAt })
3638
.from(user)
3739
.where(eq(user.id, userId))
3840
.limit(1)
3941
if (!self?.mailbox) return false
4042
const [other] = await reader
4143
.select({ id: user.id })
4244
.from(user)
43-
.where(and(sql`${gmailMailbox(user.email)} = ${self.mailbox}`, ne(user.id, userId)))
45+
.where(
46+
and(
47+
sql`${gmailMailbox(user.email)} = ${self.mailbox}`,
48+
or(
49+
lt(user.createdAt, self.createdAt),
50+
and(eq(user.createdAt, self.createdAt), lt(user.id, userId))
51+
)
52+
)
53+
)
4454
.limit(1)
4555
return Boolean(other)
4656
}

0 commit comments

Comments
 (0)