Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
18 changes: 12 additions & 6 deletions docs/contributing/architecture/entitlements.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,12 +183,18 @@ the same cold zero-init path):
same `readAdminEntitlementConsumption` helper over a bounded sweep of the top
~15 active users by current-month event count. The same hourly lane also
emails verified person accounts when usage crosses 80% or 100% of their
effective plan (transactional template). Throttle is one approaching email and
one reached email per user per UTC day, listing every resource currently in
that bucket — not one mail per entitlement. Candidate selection is the top ~80
accounts by current-month event count plus high package/secret stock, capped
at 100. Operator fleet mail is unchanged and still runs if user warning sends
fail.
effective plan (transactional template). Throttle is one mail per crossing of
a given percentage on a specific entitlement: staying at 10/10 packages does
not mail again the next UTC day. Dropping below that threshold and climbing
back over it is a new instance. Same-hour crossings of the same kind still
batch into one mail. KV prefix `entitlement-warning-user:v3` stores
`{prefix}:{userId}:{kind}:{resource}` for stock limits, and appends the UTC
day for `*_per_day` counters so a midnight reset is a new instance. A
remaining `v2` daily key for the same user and kind from the last 36 hours
counts as a claim for every resource currently in that bucket. Candidate
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
selection is the top ~80 accounts by current-month event count plus high
package/secret stock, capped at 100. Operator fleet mail is unchanged and
still runs if user warning sends fail.

`readEntitlementResourceUsage` counts only APP_DB-backed row resources (`repos`,
`saved_packages`, `secrets`). Resources whose authority is elsewhere
Expand Down
279 changes: 233 additions & 46 deletions packages/worker/src/app/user-entitlement-warning-emails.node.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { expect, test, vi } from 'vitest'
import { utcDayKey } from '@kody-internal/shared/date-keys.ts'
import type { EntitlementResource } from '#universal/plans.ts'

Check warning on line 3 in packages/worker/src/app/user-entitlement-warning-emails.node.test.ts

View workflow job for this annotation

GitHub Actions / 🧹 Static

import(consistent-type-specifier-style)

packages/worker/src/app/user-entitlement-warning-emails.node.test.ts:3:1: Prefer using inline type specifiers instead of a top-level type-only import.

const readAdminEntitlementConsumption = vi.fn()

Expand All @@ -16,13 +17,16 @@
sendCloudflareEmail(...args),
}))

const { sendUserEntitlementWarningEmails, userEntitlementWarningKvKey } =
await import('#app/user-entitlement-warning-emails.ts')
const {
sendUserEntitlementWarningEmails,
userEntitlementWarningDailyKvKey,
userEntitlementWarningKvKey,
} = await import('#app/user-entitlement-warning-emails.ts')

const stableUserId = 'a'.repeat(64)

function consumptionRow(input: {
resource: string
resource: EntitlementResource
label: string
current: number
limit: number
Expand All @@ -45,6 +49,9 @@
async put(key: string, value: string) {
store.set(key, value)
},
async delete(key: string) {
store.delete(key)
},
} as unknown as KVNamespace,
}
}
Expand Down Expand Up @@ -86,14 +93,27 @@
}) {
return {
APP_DB: createDb(input.users),
APP_BASE_URL: 'https://heykody.dev/',
APP_BASE_URL: 'https://kody.codes/',
CLOUDFLARE_ACCOUNT_ID: 'acct',
CLOUDFLARE_API_TOKEN: 'token',
BUNDLE_ARTIFACTS_KV: input.kv,
} as unknown as Env
}

test('user entitlement warnings send one 80% email and one 100% email per UTC day regardless of resource', async () => {
function instanceKey(
kind: 'approaching' | 'reached',
resource: EntitlementResource,
now?: Date,
) {
return userEntitlementWarningKvKey({
userId: stableUserId,
kind,
resource,
day: now ? utcDayKey(now) : undefined,
})
}

test('user entitlement warnings mail once per entitlement crossing, not once per day', async () => {
const now = new Date('2026-07-25T12:00:00.000Z')
const { kv, store } = createKv()
readAdminEntitlementConsumption.mockResolvedValue([
Expand Down Expand Up @@ -144,33 +164,23 @@
text: string
}
expect(approachingPayload.to).toBe('jelias@example.com')
expect(approachingPayload.from).toBe('kody@heykody.dev')
expect(approachingPayload.from).toBe('kody@kody.codes')
expect(approachingPayload.subject).toContain('approaching')
expect(approachingPayload.html).toContain(
'https://heykody.dev/account/billing',
'https://kody.codes/account/billing',
)
expect(approachingPayload.text).toContain('https://heykody.dev/account/usage')
expect(approachingPayload.text).toContain('https://kody.codes/account/usage')
expect(approachingPayload.html).toContain('execute calls per day')
expect(approachingPayload.html).toContain('saved packages')
expect(approachingPayload.html).not.toContain('secrets')
expect(approachingPayload.html).toContain(
'https://heykody.dev/images/kody-lantern.png',
expect(
store.get(instanceKey('approaching', 'execute_calls_per_day', now)),
).toBe(String(now.getTime()))
expect(store.get(instanceKey('approaching', 'saved_packages'))).toBe(
String(now.getTime()),
)

const approachingKey = userEntitlementWarningKvKey({
userId: stableUserId,
kind: 'approaching',
day: utcDayKey(now),
})
expect(store.get(approachingKey)).toBe(String(now.getTime()))
expect(
store.get(
userEntitlementWarningKvKey({
userId: stableUserId,
kind: 'reached',
day: utcDayKey(now),
}),
),
store.get(instanceKey('reached', 'execute_calls_per_day', now)),
).toBeUndefined()

sendCloudflareEmail.mockClear()
Expand All @@ -181,6 +191,28 @@
expect(stillApproaching).toEqual({ status: 'no_warnings' })
expect(sendCloudflareEmail).not.toHaveBeenCalled()

sendCloudflareEmail.mockClear()
readAdminEntitlementConsumption.mockResolvedValue([
consumptionRow({
resource: 'execute_calls_per_day',
label: 'execute calls per day',
current: 10,
limit: 250,
}),
consumptionRow({
resource: 'saved_packages',
label: 'saved packages',
current: 9,
limit: 10,
}),
])
const nextDayStillApproaching = await sendUserEntitlementWarningEmails({
env,
now: new Date('2026-07-26T01:00:00.000Z'),
})
expect(nextDayStillApproaching).toEqual({ status: 'no_warnings' })
expect(sendCloudflareEmail).not.toHaveBeenCalled()

readAdminEntitlementConsumption.mockResolvedValue([
consumptionRow({
resource: 'execute_calls_per_day',
Expand All @@ -201,9 +233,10 @@
limit: 10,
}),
])
const reachedAt = new Date('2026-07-26T03:00:00.000Z')
const reached = await sendUserEntitlementWarningEmails({
env,
now: new Date(now.getTime() + 2 * 60 * 60 * 1000),
now: reachedAt,
})
expect(reached).toEqual({
status: 'notified',
Expand All @@ -221,42 +254,196 @@
expect(reachedPayload.html).toContain('outbound fetches per day')
expect(reachedPayload.html).not.toContain('saved packages')
expect(
store.get(
userEntitlementWarningKvKey({
userId: stableUserId,
kind: 'reached',
day: utcDayKey(now),
}),
),
).toBe(String(now.getTime() + 2 * 60 * 60 * 1000))
store.get(instanceKey('reached', 'execute_calls_per_day', reachedAt)),
).toBe(String(reachedAt.getTime()))
expect(
store.get(instanceKey('approaching', 'execute_calls_per_day', reachedAt)),
).toBe(String(reachedAt.getTime()))
expect(
store.get(instanceKey('reached', 'outbound_fetches_per_day', reachedAt)),
).toBe(String(reachedAt.getTime()))

sendCloudflareEmail.mockClear()
const sameDay = await sendUserEntitlementWarningEmails({
const stillReachedSameDay = await sendUserEntitlementWarningEmails({
env,
now: new Date(now.getTime() + 3 * 60 * 60 * 1000),
now: new Date('2026-07-26T04:00:00.000Z'),
})
expect(sameDay).toEqual({ status: 'no_warnings' })
expect(stillReachedSameDay).toEqual({ status: 'no_warnings' })
expect(sendCloudflareEmail).not.toHaveBeenCalled()

const nextDay = new Date('2026-07-26T01:00:00.000Z')
const nextDayResult = await sendUserEntitlementWarningEmails({
readAdminEntitlementConsumption.mockResolvedValue([
consumptionRow({
resource: 'execute_calls_per_day',
label: 'execute calls per day',
current: 200,
limit: 250,
}),
consumptionRow({
resource: 'outbound_fetches_per_day',
label: 'outbound fetches per day',
current: 500,
limit: 500,
}),
consumptionRow({
resource: 'saved_packages',
label: 'saved packages',
current: 9,
limit: 10,
}),
])
sendCloudflareEmail.mockClear()
const nextUtcDay = new Date('2026-07-27T02:00:00.000Z')
const droppedToApproaching = await sendUserEntitlementWarningEmails({
env,
now: nextDay,
now: nextUtcDay,
})
expect(nextDayResult).toEqual({
expect(droppedToApproaching).toEqual({
status: 'notified',
emailedUsers: 1,
emailsSent: 2,
warnedResources: 3,
warnedResources: 2,
})
expect(sendCloudflareEmail).toHaveBeenCalledTimes(2)
const nextSubjects = sendCloudflareEmail.mock.calls.map(
(call) => (call[1] as { subject: string }).subject,
expect(
store.get(instanceKey('reached', 'outbound_fetches_per_day', nextUtcDay)),
).toBe(String(nextUtcDay.getTime()))
expect(
store.get(instanceKey('approaching', 'execute_calls_per_day', nextUtcDay)),
).toBe(String(nextUtcDay.getTime()))
expect(
store.get(instanceKey('reached', 'execute_calls_per_day', nextUtcDay)),
).toBeUndefined()

readAdminEntitlementConsumption.mockResolvedValue([
consumptionRow({
resource: 'execute_calls_per_day',
label: 'execute calls per day',
current: 10,
limit: 250,
}),
consumptionRow({
resource: 'outbound_fetches_per_day',
label: 'outbound fetches per day',
current: 0,
limit: 500,
}),
consumptionRow({
resource: 'saved_packages',
label: 'saved packages',
current: 2,
limit: 10,
}),
])
const clearedAt = new Date('2026-07-27T04:00:00.000Z')
await sendUserEntitlementWarningEmails({ env, now: clearedAt })
expect(
store.get(instanceKey('approaching', 'execute_calls_per_day', clearedAt)),
).toBeUndefined()
expect(
store.get(instanceKey('approaching', 'saved_packages')),
).toBeUndefined()
expect(
store.get(instanceKey('reached', 'outbound_fetches_per_day', clearedAt)),
).toBeUndefined()

readAdminEntitlementConsumption.mockResolvedValue([
consumptionRow({
resource: 'saved_packages',
label: 'saved packages',
current: 10,
limit: 10,
}),
])
sendCloudflareEmail.mockClear()
const jumpedToLimit = new Date('2026-07-27T05:00:00.000Z')
const jumped = await sendUserEntitlementWarningEmails({
env,
now: jumpedToLimit,
})
expect(jumped).toEqual({
status: 'notified',
emailedUsers: 1,
emailsSent: 1,
warnedResources: 1,
})
expect(sendCloudflareEmail).toHaveBeenCalledTimes(1)
expect(
(sendCloudflareEmail.mock.calls[0]?.[1] as { subject: string }).subject,

Check warning on line 371 in packages/worker/src/app/user-entitlement-warning-emails.node.test.ts

View workflow job for this annotation

GitHub Actions / 🧹 Static

eslint(no-unsafe-optional-chaining)

packages/worker/src/app/user-entitlement-warning-emails.node.test.ts:371:4: Unsafe usage of optional chaining
).toContain('reached')
expect(store.get(instanceKey('reached', 'saved_packages'))).toBe(
String(jumpedToLimit.getTime()),
)
expect(store.get(instanceKey('approaching', 'saved_packages'))).toBe(
String(jumpedToLimit.getTime()),
)
expect(nextSubjects).toEqual([
"You've reached a Kody plan limit",
"You're approaching a Kody plan limit",

readAdminEntitlementConsumption.mockResolvedValue([
consumptionRow({
resource: 'saved_packages',
label: 'saved packages',
current: 9,
limit: 10,
}),
])
sendCloudflareEmail.mockClear()
const afterDropFromLimit = await sendUserEntitlementWarningEmails({
env,
now: new Date('2026-07-27T06:00:00.000Z'),
})
expect(afterDropFromLimit).toEqual({ status: 'no_warnings' })
expect(sendCloudflareEmail).not.toHaveBeenCalled()
})

test('user entitlement warnings absorb leftover daily claims without mailing again', async () => {
const now = new Date('2026-08-24T03:00:00.000Z')
const twoDaysAgo = new Date(now.getTime() - 2 * 24 * 60 * 60 * 1000)
const { kv, store } = createKv()
store.set(
userEntitlementWarningDailyKvKey({
userId: stableUserId,
kind: 'reached',
day: utcDayKey(twoDaysAgo),
}),
String(twoDaysAgo.getTime()),
)
readAdminEntitlementConsumption.mockResolvedValue([
consumptionRow({
resource: 'saved_packages',
label: 'saved packages',
current: 10,
limit: 10,
}),
])
sendCloudflareEmail.mockClear()
const env = createEnv({
users: [
{
stable_user_id: stableUserId,
email: 'maciek@example.com',
plan: 'free',
stripe_plan: null,
},
],
kv,
})

const absorbed = await sendUserEntitlementWarningEmails({ env, now })
expect(absorbed).toEqual({ status: 'no_warnings' })
expect(sendCloudflareEmail).not.toHaveBeenCalled()
expect(store.get(instanceKey('reached', 'saved_packages'))).toBe(
String(now.getTime()),
)
expect(store.get(instanceKey('approaching', 'saved_packages'))).toBe(
String(now.getTime()),
)

sendCloudflareEmail.mockClear()
const nextDay = await sendUserEntitlementWarningEmails({
env,
now: new Date('2026-08-25T01:00:00.000Z'),
})
expect(nextDay).toEqual({ status: 'no_warnings' })
expect(sendCloudflareEmail).not.toHaveBeenCalled()
})

test('user entitlement warnings skip when KV or transactional email is missing', async () => {
Expand Down
Loading
Loading