improvement(billing): ux around on demand toggling and one-off credits (#5307)

* v0.6.29: login improvements, posthog telemetry (#4026)

* feat(posthog): Add tracking on mothership abort (#4023)

Co-authored-by: Theodore Li <theo@sim.ai>

* 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

* improvement(billing): ux around on demand toggling and one-off credits

* minor ux improvement

* fix lint

---------

Co-authored-by: Waleed <walif6@gmail.com>
Co-authored-by: Theodore Li <theodoreqili@gmail.com>
Co-authored-by: Siddharth Ganesan <33737564+Sg312@users.noreply.github.com>
Co-authored-by: Theodore Li <theo@sim.ai>
This commit is contained in:
Vikhyath Mondreti
2026-06-30 19:07:06 -07:00
committed by GitHub
co-authored by Theodore Li Waleed Theodore Li Siddharth Ganesan
parent 75c364a989
commit 5055b31e82
4 changed files with 315 additions and 42 deletions
@@ -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
@@ -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() {
<span className='text-[var(--text-body)] text-small'>
Allow usage to go past included usage
</span>
<Switch
checked={isOnDemandActive}
disabled={isTogglingOnDemand || !canManageBilling}
onCheckedChange={handleToggleOnDemand}
/>
{onDemandLockedOn ? (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<span className='inline-flex'>
<Switch checked disabled onCheckedChange={handleToggleOnDemand} />
</span>
</Tooltip.Trigger>
<Tooltip.Content className='max-w-[260px]'>
<p>
{
"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."
}
</p>
</Tooltip.Content>
</Tooltip.Root>
) : (
<Switch
checked={isOnDemandActive}
disabled={isTogglingOnDemand || !canManageBilling}
onCheckedChange={handleToggleOnDemand}
/>
)}
</div>
</SettingsSection>
)}
+157
View File
@@ -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)
})
})
+82
View File
@@ -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
}