Skip to content

Commit c360daa

Browse files
committed
improvement(billing): ux around on demand toggling and one-off credits
1 parent 0371856 commit c360daa

4 files changed

Lines changed: 232 additions & 37 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: 28 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { useParams, useRouter } from 'next/navigation'
1818
import { useSession, useSubscription } from '@/lib/auth/auth-client'
1919
import { ON_DEMAND_UNLIMITED } from '@/lib/billing/constants'
2020
import { CREDIT_MULTIPLIER } from '@/lib/billing/credits/conversion'
21+
import { getCoveredUsage, getIsOnDemandActive, getOnDemandOffLimit } from '@/lib/billing/on-demand'
2122
import {
2223
getDisplayPlanName,
2324
getPlanTierCredits,
@@ -199,15 +200,28 @@ export function Billing() {
199200
? organizationBillingData.data.totalUsageLimit
200201
: usageLimitData.currentLimit || usage.limit
201202

202-
const isOnDemandActive =
203-
subscription.isPaid && planIncludedAmount > 0 && effectiveUsageLimit > planIncludedAmount
204-
205203
const effectiveCurrentUsage =
206204
subscription.isOrgScoped && organizationBillingData?.data?.totalCurrentUsage != null
207205
? organizationBillingData.data.totalCurrentUsage
208206
: usage.current
209207

210-
const canDisableOnDemand = isOnDemandActive && effectiveCurrentUsage <= planIncludedAmount
208+
/**
209+
* Goodwill credits are already baked into the usage limit by
210+
* `setUsageLimitForCredits` (limit = planBase + creditBalance). `covered` is
211+
* that same never-billed ceiling, so on-demand is "on" only when the limit is
212+
* raised above it — a credit grant alone must not read as on-demand.
213+
* `creditBalance` is the org's balance for org-scoped admins (resolved
214+
* server-side by `getCreditBalance`) and the user's balance otherwise.
215+
*/
216+
const creditBalance = subscriptionData?.data?.creditBalance ?? 0
217+
const covered = getCoveredUsage(planIncludedAmount, creditBalance)
218+
219+
const isOnDemandActive = getIsOnDemandActive({
220+
isPaid: subscription.isPaid,
221+
planIncludedAmount,
222+
effectiveUsageLimit,
223+
covered,
224+
})
211225

212226
const permissions = getSubscriptionPermissions(
213227
{
@@ -244,31 +258,17 @@ export function Billing() {
244258
)
245259
}
246260

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-
}
261+
const nextLimit = isOnDemandActive
262+
? getOnDemandOffLimit(effectiveCurrentUsage, covered)
263+
: ON_DEMAND_UNLIMITED
264+
265+
if (shouldUseOrganizationBillingContext) {
266+
await updateOrgLimit.mutateAsync({
267+
organizationId: billingOrganizationId!,
268+
limit: nextLimit,
269+
})
263270
} 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-
}
271+
await updateUserLimit.mutateAsync({ limit: nextLimit })
272272
}
273273
} catch (error) {
274274
logger.error('Failed to toggle on-demand billing', { error })
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
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+
} from '@/lib/billing/on-demand'
13+
14+
describe('getPooledCreditsRemaining', () => {
15+
it('returns limit minus usage, matching enforcement (usage >= limit blocks)', () => {
16+
expect(getPooledCreditsRemaining(120, 62)).toBe(58)
17+
expect(getPooledCreditsRemaining(30, 10)).toBe(20)
18+
})
19+
20+
it('does not add the credit balance back (the double-count regression)', () => {
21+
// team_6000, 2 seats: planBase $60 + credits $60 → limit $120, usage ~$62.
22+
// Remaining is $58 ≈ 11,600 credits — NOT $118 ≈ 23,600 (limit + credits - usage).
23+
const remaining = getPooledCreditsRemaining(120, 62)
24+
expect(remaining).toBe(58)
25+
expect(dollarsToCredits(remaining)).toBe(11_600)
26+
expect(dollarsToCredits(remaining)).not.toBe(23_600)
27+
})
28+
29+
it('clamps at zero when usage meets or exceeds the limit', () => {
30+
expect(getPooledCreditsRemaining(100, 100)).toBe(0)
31+
expect(getPooledCreditsRemaining(60, 100)).toBe(0)
32+
})
33+
34+
it('short-circuits the unlimited sentinel to ∞ instead of subtracting usage', () => {
35+
expect(getPooledCreditsRemaining(ON_DEMAND_UNLIMITED, 500)).toBe(ON_DEMAND_UNLIMITED)
36+
expect(getPooledCreditsRemaining(ON_DEMAND_UNLIMITED + 1, 0)).toBe(ON_DEMAND_UNLIMITED)
37+
})
38+
})
39+
40+
describe('getCoveredUsage', () => {
41+
it('sums the plan included amount and the goodwill credit balance', () => {
42+
expect(getCoveredUsage(60, 60)).toBe(120)
43+
expect(getCoveredUsage(30, 0)).toBe(30)
44+
})
45+
})
46+
47+
describe('getIsOnDemandActive', () => {
48+
it('reads OFF when the limit only covers planBase + credits (credit grant is not on-demand)', () => {
49+
// The concrete regression case: limit == covered, so the toggle reads OFF.
50+
expect(
51+
getIsOnDemandActive({
52+
isPaid: true,
53+
planIncludedAmount: 60,
54+
effectiveUsageLimit: 120,
55+
covered: getCoveredUsage(60, 60),
56+
})
57+
).toBe(false)
58+
})
59+
60+
it('reads ON when the limit is raised above the covered ceiling', () => {
61+
expect(
62+
getIsOnDemandActive({
63+
isPaid: true,
64+
planIncludedAmount: 60,
65+
effectiveUsageLimit: ON_DEMAND_UNLIMITED,
66+
covered: getCoveredUsage(60, 60),
67+
})
68+
).toBe(true)
69+
expect(
70+
getIsOnDemandActive({
71+
isPaid: true,
72+
planIncludedAmount: 60,
73+
effectiveUsageLimit: 121,
74+
covered: getCoveredUsage(60, 60),
75+
})
76+
).toBe(true)
77+
})
78+
79+
it('is never active for non-paid plans or a zero included allowance', () => {
80+
expect(
81+
getIsOnDemandActive({
82+
isPaid: false,
83+
planIncludedAmount: 60,
84+
effectiveUsageLimit: ON_DEMAND_UNLIMITED,
85+
covered: 120,
86+
})
87+
).toBe(false)
88+
expect(
89+
getIsOnDemandActive({
90+
isPaid: true,
91+
planIncludedAmount: 0,
92+
effectiveUsageLimit: ON_DEMAND_UNLIMITED,
93+
covered: 0,
94+
})
95+
).toBe(false)
96+
})
97+
98+
it('behaves equivalently on the personal Pro path (no credits)', () => {
99+
const covered = getCoveredUsage(30, 0)
100+
expect(
101+
getIsOnDemandActive({
102+
isPaid: true,
103+
planIncludedAmount: 30,
104+
effectiveUsageLimit: 30,
105+
covered,
106+
})
107+
).toBe(false)
108+
expect(
109+
getIsOnDemandActive({
110+
isPaid: true,
111+
planIncludedAmount: 30,
112+
effectiveUsageLimit: ON_DEMAND_UNLIMITED,
113+
covered,
114+
})
115+
).toBe(true)
116+
})
117+
})
118+
119+
describe('getOnDemandOffLimit', () => {
120+
it('drops the limit to the covered ceiling when usage is below it', () => {
121+
expect(getOnDemandOffLimit(62, 120)).toBe(120)
122+
expect(getOnDemandOffLimit(10, 30)).toBe(30)
123+
})
124+
125+
it('never lowers the limit below current usage', () => {
126+
expect(getOnDemandOffLimit(150, 120)).toBe(150)
127+
})
128+
129+
it('lands on covered when usage equals it', () => {
130+
expect(getOnDemandOffLimit(120, 120)).toBe(120)
131+
})
132+
})

apps/sim/lib/billing/on-demand.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
/**
2+
* On-demand / pooled usage display and toggle math.
3+
*
4+
* DB values are dollars; these helpers operate on dollars and are the single
5+
* source of truth shared by the credits chip and the billing settings toggle so
6+
* the two surfaces can never disagree about what "remaining" or "on-demand on"
7+
* means.
8+
*/
9+
10+
import { ON_DEMAND_UNLIMITED } from '@/lib/billing/constants'
11+
12+
/**
13+
* Dollars of pooled plan allowance still available before usage is capped.
14+
*
15+
* Mirrors enforcement exactly — `buildUsageData` in usage-monitor blocks when
16+
* `currentUsage >= limit`, so remaining is `limit - currentUsage` and nothing
17+
* more. Goodwill credits are already folded into `usageLimit` by
18+
* `setUsageLimitForCredits`, so they must NOT be added back here; doing so
19+
* double-counts the balance and overstates what's left. The unlimited sentinel
20+
* short-circuits to itself — rendered as ∞ — instead of subtracting usage from
21+
* the sentinel value.
22+
*/
23+
export function getPooledCreditsRemaining(usageLimit: number, currentUsage: number): number {
24+
if (usageLimit >= ON_DEMAND_UNLIMITED) return ON_DEMAND_UNLIMITED
25+
return Math.max(0, usageLimit - currentUsage)
26+
}
27+
28+
/**
29+
* The maximum usage that is never billed: the plan's included allowance
30+
* (`planBase`) plus any goodwill credit balance. `setUsageLimitForCredits` raises
31+
* the usage limit to exactly this value when credits are granted, so the stored
32+
* limit already reflects the credits.
33+
*/
34+
export function getCoveredUsage(planIncludedAmount: number, creditBalance: number): number {
35+
return planIncludedAmount + creditBalance
36+
}
37+
38+
/**
39+
* Whether on-demand (past-included) usage is enabled: the usage limit sits above
40+
* the covered ceiling. Only meaningful for a paid plan with a positive included
41+
* allowance. Comparing against `covered` — not `planIncludedAmount` alone — is
42+
* what keeps a credit grant, which raises the limit to `planBase + creditBalance`,
43+
* from being misread as on-demand having been switched on.
44+
*/
45+
export function getIsOnDemandActive(params: {
46+
isPaid: boolean
47+
planIncludedAmount: number
48+
effectiveUsageLimit: number
49+
covered: number
50+
}): boolean {
51+
const { isPaid, planIncludedAmount, effectiveUsageLimit, covered } = params
52+
if (!isPaid || planIncludedAmount <= 0) return false
53+
return effectiveUsageLimit > covered
54+
}
55+
56+
/**
57+
* The usage limit to persist when turning on-demand OFF: drop back to the covered
58+
* ceiling, but never below current usage — lowering the limit under usage would
59+
* retroactively put the account over its cap. When usage already exceeds covered
60+
* the limit lands on current usage and the toggle stays on until usage resets;
61+
* that is an accepted edge, never a blocked action.
62+
*/
63+
export function getOnDemandOffLimit(currentUsage: number, covered: number): number {
64+
return Math.max(currentUsage, covered)
65+
}

0 commit comments

Comments
 (0)