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
+}