diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/credits-chip/credits-chip.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/credits-chip/credits-chip.tsx index 2cf39ea8b49..909ee90005a 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/credits-chip/credits-chip.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/credits-chip/credits-chip.tsx @@ -5,8 +5,8 @@ import { Chip } from '@sim/emcn' import { Credit } from '@sim/emcn/icons' import { useQueryClient } from '@tanstack/react-query' import { useParams, useRouter } from 'next/navigation' -import { ON_DEMAND_UNLIMITED } from '@/lib/billing/constants' import { formatCredits } from '@/lib/billing/credits/conversion' +import { getPooledCreditsRemaining } from '@/lib/billing/on-demand' import { buildUpgradeHref } from '@/lib/billing/upgrade-reasons' import { isBillingEnabled } from '@/app/workspace/[workspaceId]/settings/navigation' import { useMyMemberCredits } from '@/hooks/queries/organization' @@ -66,19 +66,17 @@ function CreditsChipInner() { if (memberLoading) return null /** - * Pooled/plan remaining (dollars): unused plan allowance plus any purchased - * credit balance. Null when the plan-based chip wouldn't show on its own (data - * not ready, or the plan isn't credit-metered). `ON_DEMAND_UNLIMITED` means - * effectively unbounded — rendered as ∞ — so short-circuit instead of - * subtracting usage from the sentinel. + * Pooled/plan remaining (dollars): unused plan allowance, matching enforcement + * (`currentUsage >= limit` blocks, so remaining is `limit - currentUsage`). + * Granted credits are already folded into `usageLimit`, so they are not added + * again here. Null when the plan-based chip wouldn't show on its own (data not + * ready, or the plan isn't credit-metered). The unlimited sentinel renders as ∞. */ const pooledData = !isLoading && hasData && planView.showCredits ? (data?.data ?? null) : null const pooledRemaining = pooledData === null ? null - : pooledData.usageLimit >= ON_DEMAND_UNLIMITED - ? ON_DEMAND_UNLIMITED - : Math.max(0, pooledData.usageLimit + pooledData.creditBalance - pooledData.currentUsage) + : getPooledCreditsRemaining(pooledData.usageLimit, pooledData.currentUsage) /** * A per-member cap is the authoritative personal remaining, but the actor gate diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.tsx index 9887f5bd729..3dc0ca2b8b7 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.tsx @@ -8,6 +8,7 @@ import { chipVariants, cn, Switch, + Tooltip, toast, } from '@sim/emcn' import { createLogger } from '@sim/logger' @@ -18,6 +19,12 @@ import { useParams, useRouter } from 'next/navigation' import { useSession, useSubscription } from '@/lib/auth/auth-client' import { ON_DEMAND_UNLIMITED } from '@/lib/billing/constants' import { CREDIT_MULTIPLIER } from '@/lib/billing/credits/conversion' +import { + getCoveredUsage, + getIsOnDemandActive, + getOnDemandOffLimit, + isOnDemandOffDisabled, +} from '@/lib/billing/on-demand' import { getDisplayPlanName, getPlanTierCredits, @@ -199,15 +206,41 @@ export function Billing() { ? organizationBillingData.data.totalUsageLimit : usageLimitData.currentLimit || usage.limit - const isOnDemandActive = - subscription.isPaid && planIncludedAmount > 0 && effectiveUsageLimit > planIncludedAmount - const effectiveCurrentUsage = subscription.isOrgScoped && organizationBillingData?.data?.totalCurrentUsage != null ? organizationBillingData.data.totalCurrentUsage : usage.current - const canDisableOnDemand = isOnDemandActive && effectiveCurrentUsage <= planIncludedAmount + /** + * Goodwill credits are already baked into the usage limit by + * `setUsageLimitForCredits` (limit = planBase + creditBalance). `covered` is + * that same never-billed ceiling, so on-demand is "on" only when the limit is + * raised above it — a credit grant alone must not read as on-demand. + * `creditBalance` is the org's balance for org-scoped admins (resolved + * server-side by `getCreditBalance`) and the user's balance otherwise. + */ + const creditBalance = subscriptionData?.data?.creditBalance ?? 0 + const covered = getCoveredUsage(planIncludedAmount, creditBalance) + + const isOnDemandActive = getIsOnDemandActive({ + isPaid: subscription.isPaid, + planIncludedAmount, + effectiveUsageLimit, + covered, + }) + + /** + * When usage already sits above `covered`, turning on-demand off would re-cap + * the limit at current usage and the switch would bounce straight back on + * (see `getOnDemandOffLimit`). Disable it and explain why via tooltip instead + * of accepting a no-op click; it re-enables once usage drops back to/below + * covered (e.g. the next billing reset). + */ + const onDemandLockedOn = isOnDemandOffDisabled({ + isOnDemandActive, + effectiveCurrentUsage, + covered, + }) const permissions = getSubscriptionPermissions( { @@ -244,31 +277,17 @@ export function Billing() { ) } - if (isOnDemandActive) { - if (!canDisableOnDemand) { - toast.error("Can't turn off on-demand usage", { - description: - "Your usage is above your plan's included amount. It can be turned off once usage drops below it.", - }) - return - } - if (shouldUseOrganizationBillingContext) { - await updateOrgLimit.mutateAsync({ - organizationId: billingOrganizationId!, - limit: planIncludedAmount, - }) - } else { - await updateUserLimit.mutateAsync({ limit: planIncludedAmount }) - } + const nextLimit = isOnDemandActive + ? getOnDemandOffLimit(effectiveCurrentUsage, covered) + : ON_DEMAND_UNLIMITED + + if (shouldUseOrganizationBillingContext) { + await updateOrgLimit.mutateAsync({ + organizationId: billingOrganizationId!, + limit: nextLimit, + }) } else { - if (shouldUseOrganizationBillingContext) { - await updateOrgLimit.mutateAsync({ - organizationId: billingOrganizationId!, - limit: ON_DEMAND_UNLIMITED, - }) - } else { - await updateUserLimit.mutateAsync({ limit: ON_DEMAND_UNLIMITED }) - } + await updateUserLimit.mutateAsync({ limit: nextLimit }) } } catch (error) { logger.error('Failed to toggle on-demand billing', { error }) @@ -452,11 +471,28 @@ export function Billing() { Allow usage to go past included usage - + {onDemandLockedOn ? ( + + + + + + + +

+ { + "Your usage is above your plan's included amount, so on-demand can't be turned off yet. It turns off once usage drops below it — at the latest when your billing period resets." + } +

+
+
+ ) : ( + + )} )} diff --git a/apps/sim/lib/billing/on-demand.test.ts b/apps/sim/lib/billing/on-demand.test.ts new file mode 100644 index 00000000000..72adebd33e1 --- /dev/null +++ b/apps/sim/lib/billing/on-demand.test.ts @@ -0,0 +1,157 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { ON_DEMAND_UNLIMITED } from '@/lib/billing/constants' +import { dollarsToCredits } from '@/lib/billing/credits/conversion' +import { + getCoveredUsage, + getIsOnDemandActive, + getOnDemandOffLimit, + getPooledCreditsRemaining, + isOnDemandOffDisabled, +} from '@/lib/billing/on-demand' + +describe('getPooledCreditsRemaining', () => { + it('returns limit minus usage, matching enforcement (usage >= limit blocks)', () => { + expect(getPooledCreditsRemaining(120, 62)).toBe(58) + expect(getPooledCreditsRemaining(30, 10)).toBe(20) + }) + + it('does not add the credit balance back (the double-count regression)', () => { + // team_6000, 2 seats: planBase $60 + credits $60 → limit $120, usage ~$62. + // Remaining is $58 ≈ 11,600 credits — NOT $118 ≈ 23,600 (limit + credits - usage). + const remaining = getPooledCreditsRemaining(120, 62) + expect(remaining).toBe(58) + expect(dollarsToCredits(remaining)).toBe(11_600) + expect(dollarsToCredits(remaining)).not.toBe(23_600) + }) + + it('clamps at zero when usage meets or exceeds the limit', () => { + expect(getPooledCreditsRemaining(100, 100)).toBe(0) + expect(getPooledCreditsRemaining(60, 100)).toBe(0) + }) + + it('short-circuits the unlimited sentinel to ∞ instead of subtracting usage', () => { + expect(getPooledCreditsRemaining(ON_DEMAND_UNLIMITED, 500)).toBe(ON_DEMAND_UNLIMITED) + expect(getPooledCreditsRemaining(ON_DEMAND_UNLIMITED + 1, 0)).toBe(ON_DEMAND_UNLIMITED) + }) +}) + +describe('getCoveredUsage', () => { + it('sums the plan included amount and the goodwill credit balance', () => { + expect(getCoveredUsage(60, 60)).toBe(120) + expect(getCoveredUsage(30, 0)).toBe(30) + }) +}) + +describe('getIsOnDemandActive', () => { + it('reads OFF when the limit only covers planBase + credits (credit grant is not on-demand)', () => { + // The concrete regression case: limit == covered, so the toggle reads OFF. + expect( + getIsOnDemandActive({ + isPaid: true, + planIncludedAmount: 60, + effectiveUsageLimit: 120, + covered: getCoveredUsage(60, 60), + }) + ).toBe(false) + }) + + it('reads ON when the limit is raised above the covered ceiling', () => { + expect( + getIsOnDemandActive({ + isPaid: true, + planIncludedAmount: 60, + effectiveUsageLimit: ON_DEMAND_UNLIMITED, + covered: getCoveredUsage(60, 60), + }) + ).toBe(true) + expect( + getIsOnDemandActive({ + isPaid: true, + planIncludedAmount: 60, + effectiveUsageLimit: 121, + covered: getCoveredUsage(60, 60), + }) + ).toBe(true) + }) + + it('is never active for non-paid plans or a zero included allowance', () => { + expect( + getIsOnDemandActive({ + isPaid: false, + planIncludedAmount: 60, + effectiveUsageLimit: ON_DEMAND_UNLIMITED, + covered: 120, + }) + ).toBe(false) + expect( + getIsOnDemandActive({ + isPaid: true, + planIncludedAmount: 0, + effectiveUsageLimit: ON_DEMAND_UNLIMITED, + covered: 0, + }) + ).toBe(false) + }) + + it('behaves equivalently on the personal Pro path (no credits)', () => { + const covered = getCoveredUsage(30, 0) + expect( + getIsOnDemandActive({ + isPaid: true, + planIncludedAmount: 30, + effectiveUsageLimit: 30, + covered, + }) + ).toBe(false) + expect( + getIsOnDemandActive({ + isPaid: true, + planIncludedAmount: 30, + effectiveUsageLimit: ON_DEMAND_UNLIMITED, + covered, + }) + ).toBe(true) + }) +}) + +describe('getOnDemandOffLimit', () => { + it('drops the limit to the covered ceiling when usage is below it', () => { + expect(getOnDemandOffLimit(62, 120)).toBe(120) + expect(getOnDemandOffLimit(10, 30)).toBe(30) + }) + + it('never lowers the limit below current usage', () => { + expect(getOnDemandOffLimit(150, 120)).toBe(150) + }) + + it('lands on covered when usage equals it', () => { + expect(getOnDemandOffLimit(120, 120)).toBe(120) + }) +}) + +describe('isOnDemandOffDisabled', () => { + it('disables the toggle when on-demand is on and usage is above covered', () => { + // Turning off here would re-cap at usage (150) and bounce back on, so lock it. + expect( + isOnDemandOffDisabled({ isOnDemandActive: true, effectiveCurrentUsage: 150, covered: 120 }) + ).toBe(true) + }) + + it('allows turning off when usage is at or below covered', () => { + expect( + isOnDemandOffDisabled({ isOnDemandActive: true, effectiveCurrentUsage: 120, covered: 120 }) + ).toBe(false) + expect( + isOnDemandOffDisabled({ isOnDemandActive: true, effectiveCurrentUsage: 62, covered: 120 }) + ).toBe(false) + }) + + it('never disables when on-demand is already off (turning on stays allowed)', () => { + expect( + isOnDemandOffDisabled({ isOnDemandActive: false, effectiveCurrentUsage: 150, covered: 120 }) + ).toBe(false) + }) +}) diff --git a/apps/sim/lib/billing/on-demand.ts b/apps/sim/lib/billing/on-demand.ts new file mode 100644 index 00000000000..59d6bf408d3 --- /dev/null +++ b/apps/sim/lib/billing/on-demand.ts @@ -0,0 +1,82 @@ +/** + * On-demand / pooled usage display and toggle math. + * + * DB values are dollars; these helpers operate on dollars and are the single + * source of truth shared by the credits chip and the billing settings toggle so + * the two surfaces can never disagree about what "remaining" or "on-demand on" + * means. + */ + +import { ON_DEMAND_UNLIMITED } from '@/lib/billing/constants' + +/** + * Dollars of pooled plan allowance still available before usage is capped. + * + * Mirrors enforcement exactly — `buildUsageData` in usage-monitor blocks when + * `currentUsage >= limit`, so remaining is `limit - currentUsage` and nothing + * more. Goodwill credits are already folded into `usageLimit` by + * `setUsageLimitForCredits`, so they must NOT be added back here; doing so + * double-counts the balance and overstates what's left. The unlimited sentinel + * short-circuits to itself — rendered as ∞ — instead of subtracting usage from + * the sentinel value. + */ +export function getPooledCreditsRemaining(usageLimit: number, currentUsage: number): number { + if (usageLimit >= ON_DEMAND_UNLIMITED) return ON_DEMAND_UNLIMITED + return Math.max(0, usageLimit - currentUsage) +} + +/** + * The maximum usage that is never billed: the plan's included allowance + * (`planBase`) plus any goodwill credit balance. `setUsageLimitForCredits` raises + * the usage limit to exactly this value when credits are granted, so the stored + * limit already reflects the credits. + */ +export function getCoveredUsage(planIncludedAmount: number, creditBalance: number): number { + return planIncludedAmount + creditBalance +} + +/** + * Whether on-demand (past-included) usage is enabled: the usage limit sits above + * the covered ceiling. Only meaningful for a paid plan with a positive included + * allowance. Comparing against `covered` — not `planIncludedAmount` alone — is + * what keeps a credit grant, which raises the limit to `planBase + creditBalance`, + * from being misread as on-demand having been switched on. + */ +export function getIsOnDemandActive(params: { + isPaid: boolean + planIncludedAmount: number + effectiveUsageLimit: number + covered: number +}): boolean { + const { isPaid, planIncludedAmount, effectiveUsageLimit, covered } = params + if (!isPaid || planIncludedAmount <= 0) return false + return effectiveUsageLimit > covered +} + +/** + * The usage limit to persist when turning on-demand OFF: drop back to the covered + * ceiling, but never below current usage — lowering the limit under usage would + * retroactively put the account over its cap. When usage already exceeds covered + * the limit lands on current usage and the toggle stays on until usage resets; + * that is an accepted edge, never a blocked action. + */ +export function getOnDemandOffLimit(currentUsage: number, covered: number): number { + return Math.max(currentUsage, covered) +} + +/** + * Whether the on-demand toggle should render disabled: it is on, but usage has + * already passed the covered ceiling, so turning it off would only re-cap the + * limit at current usage ({@link getOnDemandOffLimit}) and the control would + * spring straight back on. The UI disables it with an explanatory tooltip rather + * than accepting a no-op click. The state clears on its own once usage drops back + * to or below covered (e.g. at the next billing reset). + */ +export function isOnDemandOffDisabled(params: { + isOnDemandActive: boolean + effectiveCurrentUsage: number + covered: number +}): boolean { + const { isOnDemandActive, effectiveCurrentUsage, covered } = params + return isOnDemandActive && effectiveCurrentUsage > covered +}