mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
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:
co-authored by
Theodore Li
Waleed
Theodore Li
Siddharth Ganesan
parent
75c364a989
commit
5055b31e82
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user