Skip to content

Commit 5055b31

Browse files
icecrasher321waleedlatif1TheodoreSpeaksSg312
authored
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>
1 parent 75c364a commit 5055b31

4 files changed

Lines changed: 315 additions & 42 deletions

File tree

apps/sim/app/workspace/[workspaceId]/home/components/credits-chip/credits-chip.tsx

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@ import { Chip } from '@sim/emcn'
55
import { Credit } from '@sim/emcn/icons'
66
import { useQueryClient } from '@tanstack/react-query'
77
import { useParams, useRouter } from 'next/navigation'
8-
import { ON_DEMAND_UNLIMITED } from '@/lib/billing/constants'
98
import { formatCredits } from '@/lib/billing/credits/conversion'
9+
import { getPooledCreditsRemaining } from '@/lib/billing/on-demand'
1010
import { buildUpgradeHref } from '@/lib/billing/upgrade-reasons'
1111
import { isBillingEnabled } from '@/app/workspace/[workspaceId]/settings/navigation'
1212
import { useMyMemberCredits } from '@/hooks/queries/organization'
@@ -66,19 +66,17 @@ function CreditsChipInner() {
6666
if (memberLoading) return null
6767

6868
/**
69-
* Pooled/plan remaining (dollars): unused plan allowance plus any purchased
70-
* credit balance. Null when the plan-based chip wouldn't show on its own (data
71-
* not ready, or the plan isn't credit-metered). `ON_DEMAND_UNLIMITED` means
72-
* effectively unbounded — rendered as ∞ — so short-circuit instead of
73-
* subtracting usage from the sentinel.
69+
* Pooled/plan remaining (dollars): unused plan allowance, matching enforcement
70+
* (`currentUsage >= limit` blocks, so remaining is `limit - currentUsage`).
71+
* Granted credits are already folded into `usageLimit`, so they are not added
72+
* again here. Null when the plan-based chip wouldn't show on its own (data not
73+
* ready, or the plan isn't credit-metered). The unlimited sentinel renders as ∞.
7474
*/
7575
const pooledData = !isLoading && hasData && planView.showCredits ? (data?.data ?? null) : null
7676
const pooledRemaining =
7777
pooledData === null
7878
? null
79-
: pooledData.usageLimit >= ON_DEMAND_UNLIMITED
80-
? ON_DEMAND_UNLIMITED
81-
: Math.max(0, pooledData.usageLimit + pooledData.creditBalance - pooledData.currentUsage)
79+
: getPooledCreditsRemaining(pooledData.usageLimit, pooledData.currentUsage)
8280

8381
/**
8482
* A per-member cap is the authoritative personal remaining, but the actor gate

apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.tsx

Lines changed: 69 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
chipVariants,
99
cn,
1010
Switch,
11+
Tooltip,
1112
toast,
1213
} from '@sim/emcn'
1314
import { createLogger } from '@sim/logger'
@@ -18,6 +19,12 @@ import { useParams, useRouter } from 'next/navigation'
1819
import { useSession, useSubscription } from '@/lib/auth/auth-client'
1920
import { ON_DEMAND_UNLIMITED } from '@/lib/billing/constants'
2021
import { CREDIT_MULTIPLIER } from '@/lib/billing/credits/conversion'
22+
import {
23+
getCoveredUsage,
24+
getIsOnDemandActive,
25+
getOnDemandOffLimit,
26+
isOnDemandOffDisabled,
27+
} from '@/lib/billing/on-demand'
2128
import {
2229
getDisplayPlanName,
2330
getPlanTierCredits,
@@ -199,15 +206,41 @@ export function Billing() {
199206
? organizationBillingData.data.totalUsageLimit
200207
: usageLimitData.currentLimit || usage.limit
201208

202-
const isOnDemandActive =
203-
subscription.isPaid && planIncludedAmount > 0 && effectiveUsageLimit > planIncludedAmount
204-
205209
const effectiveCurrentUsage =
206210
subscription.isOrgScoped && organizationBillingData?.data?.totalCurrentUsage != null
207211
? organizationBillingData.data.totalCurrentUsage
208212
: usage.current
209213

210-
const canDisableOnDemand = isOnDemandActive && effectiveCurrentUsage <= planIncludedAmount
214+
/**
215+
* Goodwill credits are already baked into the usage limit by
216+
* `setUsageLimitForCredits` (limit = planBase + creditBalance). `covered` is
217+
* that same never-billed ceiling, so on-demand is "on" only when the limit is
218+
* raised above it — a credit grant alone must not read as on-demand.
219+
* `creditBalance` is the org's balance for org-scoped admins (resolved
220+
* server-side by `getCreditBalance`) and the user's balance otherwise.
221+
*/
222+
const creditBalance = subscriptionData?.data?.creditBalance ?? 0
223+
const covered = getCoveredUsage(planIncludedAmount, creditBalance)
224+
225+
const isOnDemandActive = getIsOnDemandActive({
226+
isPaid: subscription.isPaid,
227+
planIncludedAmount,
228+
effectiveUsageLimit,
229+
covered,
230+
})
231+
232+
/**
233+
* When usage already sits above `covered`, turning on-demand off would re-cap
234+
* the limit at current usage and the switch would bounce straight back on
235+
* (see `getOnDemandOffLimit`). Disable it and explain why via tooltip instead
236+
* of accepting a no-op click; it re-enables once usage drops back to/below
237+
* covered (e.g. the next billing reset).
238+
*/
239+
const onDemandLockedOn = isOnDemandOffDisabled({
240+
isOnDemandActive,
241+
effectiveCurrentUsage,
242+
covered,
243+
})
211244

212245
const permissions = getSubscriptionPermissions(
213246
{
@@ -244,31 +277,17 @@ export function Billing() {
244277
)
245278
}
246279

247-
if (isOnDemandActive) {
248-
if (!canDisableOnDemand) {
249-
toast.error("Can't turn off on-demand usage", {
250-
description:
251-
"Your usage is above your plan's included amount. It can be turned off once usage drops below it.",
252-
})
253-
return
254-
}
255-
if (shouldUseOrganizationBillingContext) {
256-
await updateOrgLimit.mutateAsync({
257-
organizationId: billingOrganizationId!,
258-
limit: planIncludedAmount,
259-
})
260-
} else {
261-
await updateUserLimit.mutateAsync({ limit: planIncludedAmount })
262-
}
280+
const nextLimit = isOnDemandActive
281+
? getOnDemandOffLimit(effectiveCurrentUsage, covered)
282+
: ON_DEMAND_UNLIMITED
283+
284+
if (shouldUseOrganizationBillingContext) {
285+
await updateOrgLimit.mutateAsync({
286+
organizationId: billingOrganizationId!,
287+
limit: nextLimit,
288+
})
263289
} else {
264-
if (shouldUseOrganizationBillingContext) {
265-
await updateOrgLimit.mutateAsync({
266-
organizationId: billingOrganizationId!,
267-
limit: ON_DEMAND_UNLIMITED,
268-
})
269-
} else {
270-
await updateUserLimit.mutateAsync({ limit: ON_DEMAND_UNLIMITED })
271-
}
290+
await updateUserLimit.mutateAsync({ limit: nextLimit })
272291
}
273292
} catch (error) {
274293
logger.error('Failed to toggle on-demand billing', { error })
@@ -452,11 +471,28 @@ export function Billing() {
452471
<span className='text-[var(--text-body)] text-small'>
453472
Allow usage to go past included usage
454473
</span>
455-
<Switch
456-
checked={isOnDemandActive}
457-
disabled={isTogglingOnDemand || !canManageBilling}
458-
onCheckedChange={handleToggleOnDemand}
459-
/>
474+
{onDemandLockedOn ? (
475+
<Tooltip.Root>
476+
<Tooltip.Trigger asChild>
477+
<span className='inline-flex'>
478+
<Switch checked disabled onCheckedChange={handleToggleOnDemand} />
479+
</span>
480+
</Tooltip.Trigger>
481+
<Tooltip.Content className='max-w-[260px]'>
482+
<p>
483+
{
484+
"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."
485+
}
486+
</p>
487+
</Tooltip.Content>
488+
</Tooltip.Root>
489+
) : (
490+
<Switch
491+
checked={isOnDemandActive}
492+
disabled={isTogglingOnDemand || !canManageBilling}
493+
onCheckedChange={handleToggleOnDemand}
494+
/>
495+
)}
460496
</div>
461497
</SettingsSection>
462498
)}
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it } from 'vitest'
5+
import { ON_DEMAND_UNLIMITED } from '@/lib/billing/constants'
6+
import { dollarsToCredits } from '@/lib/billing/credits/conversion'
7+
import {
8+
getCoveredUsage,
9+
getIsOnDemandActive,
10+
getOnDemandOffLimit,
11+
getPooledCreditsRemaining,
12+
isOnDemandOffDisabled,
13+
} from '@/lib/billing/on-demand'
14+
15+
describe('getPooledCreditsRemaining', () => {
16+
it('returns limit minus usage, matching enforcement (usage >= limit blocks)', () => {
17+
expect(getPooledCreditsRemaining(120, 62)).toBe(58)
18+
expect(getPooledCreditsRemaining(30, 10)).toBe(20)
19+
})
20+
21+
it('does not add the credit balance back (the double-count regression)', () => {
22+
// team_6000, 2 seats: planBase $60 + credits $60 → limit $120, usage ~$62.
23+
// Remaining is $58 ≈ 11,600 credits — NOT $118 ≈ 23,600 (limit + credits - usage).
24+
const remaining = getPooledCreditsRemaining(120, 62)
25+
expect(remaining).toBe(58)
26+
expect(dollarsToCredits(remaining)).toBe(11_600)
27+
expect(dollarsToCredits(remaining)).not.toBe(23_600)
28+
})
29+
30+
it('clamps at zero when usage meets or exceeds the limit', () => {
31+
expect(getPooledCreditsRemaining(100, 100)).toBe(0)
32+
expect(getPooledCreditsRemaining(60, 100)).toBe(0)
33+
})
34+
35+
it('short-circuits the unlimited sentinel to ∞ instead of subtracting usage', () => {
36+
expect(getPooledCreditsRemaining(ON_DEMAND_UNLIMITED, 500)).toBe(ON_DEMAND_UNLIMITED)
37+
expect(getPooledCreditsRemaining(ON_DEMAND_UNLIMITED + 1, 0)).toBe(ON_DEMAND_UNLIMITED)
38+
})
39+
})
40+
41+
describe('getCoveredUsage', () => {
42+
it('sums the plan included amount and the goodwill credit balance', () => {
43+
expect(getCoveredUsage(60, 60)).toBe(120)
44+
expect(getCoveredUsage(30, 0)).toBe(30)
45+
})
46+
})
47+
48+
describe('getIsOnDemandActive', () => {
49+
it('reads OFF when the limit only covers planBase + credits (credit grant is not on-demand)', () => {
50+
// The concrete regression case: limit == covered, so the toggle reads OFF.
51+
expect(
52+
getIsOnDemandActive({
53+
isPaid: true,
54+
planIncludedAmount: 60,
55+
effectiveUsageLimit: 120,
56+
covered: getCoveredUsage(60, 60),
57+
})
58+
).toBe(false)
59+
})
60+
61+
it('reads ON when the limit is raised above the covered ceiling', () => {
62+
expect(
63+
getIsOnDemandActive({
64+
isPaid: true,
65+
planIncludedAmount: 60,
66+
effectiveUsageLimit: ON_DEMAND_UNLIMITED,
67+
covered: getCoveredUsage(60, 60),
68+
})
69+
).toBe(true)
70+
expect(
71+
getIsOnDemandActive({
72+
isPaid: true,
73+
planIncludedAmount: 60,
74+
effectiveUsageLimit: 121,
75+
covered: getCoveredUsage(60, 60),
76+
})
77+
).toBe(true)
78+
})
79+
80+
it('is never active for non-paid plans or a zero included allowance', () => {
81+
expect(
82+
getIsOnDemandActive({
83+
isPaid: false,
84+
planIncludedAmount: 60,
85+
effectiveUsageLimit: ON_DEMAND_UNLIMITED,
86+
covered: 120,
87+
})
88+
).toBe(false)
89+
expect(
90+
getIsOnDemandActive({
91+
isPaid: true,
92+
planIncludedAmount: 0,
93+
effectiveUsageLimit: ON_DEMAND_UNLIMITED,
94+
covered: 0,
95+
})
96+
).toBe(false)
97+
})
98+
99+
it('behaves equivalently on the personal Pro path (no credits)', () => {
100+
const covered = getCoveredUsage(30, 0)
101+
expect(
102+
getIsOnDemandActive({
103+
isPaid: true,
104+
planIncludedAmount: 30,
105+
effectiveUsageLimit: 30,
106+
covered,
107+
})
108+
).toBe(false)
109+
expect(
110+
getIsOnDemandActive({
111+
isPaid: true,
112+
planIncludedAmount: 30,
113+
effectiveUsageLimit: ON_DEMAND_UNLIMITED,
114+
covered,
115+
})
116+
).toBe(true)
117+
})
118+
})
119+
120+
describe('getOnDemandOffLimit', () => {
121+
it('drops the limit to the covered ceiling when usage is below it', () => {
122+
expect(getOnDemandOffLimit(62, 120)).toBe(120)
123+
expect(getOnDemandOffLimit(10, 30)).toBe(30)
124+
})
125+
126+
it('never lowers the limit below current usage', () => {
127+
expect(getOnDemandOffLimit(150, 120)).toBe(150)
128+
})
129+
130+
it('lands on covered when usage equals it', () => {
131+
expect(getOnDemandOffLimit(120, 120)).toBe(120)
132+
})
133+
})
134+
135+
describe('isOnDemandOffDisabled', () => {
136+
it('disables the toggle when on-demand is on and usage is above covered', () => {
137+
// Turning off here would re-cap at usage (150) and bounce back on, so lock it.
138+
expect(
139+
isOnDemandOffDisabled({ isOnDemandActive: true, effectiveCurrentUsage: 150, covered: 120 })
140+
).toBe(true)
141+
})
142+
143+
it('allows turning off when usage is at or below covered', () => {
144+
expect(
145+
isOnDemandOffDisabled({ isOnDemandActive: true, effectiveCurrentUsage: 120, covered: 120 })
146+
).toBe(false)
147+
expect(
148+
isOnDemandOffDisabled({ isOnDemandActive: true, effectiveCurrentUsage: 62, covered: 120 })
149+
).toBe(false)
150+
})
151+
152+
it('never disables when on-demand is already off (turning on stays allowed)', () => {
153+
expect(
154+
isOnDemandOffDisabled({ isOnDemandActive: false, effectiveCurrentUsage: 150, covered: 120 })
155+
).toBe(false)
156+
})
157+
})

0 commit comments

Comments
 (0)