diff --git a/CHANGELOG.md b/CHANGELOG.md index ef8a1f4bde..d960459cc1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ This is the log of notable changes to EAS CLI and related packages. ### 🎉 New features - [eas-cli] New command `observe:session` for inspecting events by session ID. ([#3868](https://github.com/expo/eas-cli/pull/3868) by [@douglowder](https://github.com/douglowder)) +- [eas-cli] Add tiered build-credit usage warnings (approaching/at/over) before each build, lowering the warning threshold to 80% to match email notifications. ([#3882](https://github.com/expo/eas-cli/pull/3882) by [@sarahlane8](https://github.com/sarahlane8)) ### 🐛 Bug fixes diff --git a/packages/eas-cli/src/graphql/generated.ts b/packages/eas-cli/src/graphql/generated.ts index c85d2eabf6..2d2905c2e0 100644 --- a/packages/eas-cli/src/graphql/generated.ts +++ b/packages/eas-cli/src/graphql/generated.ts @@ -13424,7 +13424,7 @@ export type AccountUsageForOverageWarningQueryVariables = Exact<{ }>; -export type AccountUsageForOverageWarningQuery = { __typename?: 'RootQuery', account: { __typename?: 'AccountQuery', byId: { __typename?: 'Account', id: string, name: string, subscription?: { __typename?: 'SubscriptionDetails', id: string, name?: string | null } | null, usageMetrics: { __typename?: 'AccountUsageMetrics', EAS_BUILD: { __typename?: 'UsageMetricTotal', id: string, planMetrics: Array<{ __typename?: 'EstimatedUsage', id: string, serviceMetric: EasServiceMetric, value: number, limit: number }> } } } } }; +export type AccountUsageForOverageWarningQuery = { __typename?: 'RootQuery', account: { __typename?: 'AccountQuery', byId: { __typename?: 'Account', id: string, name: string, subscription?: { __typename?: 'SubscriptionDetails', id: string, name?: string | null } | null, usageMetrics: { __typename?: 'AccountUsageMetrics', EAS_BUILD: { __typename?: 'UsageMetricTotal', id: string, totalCost: number, planMetrics: Array<{ __typename?: 'EstimatedUsage', id: string, serviceMetric: EasServiceMetric, value: number, limit: number }>, overageMetrics: Array<{ __typename?: 'EstimatedOverageAndCost', id: string, value: number }> } } } } }; export type AccountBillingPeriodQueryVariables = Exact<{ accountId: Scalars['String']['input']; diff --git a/packages/eas-cli/src/graphql/queries/AccountQuery.ts b/packages/eas-cli/src/graphql/queries/AccountQuery.ts index d343f5dd7b..89a6d25681 100644 --- a/packages/eas-cli/src/graphql/queries/AccountQuery.ts +++ b/packages/eas-cli/src/graphql/queries/AccountQuery.ts @@ -198,6 +198,11 @@ export const AccountQuery = { value limit } + overageMetrics { + id + value + } + totalCost } } } diff --git a/packages/eas-cli/src/utils/usage/__tests__/checkForOverages-test.ts b/packages/eas-cli/src/utils/usage/__tests__/checkForOverages-test.ts index 741a8837ea..ce734ecc93 100644 --- a/packages/eas-cli/src/utils/usage/__tests__/checkForOverages-test.ts +++ b/packages/eas-cli/src/utils/usage/__tests__/checkForOverages-test.ts @@ -9,6 +9,7 @@ import { import { AccountQuery } from '../../../graphql/queries/AccountQuery'; import Log, { link } from '../../../log'; import { + classifyUsageTier, createProgressBar, displayOverageWarning, maybeWarnAboutUsageOveragesAsync, @@ -44,11 +45,15 @@ function createMockAccountUsage({ name = 'test-account', subscriptionName = 'Free', buildPlanMetrics = [], + overageMetrics = [], + totalCost = 0, }: { id?: string; name?: string; subscriptionName?: string; buildPlanMetrics?: EstimatedUsage[]; + overageMetrics?: { __typename: 'EstimatedOverageAndCost'; id: string; value: number }[]; + totalCost?: number; } = {}): AccountUsageForOverageWarningQuery['account']['byId'] { return { __typename: 'Account', @@ -64,6 +69,8 @@ function createMockAccountUsage({ __typename: 'UsageMetricTotal', id: 'metric-id', planMetrics: buildPlanMetrics, + overageMetrics, + totalCost, }, }, }; @@ -83,7 +90,7 @@ describe('maybeWarnAboutUsageOveragesAsync', () => { mockDebug.mockClear(); }); - it('displays a warning for a Free plan with high build usage', async () => { + it('displays an approaching-tier warning for a Free plan at 80%+ usage', async () => { mockGetUsageForOverageWarningAsync.mockResolvedValue( createMockAccountUsage({ buildPlanMetrics: [createMockPlanMetric({ value: 85 })], @@ -102,14 +109,14 @@ describe('maybeWarnAboutUsageOveragesAsync', () => { ); expect(mockWarn).toHaveBeenCalledWith( - expect.stringContaining("You've used 85% of your included build credits for this month.") + expect.stringContaining("You've used 85% of your included build credits this billing period.") ); expect(mockWarn).toHaveBeenCalledWith( expect.stringContaining("You won't be able to start new builds once you reach the limit.") ); }); - it('displays a warning for a Starter plan with high build usage', async () => { + it('displays an approaching-tier warning for a paid plan at 80%+ usage', async () => { mockGetUsageForOverageWarningAsync.mockResolvedValue( createMockAccountUsage({ subscriptionName: 'Starter', @@ -123,7 +130,7 @@ describe('maybeWarnAboutUsageOveragesAsync', () => { }); expect(mockWarn).toHaveBeenCalledWith( - expect.stringContaining("You've used 90% of your included build credits for this month.") + expect.stringContaining("You've used 90% of your included build credits this billing period.") ); expect(mockWarn).toHaveBeenCalledWith( expect.stringContaining( @@ -132,6 +139,94 @@ describe('maybeWarnAboutUsageOveragesAsync', () => { ); }); + it('displays an at-tier warning for a Free plan that has hit the limit', async () => { + mockGetUsageForOverageWarningAsync.mockResolvedValue( + createMockAccountUsage({ + buildPlanMetrics: [createMockPlanMetric({ value: 100 })], + }) + ); + + await maybeWarnAboutUsageOveragesAsync({ + graphqlClient: mockGraphqlClient, + accountId: 'account-id', + }); + + expect(mockWarn).toHaveBeenCalledWith( + expect.stringContaining("You've reached your included build credits this billing period.") + ); + expect(mockWarn).toHaveBeenCalledWith( + expect.stringContaining('New builds are blocked until your billing period resets.') + ); + }); + + it('displays an at-tier warning for a paid plan that has hit the limit with no overage', async () => { + mockGetUsageForOverageWarningAsync.mockResolvedValue( + createMockAccountUsage({ + subscriptionName: 'Starter', + buildPlanMetrics: [createMockPlanMetric({ value: 100 })], + }) + ); + + await maybeWarnAboutUsageOveragesAsync({ + graphqlClient: mockGraphqlClient, + accountId: 'account-id', + }); + + expect(mockWarn).toHaveBeenCalledWith( + expect.stringContaining("You've reached your included build credits this billing period.") + ); + expect(mockWarn).toHaveBeenCalledWith( + expect.stringContaining('Additional builds will be charged at pay-as-you-go rates.') + ); + }); + + it('displays the at-tier message for a Free plan even if overage is reported (defensive fallback)', async () => { + mockGetUsageForOverageWarningAsync.mockResolvedValue( + createMockAccountUsage({ + buildPlanMetrics: [createMockPlanMetric({ value: 105 })], + overageMetrics: [{ __typename: 'EstimatedOverageAndCost', id: 'o1', value: 5 }], + totalCost: 0, + }) + ); + + await maybeWarnAboutUsageOveragesAsync({ + graphqlClient: mockGraphqlClient, + accountId: 'account-id', + }); + + expect(mockWarn).toHaveBeenCalledWith( + expect.stringContaining("You've reached your included build credits this billing period.") + ); + expect(mockWarn).toHaveBeenCalledWith( + expect.stringContaining('New builds are blocked until your billing period resets.') + ); + }); + + it('displays an over-tier warning for a paid plan with overage and cost', async () => { + mockGetUsageForOverageWarningAsync.mockResolvedValue( + createMockAccountUsage({ + subscriptionName: 'Starter', + buildPlanMetrics: [createMockPlanMetric({ value: 110 })], + overageMetrics: [{ __typename: 'EstimatedOverageAndCost', id: 'o1', value: 10 }], + totalCost: 1500, + }) + ); + + await maybeWarnAboutUsageOveragesAsync({ + graphqlClient: mockGraphqlClient, + accountId: 'account-id', + }); + + expect(mockWarn).toHaveBeenCalledWith( + expect.stringContaining( + "You've used 10 builds beyond your included credits this billing period ($15.00 in overages so far)." + ) + ); + expect(mockWarn).toHaveBeenCalledWith( + expect.stringContaining('Additional builds continue at pay-as-you-go rates.') + ); + }); + it('does not display a warning when usage is below threshold', async () => { mockGetUsageForOverageWarningAsync.mockResolvedValue( createMockAccountUsage({ @@ -171,6 +266,30 @@ describe('maybeWarnAboutUsageOveragesAsync', () => { }); }); +describe('classifyUsageTier', () => { + it('returns null when usage is below threshold', () => { + expect(classifyUsageTier({ planValue: 50, limit: 100, overageCount: 0 })).toBeNull(); + expect(classifyUsageTier({ planValue: 79, limit: 100, overageCount: 0 })).toBeNull(); + }); + + it('returns "approaching" at >= 80% but below limit', () => { + expect(classifyUsageTier({ planValue: 80, limit: 100, overageCount: 0 })).toBe('approaching'); + expect(classifyUsageTier({ planValue: 99, limit: 100, overageCount: 0 })).toBe('approaching'); + }); + + it('returns "at" when planValue >= limit and no overage', () => { + expect(classifyUsageTier({ planValue: 100, limit: 100, overageCount: 0 })).toBe('at'); + }); + + it('returns "over" when any overage has been counted', () => { + expect(classifyUsageTier({ planValue: 105, limit: 100, overageCount: 5 })).toBe('over'); + }); + + it('prefers "over" if overage exists, regardless of planValue', () => { + expect(classifyUsageTier({ planValue: 50, limit: 100, overageCount: 1 })).toBe('over'); + }); +}); + describe('createProgressBar', () => { it('creates a progress bar with correct fill for a given percentage', () => { const bar = createProgressBar(85, 20); @@ -202,16 +321,20 @@ describe('displayOverageWarning', () => { mockLink.mockClear(); }); - it('displays a warning for a Free plan', () => { + it('approaching tier: displays a warning for a Free plan with progress bar', () => { displayOverageWarning({ - percentUsed: 85, - hasFreePlan: true, + tier: 'approaching', name: 'test-account', + hasFreePlan: true, + planValue: 85, + limit: 100, + overageCount: 0, + overageCostCents: 0, }); expect(mockWarn).toHaveBeenCalledTimes(2); expect(mockWarn).toHaveBeenCalledWith( - expect.stringContaining("You've used 85% of your included build credits for this month.") + expect.stringContaining("You've used 85% of your included build credits this billing period.") ); expect(mockWarn).toHaveBeenCalledWith( expect.stringContaining("You won't be able to start new builds once you reach the limit.") @@ -219,18 +342,23 @@ describe('displayOverageWarning', () => { expect(mockWarn).toHaveBeenCalledWith( expect.stringContaining('Upgrade your plan to continue service.') ); + expect(mockWarn).toHaveBeenCalledWith(expect.stringMatching(/█+░+/)); }); - it('displays a warning for a paid plan', () => { + it('approaching tier: displays a warning for a paid plan with progress bar', () => { displayOverageWarning({ - percentUsed: 85, - hasFreePlan: false, + tier: 'approaching', name: 'test-account', + hasFreePlan: false, + planValue: 85, + limit: 100, + overageCount: 0, + overageCostCents: 0, }); expect(mockWarn).toHaveBeenCalledTimes(2); expect(mockWarn).toHaveBeenCalledWith( - expect.stringContaining("You've used 85% of your included build credits for this month.") + expect.stringContaining("You've used 85% of your included build credits this billing period.") ); expect(mockWarn).toHaveBeenCalledWith( expect.stringContaining( @@ -238,44 +366,118 @@ describe('displayOverageWarning', () => { ) ); expect(mockWarn).toHaveBeenCalledWith(expect.stringContaining('See usage in billing.')); + }); + + it('at tier: displays a Free-plan limit-reached message with present-tense block wording', () => { + displayOverageWarning({ + tier: 'at', + name: 'test-account', + hasFreePlan: true, + planValue: 100, + limit: 100, + overageCount: 0, + overageCostCents: 0, + }); + expect(mockWarn).toHaveBeenCalledWith( - expect.stringContaining('██████████████████████████░░░░') + expect.stringContaining("You've reached your included build credits this billing period.") + ); + expect(mockWarn).toHaveBeenCalledWith( + expect.stringContaining('New builds are blocked until your billing period resets.') ); }); - it('includes correct account name in billing URL', () => { + it('at tier: displays a paid-plan limit-reached message with pay-as-you-go warning', () => { displayOverageWarning({ - percentUsed: 85, - hasFreePlan: true, - name: 'my-custom-account', + tier: 'at', + name: 'test-account', + hasFreePlan: false, + planValue: 100, + limit: 100, + overageCount: 0, + overageCostCents: 0, }); - expect(mockLink).toHaveBeenCalledWith( - 'https://expo.dev/accounts/my-custom-account/settings/billing', - expect.objectContaining({ text: 'Upgrade your plan to continue service.' }) + expect(mockWarn).toHaveBeenCalledWith( + expect.stringContaining("You've reached your included build credits this billing period.") + ); + expect(mockWarn).toHaveBeenCalledWith( + expect.stringContaining('Additional builds will be charged at pay-as-you-go rates.') ); }); - it('shows progress bar when usage is under 100%', () => { + it('over tier on Free plan: defensively falls back to the at-tier blocked message', () => { displayOverageWarning({ - percentUsed: 85, + tier: 'over', + name: 'test-account', hasFreePlan: true, + planValue: 105, + limit: 100, + overageCount: 5, + overageCostCents: 0, + }); + + expect(mockWarn).toHaveBeenCalledWith( + expect.stringContaining("You've reached your included build credits this billing period.") + ); + expect(mockWarn).toHaveBeenCalledWith( + expect.stringContaining('New builds are blocked until your billing period resets.') + ); + }); + + it('over tier: displays a paid-plan overage count and accrued cost', () => { + displayOverageWarning({ + tier: 'over', name: 'test-account', + hasFreePlan: false, + planValue: 110, + limit: 100, + overageCount: 10, + overageCostCents: 1500, }); - expect(mockWarn).toHaveBeenCalledWith(expect.stringMatching(/█+░+/)); + expect(mockWarn).toHaveBeenCalledWith( + expect.stringContaining( + "You've used 10 builds beyond your included credits this billing period ($15.00 in overages so far)." + ) + ); + expect(mockWarn).toHaveBeenCalledWith( + expect.stringContaining('Additional builds continue at pay-as-you-go rates.') + ); }); - it('does not show progress bar when usage is at 100%', () => { + it('over tier: paid plan with a single overage build uses singular wording', () => { displayOverageWarning({ - percentUsed: 100, - hasFreePlan: true, + tier: 'over', name: 'test-account', + hasFreePlan: false, + planValue: 101, + limit: 100, + overageCount: 1, + overageCostCents: 150, }); expect(mockWarn).toHaveBeenCalledWith( - expect.stringContaining("You've used 100% of your included build credits for this month.") + expect.stringContaining( + "You've used 1 build beyond your included credits this billing period ($1.50 in overages so far)." + ) + ); + }); + + it('includes correct account name in billing URL', () => { + displayOverageWarning({ + tier: 'approaching', + name: 'my-custom-account', + hasFreePlan: true, + planValue: 85, + limit: 100, + overageCount: 0, + overageCostCents: 0, + }); + + expect(mockLink).toHaveBeenCalledWith( + 'https://expo.dev/accounts/my-custom-account/settings/billing', + expect.objectContaining({ text: 'Upgrade your plan to continue service.' }) ); - expect(mockWarn).not.toHaveBeenCalledWith(expect.stringMatching(/[█░]/)); }); }); diff --git a/packages/eas-cli/src/utils/usage/checkForOverages.ts b/packages/eas-cli/src/utils/usage/checkForOverages.ts index 83b3aee0ca..f141957857 100644 --- a/packages/eas-cli/src/utils/usage/checkForOverages.ts +++ b/packages/eas-cli/src/utils/usage/checkForOverages.ts @@ -4,7 +4,9 @@ import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/creat import { AccountQuery } from '../../graphql/queries/AccountQuery'; import Log, { link } from '../../log'; -const THRESHOLD_PERCENT = 85; +const APPROACHING_THRESHOLD_PERCENT = 80; + +export type UsageTier = 'approaching' | 'at' | 'over'; export async function maybeWarnAboutUsageOveragesAsync({ graphqlClient, @@ -15,28 +17,71 @@ export async function maybeWarnAboutUsageOveragesAsync({ }): Promise { try { const currentDate = new Date(); - const { - name, - subscription, - usageMetrics: { EAS_BUILD }, - } = await AccountQuery.getUsageForOverageWarningAsync(graphqlClient, accountId, currentDate); + const account = await AccountQuery.getUsageForOverageWarningAsync( + graphqlClient, + accountId, + currentDate + ); + if (!account) { + return; + } - const planMetric = EAS_BUILD?.planMetrics?.[0]; + const { name, subscription, usageMetrics } = account; + const buildMetrics = usageMetrics.EAS_BUILD; + const planMetric = buildMetrics?.planMetrics?.[0]; if (!planMetric || !subscription) { return; } - const percentUsed = calculatePercentUsed(planMetric.value, planMetric.limit); - if (percentUsed >= THRESHOLD_PERCENT) { - const hasFreePlan = subscription.name === 'Free'; - displayOverageWarning({ percentUsed, hasFreePlan, name }); + const overageCount = buildMetrics.overageMetrics.reduce((sum, o) => sum + o.value, 0); + const overageCostCents = buildMetrics.totalCost; + const tier = classifyUsageTier({ + planValue: planMetric.value, + limit: planMetric.limit, + overageCount, + }); + if (!tier) { + return; } + + const hasFreePlan = subscription.name === 'Free'; + displayOverageWarning({ + tier, + name, + hasFreePlan, + planValue: planMetric.value, + limit: planMetric.limit, + overageCount, + overageCostCents, + }); } catch (error) { // Silently fail if we can't fetch usage data - we don't want to block the user's workflow Log.debug(`Failed to fetch usage data: ${error}`); } } +export function classifyUsageTier({ + planValue, + limit, + overageCount, +}: { + planValue: number; + limit: number; + overageCount: number; +}): UsageTier | null { + if (overageCount > 0) { + return 'over'; + } + if (limit > 0 && planValue >= limit) { + return 'at'; + } + const percentUsed = calculatePercentUsed(planValue, limit); + if (percentUsed >= APPROACHING_THRESHOLD_PERCENT) { + return 'approaching'; + } + return null; +} + export function calculatePercentUsed(value: number, limit: number): number { if (limit === 0) { return 0; @@ -52,31 +97,70 @@ export function createProgressBar(percentUsed: number, width: number = 30): stri return `${filled}${empty}`; } +function formatCents(cents: number): string { + return `$${(cents / 100).toFixed(2)}`; +} + export function displayOverageWarning({ - percentUsed, - hasFreePlan, + tier, name, + hasFreePlan, + planValue, + limit, + overageCount, + overageCostCents, }: { - percentUsed: number; - hasFreePlan: boolean; + tier: UsageTier; name: string; + hasFreePlan: boolean; + planValue: number; + limit: number; + overageCount: number; + overageCostCents: number; }): void { - const message = chalk.bold( - `You've used ${percentUsed}% of your included build credits for this month.` - ); - // Don't show progress bar at 100% - it's redundant when the limit is reached - const progressBar = percentUsed < 100 ? ' ' + createProgressBar(percentUsed) : ''; - Log.warn(message + progressBar); - const billingUrl = `https://expo.dev/accounts/${name}/settings/billing`; - const warning = hasFreePlan - ? "You won't be able to start new builds once you reach the limit. " + - link(billingUrl, { text: 'Upgrade your plan to continue service.', dim: false }) - : 'Additional usage beyond your limit will be charged at pay-as-you-go rates. ' + - link(billingUrl, { - text: 'See usage in billing.', - dim: false, - }); - Log.warn(warning); + if (tier === 'approaching') { + const percentUsed = calculatePercentUsed(planValue, limit); + Log.warn( + chalk.bold( + `You've used ${percentUsed}% of your included build credits this billing period.` + ) + + ' ' + + createProgressBar(percentUsed) + ); + Log.warn( + hasFreePlan + ? "You won't be able to start new builds once you reach the limit. " + + link(billingUrl, { text: 'Upgrade your plan to continue service.', dim: false }) + : 'Additional usage beyond your limit will be charged at pay-as-you-go rates. ' + + link(billingUrl, { text: 'See usage in billing.', dim: false }) + ); + return; + } + + // Free users are blocked at the limit, so they can't reach an "over" state. + // If we ever see Free + over (data inconsistency, mid-flight plan change), treat it as "at". + if (tier === 'at' || (tier === 'over' && hasFreePlan)) { + Log.warn(chalk.bold("You've reached your included build credits this billing period.")); + Log.warn( + hasFreePlan + ? 'New builds are blocked until your billing period resets. ' + + link(billingUrl, { text: 'Upgrade your plan to continue building.', dim: false }) + : 'Additional builds will be charged at pay-as-you-go rates. ' + + link(billingUrl, { text: 'See usage in billing.', dim: false }) + ); + return; + } + + // tier === 'over' && paid plan + Log.warn( + chalk.bold( + `You've used ${overageCount} build${overageCount === 1 ? '' : 's'} beyond your included credits this billing period (${formatCents(overageCostCents)} in overages so far).` + ) + ); + Log.warn( + 'Additional builds continue at pay-as-you-go rates. ' + + link(billingUrl, { text: 'See usage in billing.', dim: false }) + ); }