chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Number of pills to display in usage indicators.
|
||||
*/
|
||||
export const USAGE_PILL_COUNT = 8
|
||||
|
||||
/**
|
||||
* Usage percentage thresholds for visual states.
|
||||
*/
|
||||
export const USAGE_THRESHOLDS = {
|
||||
/** Warning threshold (yellow/orange state) */
|
||||
WARNING: 75,
|
||||
/** Critical threshold (red state) */
|
||||
CRITICAL: 90,
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Color values for usage pill states using CSS variables
|
||||
*/
|
||||
export const USAGE_PILL_COLORS = {
|
||||
/** Unfilled pill color (gray) */
|
||||
UNFILLED: 'var(--surface-7)',
|
||||
/** Normal filled pill color (blue) */
|
||||
FILLED: 'var(--brand-secondary)',
|
||||
/** Warning state pill color (yellow/orange) */
|
||||
WARNING: 'var(--warning)',
|
||||
/** Critical/limit reached pill color (red) */
|
||||
AT_LIMIT: 'var(--text-error)',
|
||||
} as const
|
||||
@@ -0,0 +1,14 @@
|
||||
export { USAGE_PILL_COLORS, USAGE_THRESHOLDS } from './consts'
|
||||
export {
|
||||
type CtaIntent,
|
||||
type CtaVariant,
|
||||
derivePlanView,
|
||||
getUpgradeCardCta,
|
||||
type PlanCardCta,
|
||||
type PlanTier,
|
||||
type PlanView,
|
||||
resolvePlanTier,
|
||||
type UpgradeCardId,
|
||||
} from './plan-view'
|
||||
export { useLimitUpgradeToast } from './use-limit-upgrade-toast'
|
||||
export { getFilledPillColor, getSubscriptionAccessState } from './utils'
|
||||
@@ -0,0 +1,118 @@
|
||||
import { CREDIT_TIERS } from '@/lib/billing/constants'
|
||||
import { getPlanTierCredits, isEnterprise, isFree, isPaid } from '@/lib/billing/plan-helpers'
|
||||
|
||||
/**
|
||||
* Canonical client-side plan abstraction.
|
||||
*
|
||||
* Single source of truth for the plan-derived UI decisions shared across the
|
||||
* upgrade page, the home credits chip, the sidebar usage indicator, and the
|
||||
* settings billing page: which tier the user is on, the per-card CTA on the
|
||||
* upgrade page, whether the upgrade page is accessible, and whether credit
|
||||
* balances should be shown.
|
||||
*
|
||||
* Pure and framework-free — see {@link usePlanView} for the React Query wrapper.
|
||||
*/
|
||||
|
||||
/** Credit-tier-resolved plan identity used to drive upgrade-page UI. */
|
||||
export type PlanTier = 'free' | 'pro' | 'max' | 'enterprise'
|
||||
|
||||
/** The three plan cards rendered on the upgrade page. */
|
||||
export type UpgradeCardId = 'pro' | 'max' | 'enterprise'
|
||||
|
||||
/** Chip variant a card's CTA renders with. */
|
||||
export type CtaVariant = 'primary' | 'border-shadow'
|
||||
|
||||
/** What activating a card's CTA does, mapped to a handler by the consumer. */
|
||||
export type CtaIntent = 'upgrade' | 'downgrade' | 'manage' | 'sales'
|
||||
|
||||
/** Plan-derived CTA descriptor for a single upgrade card. */
|
||||
export interface PlanCardCta {
|
||||
label: string
|
||||
variant: CtaVariant
|
||||
intent: CtaIntent
|
||||
/** True when this card is the highlighted next step up from the current plan. */
|
||||
highlighted: boolean
|
||||
}
|
||||
|
||||
/** Plan-derived view consumed by every billing surface. */
|
||||
export interface PlanView {
|
||||
tier: PlanTier
|
||||
isFree: boolean
|
||||
isPaid: boolean
|
||||
isEnterprise: boolean
|
||||
/** Enterprise manages billing out-of-band — it cannot use the self-serve upgrade page. */
|
||||
canAccessUpgrade: boolean
|
||||
/** Credit balances are meaningless for enterprise (custom limits) and are hidden. */
|
||||
showCredits: boolean
|
||||
}
|
||||
|
||||
const MAX_TIER_CREDITS = CREDIT_TIERS[1].credits
|
||||
|
||||
/** Tier ordering used to derive upgrade/downgrade/highlight relationships. */
|
||||
const PLAN_RANK: Record<PlanTier, number> = { free: 0, pro: 1, max: 2, enterprise: 3 }
|
||||
|
||||
/**
|
||||
* Resolve a plan name to its credit-tier identity. Paid pro/team plans split
|
||||
* into `pro` / `max` by their credit allocation (>= 25k credits => Max).
|
||||
*/
|
||||
export function resolvePlanTier(plan: string | null | undefined): PlanTier {
|
||||
if (isEnterprise(plan)) return 'enterprise'
|
||||
if (isFree(plan)) return 'free'
|
||||
return getPlanTierCredits(plan) >= MAX_TIER_CREDITS ? 'max' : 'pro'
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the CTA for a single upgrade card given the current plan tier.
|
||||
*
|
||||
* The highlighted (primary) card is always the immediate next step up from the
|
||||
* current tier. The same-tier card reads "Current Plan" (a non-actionable
|
||||
* marker — plan management lives on the Billing settings page) and lower-tier
|
||||
* cards offer "Downgrade plan" (a secondary `border-shadow` chip). A higher card
|
||||
* reads "Get started" only while the user has no paid plan yet; once they are on
|
||||
* a paid tier it becomes an explicit "Upgrade plan".
|
||||
*/
|
||||
export function getUpgradeCardCta(current: PlanTier, card: UpgradeCardId): PlanCardCta {
|
||||
const isNextStepUp = PLAN_RANK[card] === PLAN_RANK[current] + 1
|
||||
|
||||
if (card === 'enterprise') {
|
||||
return {
|
||||
label: 'Talk to sales',
|
||||
variant: isNextStepUp ? 'primary' : 'border-shadow',
|
||||
intent: 'sales',
|
||||
highlighted: isNextStepUp,
|
||||
}
|
||||
}
|
||||
|
||||
if (PLAN_RANK[current] === PLAN_RANK[card]) {
|
||||
return { label: 'Current Plan', variant: 'border-shadow', intent: 'manage', highlighted: false }
|
||||
}
|
||||
|
||||
if (PLAN_RANK[current] > PLAN_RANK[card]) {
|
||||
return {
|
||||
label: 'Downgrade plan',
|
||||
variant: 'border-shadow',
|
||||
intent: 'downgrade',
|
||||
highlighted: false,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
label: current === 'free' ? 'Get started' : 'Upgrade plan',
|
||||
variant: isNextStepUp ? 'primary' : 'border-shadow',
|
||||
intent: 'upgrade',
|
||||
highlighted: isNextStepUp,
|
||||
}
|
||||
}
|
||||
|
||||
/** Derive the shared plan view from a plan name. */
|
||||
export function derivePlanView(plan: string | null | undefined): PlanView {
|
||||
const enterprise = isEnterprise(plan)
|
||||
return {
|
||||
tier: resolvePlanTier(plan),
|
||||
isFree: isFree(plan),
|
||||
isPaid: isPaid(plan),
|
||||
isEnterprise: enterprise,
|
||||
canAccessUpgrade: !enterprise,
|
||||
showCredits: !enterprise,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
export interface UsageData {
|
||||
current: number
|
||||
limit: number
|
||||
percentUsed: number
|
||||
isWarning: boolean
|
||||
isExceeded: boolean
|
||||
billingPeriodStart: Date | string | null
|
||||
billingPeriodEnd: Date | string | null
|
||||
lastPeriodCost: number
|
||||
lastPeriodCopilotCost?: number
|
||||
copilotCost?: number
|
||||
}
|
||||
|
||||
interface UsageLimitData {
|
||||
currentLimit: number
|
||||
canEdit: boolean
|
||||
minimumLimit: number
|
||||
plan: string
|
||||
setBy?: string
|
||||
updatedAt?: Date
|
||||
}
|
||||
|
||||
export interface SubscriptionData {
|
||||
isPaid: boolean
|
||||
isPro: boolean
|
||||
isTeam: boolean
|
||||
isEnterprise: boolean
|
||||
/** True when the subscription's `referenceId` is an organization. */
|
||||
isOrgScoped: boolean
|
||||
organizationId: string | null
|
||||
plan: string
|
||||
status: string | null
|
||||
seats: number | null
|
||||
metadata: any | null
|
||||
stripeSubscriptionId: string | null
|
||||
periodEnd: Date | string | null
|
||||
cancelAtPeriodEnd?: boolean
|
||||
usage: UsageData
|
||||
billingBlocked?: boolean
|
||||
}
|
||||
|
||||
export type BillingStatus = 'unknown' | 'ok' | 'warning' | 'exceeded' | 'blocked'
|
||||
|
||||
interface SubscriptionStore {
|
||||
subscriptionData: SubscriptionData | null
|
||||
usageLimitData: UsageLimitData | null
|
||||
isLoading: boolean
|
||||
error: string | null
|
||||
lastFetched: number | null
|
||||
loadSubscriptionData: () => Promise<SubscriptionData | null>
|
||||
loadUsageLimitData: () => Promise<UsageLimitData | null>
|
||||
loadData: () => Promise<{
|
||||
subscriptionData: SubscriptionData | null
|
||||
usageLimitData: UsageLimitData | null
|
||||
}>
|
||||
updateUsageLimit: (newLimit: number) => Promise<{ success: boolean; error?: string }>
|
||||
refresh: () => Promise<void>
|
||||
clearError: () => void
|
||||
reset: () => void
|
||||
getSubscriptionStatus: () => {
|
||||
isPaid: boolean
|
||||
isPro: boolean
|
||||
isTeam: boolean
|
||||
isEnterprise: boolean
|
||||
isOrgScoped: boolean
|
||||
organizationId: string | null
|
||||
isFree: boolean
|
||||
plan: string
|
||||
status: string | null
|
||||
seats: number | null
|
||||
metadata: any | null
|
||||
}
|
||||
getUsage: () => UsageData
|
||||
getBillingStatus: () => BillingStatus
|
||||
getRemainingBudget: () => number
|
||||
getDaysRemainingInPeriod: () => number | null
|
||||
isAtLeastPro: () => boolean
|
||||
isAtLeastTeam: () => boolean
|
||||
canUpgrade: () => boolean
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
import { useCallback } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { isOrgAdminRole } from '@sim/platform-authz/predicates'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { ApiClientError } from '@/lib/api/client/errors'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import { listCreatorOrganizationsContract } from '@/lib/api/contracts/organizations'
|
||||
import { subscriptionTransferContract } from '@/lib/api/contracts/user'
|
||||
import { client, useSession, useSubscription } from '@/lib/auth/auth-client'
|
||||
import { buildPlanName, getDisplayPlanName, isPaid } from '@/lib/billing/plan-helpers'
|
||||
import { hasPaidSubscriptionStatus } from '@/lib/billing/subscriptions/utils'
|
||||
import { organizationKeys } from '@/hooks/queries/organization'
|
||||
|
||||
const logger = createLogger('SubscriptionUpgrade')
|
||||
|
||||
type TargetPlan = 'pro' | 'team'
|
||||
|
||||
const CONSTANTS = {
|
||||
INITIAL_TEAM_SEATS: 1,
|
||||
DEFAULT_CREDIT_TIER: 6000,
|
||||
} as const
|
||||
|
||||
interface UpgradeOptions {
|
||||
creditTier?: number
|
||||
annual?: boolean
|
||||
}
|
||||
|
||||
export function useSubscriptionUpgrade() {
|
||||
const { data: session } = useSession()
|
||||
const betterAuthSubscription = useSubscription()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const handleUpgrade = useCallback(
|
||||
async (targetPlan: TargetPlan, options?: UpgradeOptions) => {
|
||||
const creditTier = options?.creditTier ?? CONSTANTS.DEFAULT_CREDIT_TIER
|
||||
const annual = options?.annual ?? false
|
||||
const planName = buildPlanName(targetPlan, creditTier)
|
||||
const userId = session?.user?.id
|
||||
if (!userId) {
|
||||
throw new Error('User not authenticated')
|
||||
}
|
||||
|
||||
let currentSubscriptionRowId: string | undefined
|
||||
let currentStripeSubscriptionId: string | undefined
|
||||
let allSubscriptions: any[] = []
|
||||
try {
|
||||
const listResult = await client.subscription.list()
|
||||
allSubscriptions = listResult.data || []
|
||||
const activePersonalSub = allSubscriptions.find(
|
||||
(sub: any) => hasPaidSubscriptionStatus(sub.status) && sub.referenceId === userId
|
||||
)
|
||||
currentSubscriptionRowId = activePersonalSub?.id
|
||||
currentStripeSubscriptionId = activePersonalSub?.stripeSubscriptionId
|
||||
} catch (_e) {
|
||||
currentSubscriptionRowId = undefined
|
||||
currentStripeSubscriptionId = undefined
|
||||
}
|
||||
|
||||
if (currentSubscriptionRowId && !currentStripeSubscriptionId) {
|
||||
logger.error('Active paid subscription is missing its Stripe subscription ID', {
|
||||
userId,
|
||||
subscriptionRowId: currentSubscriptionRowId,
|
||||
targetPlan,
|
||||
})
|
||||
throw new Error(
|
||||
'We could not match your current plan with our payment provider. Please contact support before upgrading so you are not charged twice.'
|
||||
)
|
||||
}
|
||||
|
||||
let referenceId = userId
|
||||
|
||||
if (targetPlan === 'team') {
|
||||
try {
|
||||
let orgsData
|
||||
try {
|
||||
orgsData = await requestJson(listCreatorOrganizationsContract, {})
|
||||
} catch (err) {
|
||||
if (err instanceof ApiClientError) {
|
||||
throw new Error('Failed to check organization status')
|
||||
}
|
||||
throw err
|
||||
}
|
||||
const existingOrg = orgsData.organizations?.find((org) => isOrgAdminRole(org.role))
|
||||
|
||||
if (existingOrg) {
|
||||
const existingOrgSub = allSubscriptions.find(
|
||||
(sub: any) =>
|
||||
hasPaidSubscriptionStatus(sub.status) &&
|
||||
sub.referenceId === existingOrg.id &&
|
||||
isPaid(sub.plan)
|
||||
)
|
||||
|
||||
if (existingOrgSub) {
|
||||
logger.warn('Organization already has an active subscription', {
|
||||
userId,
|
||||
organizationId: existingOrg.id,
|
||||
existingSubscriptionId: existingOrgSub.id,
|
||||
plan: existingOrgSub.plan,
|
||||
})
|
||||
const existingPlanName = getDisplayPlanName(existingOrgSub.plan)
|
||||
throw new Error(
|
||||
`This organization is already on the ${existingPlanName} plan. Manage it from the billing settings.`
|
||||
)
|
||||
}
|
||||
|
||||
logger.info('Using existing organization for team plan upgrade', {
|
||||
userId,
|
||||
organizationId: existingOrg.id,
|
||||
})
|
||||
referenceId = existingOrg.id
|
||||
|
||||
try {
|
||||
await client.organization.setActive({ organizationId: referenceId })
|
||||
logger.info('Set organization as active', { organizationId: referenceId })
|
||||
} catch (error) {
|
||||
logger.warn('Failed to set organization as active, proceeding with upgrade', {
|
||||
organizationId: referenceId,
|
||||
error: getErrorMessage(error, 'Unknown error'),
|
||||
})
|
||||
}
|
||||
} else if (orgsData.isMemberOfAnyOrg) {
|
||||
throw new Error(
|
||||
'You are already a member of an organization. Please leave it or ask an admin to upgrade.'
|
||||
)
|
||||
} else {
|
||||
logger.info('Will create organization after payment succeeds', { userId })
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to prepare for team plan upgrade', error)
|
||||
throw error instanceof Error
|
||||
? error
|
||||
: new Error('Failed to prepare team workspace. Please try again or contact support.')
|
||||
}
|
||||
}
|
||||
|
||||
const currentUrl = `${window.location.origin}${window.location.pathname}`
|
||||
const successUrlObj = new URL(window.location.href)
|
||||
successUrlObj.searchParams.set('upgraded', 'true')
|
||||
const successUrl = successUrlObj.toString()
|
||||
|
||||
try {
|
||||
const upgradeParams = {
|
||||
plan: planName,
|
||||
referenceId,
|
||||
successUrl,
|
||||
cancelUrl: currentUrl,
|
||||
...(targetPlan === 'team' && { seats: CONSTANTS.INITIAL_TEAM_SEATS }),
|
||||
...(annual && { annual: true }),
|
||||
} as const
|
||||
|
||||
const finalParams = currentStripeSubscriptionId
|
||||
? { ...upgradeParams, subscriptionId: currentStripeSubscriptionId }
|
||||
: upgradeParams
|
||||
|
||||
logger.info(
|
||||
currentStripeSubscriptionId
|
||||
? 'Upgrading existing subscription'
|
||||
: 'Creating new subscription',
|
||||
{
|
||||
targetPlan,
|
||||
planName,
|
||||
annual,
|
||||
currentStripeSubscriptionId,
|
||||
currentSubscriptionRowId,
|
||||
referenceId,
|
||||
}
|
||||
)
|
||||
|
||||
await betterAuthSubscription.upgrade(finalParams)
|
||||
|
||||
if (targetPlan === 'team' && currentSubscriptionRowId && referenceId !== userId) {
|
||||
try {
|
||||
logger.info('Transferring subscription to organization after upgrade', {
|
||||
subscriptionId: currentSubscriptionRowId,
|
||||
organizationId: referenceId,
|
||||
})
|
||||
|
||||
try {
|
||||
await requestJson(subscriptionTransferContract, {
|
||||
params: { id: currentSubscriptionRowId },
|
||||
body: { organizationId: referenceId },
|
||||
})
|
||||
logger.info('Successfully transferred subscription to organization', {
|
||||
subscriptionId: currentSubscriptionRowId,
|
||||
organizationId: referenceId,
|
||||
})
|
||||
} catch (transferError) {
|
||||
logger.error('Failed to transfer subscription to organization', {
|
||||
subscriptionId: currentSubscriptionRowId,
|
||||
organizationId: referenceId,
|
||||
error:
|
||||
transferError instanceof ApiClientError
|
||||
? (transferError.rawBody ?? transferError.message)
|
||||
: transferError instanceof Error
|
||||
? transferError.message
|
||||
: 'Unknown error',
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error transferring subscription after upgrade', error)
|
||||
}
|
||||
}
|
||||
|
||||
if (targetPlan === 'team') {
|
||||
try {
|
||||
await queryClient.invalidateQueries({ queryKey: organizationKeys.lists() })
|
||||
logger.info('Refreshed organization data after team upgrade')
|
||||
} catch (error) {
|
||||
logger.warn('Failed to refresh organization data after upgrade', error)
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('Subscription upgrade completed successfully', { targetPlan, referenceId })
|
||||
} catch (error) {
|
||||
logger.error('Failed to initiate subscription upgrade:', error)
|
||||
|
||||
if (error instanceof Error) {
|
||||
logger.error('Detailed error:', {
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
cause: error.cause,
|
||||
})
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Failed to upgrade subscription: ${getErrorMessage(error, 'Unknown error')}`
|
||||
)
|
||||
}
|
||||
},
|
||||
[session?.user?.id, betterAuthSubscription, queryClient]
|
||||
)
|
||||
|
||||
return { handleUpgrade }
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback } from 'react'
|
||||
import { toast } from '@sim/emcn'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { buildUpgradeHref, type UpgradeReason } from '@/lib/billing/upgrade-reasons'
|
||||
|
||||
/**
|
||||
* Returns a callback that surfaces a usage-limit error as an actionable toast
|
||||
* with an "Upgrade" button deep-linking to the reason-tagged upgrade page.
|
||||
*
|
||||
* The toast persists until dismissed (emcn keeps actionable toasts open), so the
|
||||
* user always has the upgrade path within reach when they hit a limit.
|
||||
*/
|
||||
export function useLimitUpgradeToast() {
|
||||
const router = useRouter()
|
||||
const { workspaceId } = useParams<{ workspaceId: string }>()
|
||||
|
||||
return useCallback(
|
||||
(reason: UpgradeReason, message: string) => {
|
||||
toast.error(message, {
|
||||
action: {
|
||||
label: 'Upgrade',
|
||||
onClick: () => router.push(buildUpgradeHref(workspaceId, reason)),
|
||||
},
|
||||
})
|
||||
},
|
||||
[router, workspaceId]
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* Helper functions for subscription-related computations
|
||||
* These are pure functions that compute values from subscription data
|
||||
*/
|
||||
|
||||
import { DEFAULT_FREE_CREDITS } from '@/lib/billing/constants'
|
||||
import { getPlanTierCredits, isEnterprise, isFree, isPro } from '@/lib/billing/plan-helpers'
|
||||
import { hasUsableSubscriptionAccess } from '@/lib/billing/subscriptions/utils'
|
||||
import { USAGE_PILL_COLORS } from './consts'
|
||||
import type { BillingStatus, SubscriptionData, UsageData } from './types'
|
||||
|
||||
const defaultUsage: UsageData = {
|
||||
current: 0,
|
||||
limit: DEFAULT_FREE_CREDITS,
|
||||
percentUsed: 0,
|
||||
isWarning: false,
|
||||
isExceeded: false,
|
||||
billingPeriodStart: null,
|
||||
billingPeriodEnd: null,
|
||||
lastPeriodCost: 0,
|
||||
}
|
||||
|
||||
/**
|
||||
* Get subscription status flags from subscription data
|
||||
*/
|
||||
export function getSubscriptionStatus(
|
||||
subscriptionData: Partial<SubscriptionData> | null | undefined
|
||||
) {
|
||||
return {
|
||||
isPaid: subscriptionData?.isPaid ?? false,
|
||||
isPro: subscriptionData?.isPro ?? false,
|
||||
isTeam: subscriptionData?.isTeam ?? false,
|
||||
isEnterprise: subscriptionData?.isEnterprise ?? false,
|
||||
isOrgScoped: subscriptionData?.isOrgScoped ?? false,
|
||||
organizationId: subscriptionData?.organizationId ?? null,
|
||||
isFree: !(subscriptionData?.isPaid ?? false),
|
||||
plan: subscriptionData?.plan ?? 'free',
|
||||
status: subscriptionData?.status ?? null,
|
||||
seats: subscriptionData?.seats ?? null,
|
||||
metadata: subscriptionData?.metadata ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
export function getSubscriptionAccessState(
|
||||
subscriptionData: Partial<SubscriptionData> | null | undefined
|
||||
) {
|
||||
const status = getSubscriptionStatus(subscriptionData)
|
||||
const billingBlocked = Boolean(subscriptionData?.billingBlocked)
|
||||
const hasUsablePaidAccess = hasUsableSubscriptionAccess(status.status, billingBlocked)
|
||||
// Team-management features (invitations, seats, roles) are available on
|
||||
// any paid subscription attached to an organization — including `pro_*`
|
||||
// plans that have been transferred to an org. Plan-name gating would
|
||||
// miss those.
|
||||
const hasUsableTeamAccess =
|
||||
hasUsablePaidAccess && (status.isOrgScoped || status.isTeam || status.isEnterprise)
|
||||
const hasUsableEnterpriseAccess = hasUsablePaidAccess && status.isEnterprise
|
||||
const hasUsableMaxAccess =
|
||||
hasUsablePaidAccess && (getPlanTierCredits(status.plan) >= 25000 || isEnterprise(status.plan))
|
||||
|
||||
return {
|
||||
...status,
|
||||
billingBlocked,
|
||||
hasUsablePaidAccess,
|
||||
hasUsableTeamAccess,
|
||||
hasUsableEnterpriseAccess,
|
||||
hasUsableMaxAccess,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get usage data from subscription data
|
||||
* Validates and sanitizes all numeric values to prevent crashes from malformed data
|
||||
*/
|
||||
export function getUsage(
|
||||
subscriptionData: Partial<SubscriptionData> | null | undefined
|
||||
): UsageData {
|
||||
const usage = subscriptionData?.usage
|
||||
|
||||
if (!usage) {
|
||||
return defaultUsage
|
||||
}
|
||||
|
||||
return {
|
||||
current:
|
||||
typeof usage.current === 'number' && Number.isFinite(usage.current) ? usage.current : 0,
|
||||
limit:
|
||||
typeof usage.limit === 'number' && Number.isFinite(usage.limit)
|
||||
? usage.limit
|
||||
: DEFAULT_FREE_CREDITS,
|
||||
percentUsed:
|
||||
typeof usage.percentUsed === 'number' && Number.isFinite(usage.percentUsed)
|
||||
? usage.percentUsed
|
||||
: 0,
|
||||
isWarning: Boolean(usage.isWarning),
|
||||
isExceeded: Boolean(usage.isExceeded),
|
||||
billingPeriodStart: usage.billingPeriodStart ?? null,
|
||||
billingPeriodEnd: usage.billingPeriodEnd ?? null,
|
||||
lastPeriodCost:
|
||||
typeof usage.lastPeriodCost === 'number' && Number.isFinite(usage.lastPeriodCost)
|
||||
? usage.lastPeriodCost
|
||||
: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get billing status based on usage and blocked state
|
||||
*/
|
||||
export function getBillingStatus(
|
||||
subscriptionData: Partial<SubscriptionData> | null | undefined
|
||||
): BillingStatus {
|
||||
const usage = getUsage(subscriptionData)
|
||||
const blocked = subscriptionData?.billingBlocked
|
||||
if (blocked) return 'blocked'
|
||||
if (usage.isExceeded) return 'exceeded'
|
||||
if (usage.isWarning) return 'warning'
|
||||
return 'ok'
|
||||
}
|
||||
|
||||
/**
|
||||
* Get remaining budget
|
||||
*/
|
||||
export function getRemainingBudget(
|
||||
subscriptionData: Partial<SubscriptionData> | null | undefined
|
||||
): number {
|
||||
const usage = getUsage(subscriptionData)
|
||||
return Math.max(0, usage.limit - usage.current)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get days remaining in billing period
|
||||
*/
|
||||
export function getDaysRemainingInPeriod(
|
||||
subscriptionData: Partial<SubscriptionData> | null | undefined
|
||||
): number | null {
|
||||
const usage = getUsage(subscriptionData)
|
||||
if (!usage.billingPeriodEnd) return null
|
||||
|
||||
const now = new Date()
|
||||
const endDate =
|
||||
typeof usage.billingPeriodEnd === 'string'
|
||||
? new Date(usage.billingPeriodEnd)
|
||||
: usage.billingPeriodEnd
|
||||
const diffTime = endDate.getTime() - now.getTime()
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24))
|
||||
|
||||
return Math.max(0, diffDays)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if subscription is at least Pro tier
|
||||
*/
|
||||
export function isAtLeastPro(
|
||||
subscriptionData: Partial<SubscriptionData> | null | undefined
|
||||
): boolean {
|
||||
const status = getSubscriptionStatus(subscriptionData)
|
||||
return status.isPro || status.isTeam || status.isEnterprise
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if subscription is at least Team tier
|
||||
*/
|
||||
export function isAtLeastTeam(
|
||||
subscriptionData: Partial<SubscriptionData> | null | undefined
|
||||
): boolean {
|
||||
const status = getSubscriptionStatus(subscriptionData)
|
||||
return status.isTeam || status.isEnterprise
|
||||
}
|
||||
|
||||
export function canUpgrade(
|
||||
subscriptionData: Partial<SubscriptionData> | null | undefined
|
||||
): boolean {
|
||||
const status = getSubscriptionStatus(subscriptionData)
|
||||
return isFree(status.plan) || isPro(status.plan)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the appropriate filled pill color based on usage thresholds.
|
||||
*
|
||||
* @param isCritical - Whether usage is at critical level (blocked or >= 90%)
|
||||
* @param isWarning - Whether usage is at warning level (>= 75% but < critical)
|
||||
* @returns CSS color value for filled pills
|
||||
*/
|
||||
export function getFilledPillColor(isCritical: boolean, isWarning: boolean): string {
|
||||
if (isCritical) return USAGE_PILL_COLORS.AT_LIMIT
|
||||
if (isWarning) return USAGE_PILL_COLORS.WARNING
|
||||
return USAGE_PILL_COLORS.FILLED
|
||||
}
|
||||
Reference in New Issue
Block a user