From d6ec115348d0581fc2e6729298db7f31c776d1d6 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Tue, 7 Apr 2026 16:11:31 -0700 Subject: [PATCH 1/4] v0.6.29: login improvements, posthog telemetry (#4026) * feat(posthog): Add tracking on mothership abort (#4023) Co-authored-by: Theodore Li * fix(login): fix captcha headers for manual login (#4025) * fix(signup): fix turnstile key loading * fix(login): fix captcha header passing * Catch user already exists, remove login form captcha --- apps/sim/app/(auth)/signup/signup-form.tsx | 11 +++-------- .../app/workspace/[workspaceId]/home/home.tsx | 12 ++++++++++-- .../w/[workflowId]/components/panel/panel.tsx | 19 ++++++++++++++++++- apps/sim/lib/posthog/events.ts | 5 +++++ 4 files changed, 36 insertions(+), 11 deletions(-) diff --git a/apps/sim/app/(auth)/signup/signup-form.tsx b/apps/sim/app/(auth)/signup/signup-form.tsx index 55a0508ec1b..afb27cd729a 100644 --- a/apps/sim/app/(auth)/signup/signup-form.tsx +++ b/apps/sim/app/(auth)/signup/signup-form.tsx @@ -270,10 +270,8 @@ function SignupFormContent({ name: sanitizedName, }, { - fetchOptions: { - headers: { - ...(token ? { 'x-captcha-response': token } : {}), - }, + headers: { + ...(token ? { 'x-captcha-response': token } : {}), }, onError: (ctx) => { logger.error('Signup error:', ctx.error) @@ -282,10 +280,7 @@ function SignupFormContent({ let errorCode = 'unknown' if (ctx.error.code?.includes('USER_ALREADY_EXISTS')) { errorCode = 'user_already_exists' - errorMessage.push( - 'An account with this email already exists. Please sign in instead.' - ) - setEmailError(errorMessage[0]) + setEmailError('An account with this email already exists. Please sign in instead.') } else if ( ctx.error.code?.includes('BAD_REQUEST') || ctx.error.message?.includes('Email and password sign up is not enabled') diff --git a/apps/sim/app/workspace/[workspaceId]/home/home.tsx b/apps/sim/app/workspace/[workspaceId]/home/home.tsx index d76f17ff454..38367339197 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/home.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/home.tsx @@ -223,6 +223,14 @@ export function Home({ chatId }: HomeProps = {}) { posthogRef.current = posthog }, [posthog]) + const handleStopGeneration = useCallback(() => { + captureEvent(posthogRef.current, 'task_generation_aborted', { + workspace_id: workspaceId, + view: 'mothership', + }) + stopGeneration() + }, [stopGeneration, workspaceId]) + const handleSubmit = useCallback( (text: string, fileAttachments?: FileAttachmentForApi[], contexts?: ChatContext[]) => { const trimmed = text.trim() @@ -334,7 +342,7 @@ export function Home({ chatId }: HomeProps = {}) { defaultValue={initialPrompt} onSubmit={handleSubmit} isSending={isSending} - onStopGeneration={stopGeneration} + onStopGeneration={handleStopGeneration} userId={session?.user?.id} onContextAdd={handleContextAdd} /> @@ -359,7 +367,7 @@ export function Home({ chatId }: HomeProps = {}) { isSending={isSending} isReconnecting={isReconnecting} onSubmit={handleSubmit} - onStopGeneration={stopGeneration} + onStopGeneration={handleStopGeneration} messageQueue={messageQueue} onRemoveQueuedMessage={removeFromQueue} onSendQueuedMessage={sendNow} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx index 4d485c763ce..da51910789b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx @@ -4,6 +4,7 @@ import { memo, useCallback, useEffect, useRef, useState } from 'react' import { createLogger } from '@sim/logger' import { History, Plus, Square } from 'lucide-react' import { useParams, useRouter } from 'next/navigation' +import { usePostHog } from 'posthog-js/react' import { useShallow } from 'zustand/react/shallow' import { BubbleChatClose, @@ -33,6 +34,7 @@ import { import { Lock, Unlock, Upload } from '@/components/emcn/icons' import { VariableIcon } from '@/components/icons' import { useSession } from '@/lib/auth/auth-client' +import { captureEvent } from '@/lib/posthog/client' import { generateWorkflowJson } from '@/lib/workflows/operations/import-export' import { ConversationListItem } from '@/app/workspace/[workspaceId]/components' import { MothershipChat } from '@/app/workspace/[workspaceId]/home/components' @@ -101,6 +103,9 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel const params = useParams() const workspaceId = propWorkspaceId ?? (params.workspaceId as string) + const posthog = usePostHog() + const posthogRef = useRef(posthog) + const panelRef = useRef(null) const fileInputRef = useRef(null) const { activeTab, setActiveTab, panelWidth, _hasHydrated, setHasHydrated } = usePanelStore( @@ -264,6 +269,10 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel loadCopilotChats() }, [loadCopilotChats]) + useEffect(() => { + posthogRef.current = posthog + }, [posthog]) + const handleCopilotSelectChat = useCallback((chat: { id: string; title: string | null }) => { setCopilotChatId(chat.id) setCopilotChatTitle(chat.title) @@ -394,6 +403,14 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel [copilotEditQueuedMessage] ) + const handleCopilotStopGeneration = useCallback(() => { + captureEvent(posthogRef.current, 'task_generation_aborted', { + workspace_id: workspaceId, + view: 'copilot', + }) + copilotStopGeneration() + }, [copilotStopGeneration, workspaceId]) + const handleCopilotSubmit = useCallback( (text: string, fileAttachments?: FileAttachmentForApi[], contexts?: ChatContext[]) => { const trimmed = text.trim() @@ -833,7 +850,7 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel isSending={copilotIsSending} isReconnecting={copilotIsReconnecting} onSubmit={handleCopilotSubmit} - onStopGeneration={copilotStopGeneration} + onStopGeneration={handleCopilotStopGeneration} messageQueue={copilotMessageQueue} onRemoveQueuedMessage={copilotRemoveFromQueue} onSendQueuedMessage={copilotSendNow} diff --git a/apps/sim/lib/posthog/events.ts b/apps/sim/lib/posthog/events.ts index 537a9864282..faf9895bf62 100644 --- a/apps/sim/lib/posthog/events.ts +++ b/apps/sim/lib/posthog/events.ts @@ -378,6 +378,11 @@ export interface PostHogEventMap { workspace_id: string } + task_generation_aborted: { + workspace_id: string + view: 'mothership' | 'copilot' + } + task_message_sent: { workspace_id: string has_attachments: boolean From c360daa95ebaa16d2ec3133ac32edc1e606c5526 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Tue, 30 Jun 2026 18:41:37 -0700 Subject: [PATCH 2/4] improvement(billing): ux around on demand toggling and one-off credits --- .../components/credits-chip/credits-chip.tsx | 16 +-- .../settings/components/billing/billing.tsx | 56 ++++---- apps/sim/lib/billing/on-demand.test.ts | 132 ++++++++++++++++++ apps/sim/lib/billing/on-demand.ts | 65 +++++++++ 4 files changed, 232 insertions(+), 37 deletions(-) create mode 100644 apps/sim/lib/billing/on-demand.test.ts create mode 100644 apps/sim/lib/billing/on-demand.ts 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..517ddafb84f 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.tsx @@ -18,6 +18,7 @@ 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 } from '@/lib/billing/on-demand' import { getDisplayPlanName, getPlanTierCredits, @@ -199,15 +200,28 @@ 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, + }) const permissions = getSubscriptionPermissions( { @@ -244,31 +258,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 }) 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..b812be3e426 --- /dev/null +++ b/apps/sim/lib/billing/on-demand.test.ts @@ -0,0 +1,132 @@ +/** + * @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, +} 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) + }) +}) diff --git a/apps/sim/lib/billing/on-demand.ts b/apps/sim/lib/billing/on-demand.ts new file mode 100644 index 00000000000..32e4a4451fe --- /dev/null +++ b/apps/sim/lib/billing/on-demand.ts @@ -0,0 +1,65 @@ +/** + * 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) +} From 9355fa5aa25be4d0340a31dcead37890b557c5de Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Tue, 30 Jun 2026 18:54:16 -0700 Subject: [PATCH 3/4] minor ux improvement --- .../settings/components/billing/billing.tsx | 48 ++++++++++++++++--- apps/sim/lib/billing/on-demand.test.ts | 25 ++++++++++ apps/sim/lib/billing/on-demand.ts | 17 +++++++ 3 files changed, 84 insertions(+), 6 deletions(-) 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 517ddafb84f..17173b00854 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.tsx @@ -9,6 +9,7 @@ import { cn, Switch, toast, + Tooltip, } from '@sim/emcn' import { createLogger } from '@sim/logger' import { isOrgAdminRole } from '@sim/platform-authz/predicates' @@ -18,7 +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 } from '@/lib/billing/on-demand' +import { + getCoveredUsage, + getIsOnDemandActive, + getOnDemandOffLimit, + isOnDemandOffDisabled, +} from '@/lib/billing/on-demand' import { getDisplayPlanName, getPlanTierCredits, @@ -223,6 +229,19 @@ export function Billing() { 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( { isFree: subscription.isFree, @@ -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 index b812be3e426..72adebd33e1 100644 --- a/apps/sim/lib/billing/on-demand.test.ts +++ b/apps/sim/lib/billing/on-demand.test.ts @@ -9,6 +9,7 @@ import { getIsOnDemandActive, getOnDemandOffLimit, getPooledCreditsRemaining, + isOnDemandOffDisabled, } from '@/lib/billing/on-demand' describe('getPooledCreditsRemaining', () => { @@ -130,3 +131,27 @@ describe('getOnDemandOffLimit', () => { 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 index 32e4a4451fe..59d6bf408d3 100644 --- a/apps/sim/lib/billing/on-demand.ts +++ b/apps/sim/lib/billing/on-demand.ts @@ -63,3 +63,20 @@ export function getIsOnDemandActive(params: { 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 +} From 13b87fe6d62c07863e83da1ffce5a6051eb4802a Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Tue, 30 Jun 2026 18:59:36 -0700 Subject: [PATCH 4/4] fix lint --- .../[workspaceId]/settings/components/billing/billing.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 17173b00854..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,8 +8,8 @@ import { chipVariants, cn, Switch, - toast, Tooltip, + toast, } from '@sim/emcn' import { createLogger } from '@sim/logger' import { isOrgAdminRole } from '@sim/platform-authz/predicates'