Feat/workspaces 6 billing (#8508)

## Summary

Implements billing infrastructure for team workspaces, separate from
legacy personal billing.

## Changes

- **Billing abstraction**: New `useBillingContext` composable that
switches between legacy (personal) and workspace billing based on
context
- **Workspace subscription flows**: Pricing tables, plan transitions,
cancellation dialogs, and payment preview components for workspace
billing
- **Top-up credits**: Workspace-specific top-up dialog with polling for
payment confirmation
- **Workspace API**: Extended with billing endpoints (subscriptions,
invoices, payment methods, credits top-up)
- **Workspace switcher**: Now displays tier badges for each workspace
- **Subscribe polling**: Added polling mechanisms
(`useSubscribePolling`, `useTopupPolling`) for async payment flows

## Review Focus

- Billing flow correctness for workspace vs legacy contexts
- Polling timeout and error handling in payment flows

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8508-Feat-workspaces-6-billing-2f96d73d365081f69f65c1ddf369010d)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Simula_r
2026-02-06 20:52:53 -08:00
committed by GitHub
parent 030d4fd4d5
commit c5431de123
54 changed files with 4861 additions and 568 deletions

View File

@@ -0,0 +1,76 @@
import type { ComputedRef, Ref } from 'vue'
import type {
Plan,
PreviewSubscribeResponse,
SubscribeResponse,
SubscriptionDuration,
SubscriptionTier
} from '@/platform/workspace/api/workspaceApi'
export type BillingType = 'legacy' | 'workspace'
export interface SubscriptionInfo {
isActive: boolean
tier: SubscriptionTier | null
duration: SubscriptionDuration | null
planSlug: string | null
renewalDate: string | null
endDate: string | null
isCancelled: boolean
hasFunds: boolean
}
export interface BalanceInfo {
amountMicros: number
currency: string
effectiveBalanceMicros?: number
prepaidBalanceMicros?: number
cloudCreditBalanceMicros?: number
}
export interface BillingActions {
initialize: () => Promise<void>
fetchStatus: () => Promise<void>
fetchBalance: () => Promise<void>
subscribe: (
planSlug: string,
returnUrl?: string,
cancelUrl?: string
) => Promise<SubscribeResponse | void>
previewSubscribe: (
planSlug: string
) => Promise<PreviewSubscribeResponse | null>
manageSubscription: () => Promise<void>
cancelSubscription: () => Promise<void>
fetchPlans: () => Promise<void>
/**
* Ensures billing is initialized and subscription is active.
* Shows subscription dialog if not subscribed.
* Use this in extensions/entry points that require active subscription.
*/
requireActiveSubscription: () => Promise<void>
/**
* Shows the subscription dialog.
*/
showSubscriptionDialog: () => void
}
export interface BillingState {
isInitialized: Ref<boolean>
subscription: ComputedRef<SubscriptionInfo | null>
balance: ComputedRef<BalanceInfo | null>
plans: ComputedRef<Plan[]>
currentPlanSlug: ComputedRef<string | null>
isLoading: Ref<boolean>
error: Ref<string | null>
/**
* Convenience computed for checking if subscription is active.
* Equivalent to `subscription.value?.isActive ?? false`
*/
isActiveSubscription: ComputedRef<boolean>
}
export interface BillingContext extends BillingState, BillingActions {
type: ComputedRef<BillingType>
}

View File

@@ -0,0 +1,164 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useBillingContext } from './useBillingContext'
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => {
const isInPersonalWorkspace = { value: true }
const activeWorkspace = { value: { id: 'personal-123', type: 'personal' } }
return {
useTeamWorkspaceStore: () => ({
isInPersonalWorkspace: isInPersonalWorkspace.value,
activeWorkspace: activeWorkspace.value,
_setPersonalWorkspace: (value: boolean) => {
isInPersonalWorkspace.value = value
activeWorkspace.value = value
? { id: 'personal-123', type: 'personal' }
: { id: 'team-456', type: 'team' }
}
})
}
})
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
useSubscription: () => ({
isActiveSubscription: { value: true },
subscriptionTier: { value: 'PRO' },
subscriptionDuration: { value: 'MONTHLY' },
formattedRenewalDate: { value: 'Jan 1, 2025' },
formattedEndDate: { value: '' },
isCancelled: { value: false },
fetchStatus: vi.fn().mockResolvedValue(undefined),
manageSubscription: vi.fn().mockResolvedValue(undefined),
subscribe: vi.fn().mockResolvedValue(undefined),
showSubscriptionDialog: vi.fn()
})
}))
vi.mock(
'@/platform/cloud/subscription/composables/useSubscriptionDialog',
() => ({
useSubscriptionDialog: () => ({
show: vi.fn(),
hide: vi.fn()
})
})
)
vi.mock('@/stores/firebaseAuthStore', () => ({
useFirebaseAuthStore: () => ({
balance: { amount_micros: 5000000 },
fetchBalance: vi.fn().mockResolvedValue({ amount_micros: 5000000 })
})
}))
vi.mock('@/platform/cloud/subscription/composables/useBillingPlans', () => {
const plans = { value: [] }
const currentPlanSlug = { value: null }
return {
useBillingPlans: () => ({
plans,
currentPlanSlug,
isLoading: { value: false },
error: { value: null },
fetchPlans: vi.fn().mockResolvedValue(undefined),
getPlanBySlug: vi.fn().mockReturnValue(null)
})
}
})
vi.mock('@/platform/workspace/api/workspaceApi', () => ({
workspaceApi: {
getBillingStatus: vi.fn().mockResolvedValue({
is_active: true,
has_funds: true,
subscription_tier: 'PRO',
subscription_duration: 'MONTHLY'
}),
getBillingBalance: vi.fn().mockResolvedValue({
amount_micros: 10000000,
currency: 'usd'
}),
subscribe: vi.fn().mockResolvedValue({ status: 'subscribed' }),
previewSubscribe: vi.fn().mockResolvedValue({ allowed: true })
}
}))
describe('useBillingContext', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
})
it('returns legacy type for personal workspace', () => {
const { type } = useBillingContext()
expect(type.value).toBe('legacy')
})
it('provides subscription info from legacy billing', () => {
const { subscription } = useBillingContext()
expect(subscription.value).toEqual({
isActive: true,
tier: 'PRO',
duration: 'MONTHLY',
planSlug: null,
renewalDate: 'Jan 1, 2025',
endDate: null,
isCancelled: false,
hasFunds: true
})
})
it('provides balance info from legacy billing', () => {
const { balance } = useBillingContext()
expect(balance.value).toEqual({
amountMicros: 5000000,
currency: 'usd',
effectiveBalanceMicros: 5000000,
prepaidBalanceMicros: 0,
cloudCreditBalanceMicros: 0
})
})
it('exposes initialize action', async () => {
const { initialize } = useBillingContext()
await expect(initialize()).resolves.toBeUndefined()
})
it('exposes fetchStatus action', async () => {
const { fetchStatus } = useBillingContext()
await expect(fetchStatus()).resolves.toBeUndefined()
})
it('exposes fetchBalance action', async () => {
const { fetchBalance } = useBillingContext()
await expect(fetchBalance()).resolves.toBeUndefined()
})
it('exposes subscribe action', async () => {
const { subscribe } = useBillingContext()
await expect(subscribe('pro-monthly')).resolves.toBeUndefined()
})
it('exposes manageSubscription action', async () => {
const { manageSubscription } = useBillingContext()
await expect(manageSubscription()).resolves.toBeUndefined()
})
it('provides isActiveSubscription convenience computed', () => {
const { isActiveSubscription } = useBillingContext()
expect(isActiveSubscription.value).toBe(true)
})
it('exposes requireActiveSubscription action', async () => {
const { requireActiveSubscription } = useBillingContext()
await expect(requireActiveSubscription()).resolves.toBeUndefined()
})
it('exposes showSubscriptionDialog action', () => {
const { showSubscriptionDialog } = useBillingContext()
expect(() => showSubscriptionDialog()).not.toThrow()
})
})

View File

@@ -0,0 +1,242 @@
import { computed, ref, shallowRef, toValue, watch } from 'vue'
import { createSharedComposable } from '@vueuse/core'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import type {
BalanceInfo,
BillingActions,
BillingContext,
BillingType,
BillingState,
SubscriptionInfo
} from './types'
import { useLegacyBilling } from './useLegacyBilling'
import { useWorkspaceBilling } from './useWorkspaceBilling'
/**
* Unified billing context that automatically switches between legacy (user-scoped)
* and workspace billing based on the active workspace type.
*
* - Personal workspaces use legacy billing via /customers/* endpoints
* - Team workspaces use workspace billing via /billing/* endpoints
*
* The context automatically initializes when the workspace changes and provides
* a unified interface for subscription status, balance, and billing actions.
*
* @example
* ```typescript
* const {
* type,
* subscription,
* balance,
* isInitialized,
* initialize,
* subscribe
* } = useBillingContext()
*
* // Wait for initialization
* await initialize()
*
* // Check subscription status
* if (subscription.value?.isActive) {
* console.log(`Tier: ${subscription.value.tier}`)
* }
*
* // Check balance
* if (balance.value) {
* const dollars = balance.value.amountMicros / 1_000_000
* console.log(`Balance: $${dollars.toFixed(2)}`)
* }
* ```
*/
function useBillingContextInternal(): BillingContext {
const store = useTeamWorkspaceStore()
const { flags } = useFeatureFlags()
const legacyBillingRef = shallowRef<(BillingState & BillingActions) | null>(
null
)
const workspaceBillingRef = shallowRef<
(BillingState & BillingActions) | null
>(null)
const getLegacyBilling = () => {
if (!legacyBillingRef.value) {
legacyBillingRef.value = useLegacyBilling()
}
return legacyBillingRef.value
}
const getWorkspaceBilling = () => {
if (!workspaceBillingRef.value) {
workspaceBillingRef.value = useWorkspaceBilling()
}
return workspaceBillingRef.value
}
const isInitialized = ref(false)
const isLoading = ref(false)
const error = ref<string | null>(null)
/**
* Determines which billing type to use:
* - If team workspaces feature is disabled: always use legacy (/customers)
* - If team workspaces feature is enabled:
* - Personal workspace: use legacy (/customers)
* - Team workspace: use workspace (/billing)
*/
const type = computed<BillingType>(() => {
if (!flags.teamWorkspacesEnabled) return 'legacy'
return store.isInPersonalWorkspace ? 'legacy' : 'workspace'
})
const activeContext = computed(() =>
type.value === 'legacy' ? getLegacyBilling() : getWorkspaceBilling()
)
// Proxy state from active context
const subscription = computed<SubscriptionInfo | null>(() =>
toValue(activeContext.value.subscription)
)
const balance = computed<BalanceInfo | null>(() =>
toValue(activeContext.value.balance)
)
const plans = computed(() => toValue(activeContext.value.plans))
const currentPlanSlug = computed(() =>
toValue(activeContext.value.currentPlanSlug)
)
const isActiveSubscription = computed(() =>
toValue(activeContext.value.isActiveSubscription)
)
// Sync subscription info to workspace store for display in workspace switcher
// A subscription is considered "subscribed" for workspace purposes if it's active AND not cancelled
// This ensures the delete button is enabled after cancellation, even before the period ends
watch(
subscription,
(sub) => {
if (!sub || store.isInPersonalWorkspace) return
store.updateActiveWorkspace({
isSubscribed: sub.isActive && !sub.isCancelled,
subscriptionPlan: sub.planSlug
})
},
{ immediate: true }
)
// Initialize billing when workspace changes
watch(
() => store.activeWorkspace?.id,
async (newWorkspaceId, oldWorkspaceId) => {
if (!newWorkspaceId) {
// No workspace selected - reset state
isInitialized.value = false
error.value = null
return
}
if (newWorkspaceId !== oldWorkspaceId) {
// Workspace changed - reinitialize
isInitialized.value = false
try {
await initialize()
} catch (err) {
// Error is already captured in error ref
console.error('Failed to initialize billing context:', err)
}
}
},
{ immediate: true }
)
async function initialize(): Promise<void> {
if (isInitialized.value) return
isLoading.value = true
error.value = null
try {
await activeContext.value.initialize()
isInitialized.value = true
} catch (err) {
error.value =
err instanceof Error ? err.message : 'Failed to initialize billing'
throw err
} finally {
isLoading.value = false
}
}
async function fetchStatus(): Promise<void> {
return activeContext.value.fetchStatus()
}
async function fetchBalance(): Promise<void> {
return activeContext.value.fetchBalance()
}
async function subscribe(
planSlug: string,
returnUrl?: string,
cancelUrl?: string
) {
return activeContext.value.subscribe(planSlug, returnUrl, cancelUrl)
}
async function previewSubscribe(planSlug: string) {
return activeContext.value.previewSubscribe(planSlug)
}
async function manageSubscription() {
return activeContext.value.manageSubscription()
}
async function cancelSubscription() {
return activeContext.value.cancelSubscription()
}
async function fetchPlans() {
return activeContext.value.fetchPlans()
}
async function requireActiveSubscription() {
return activeContext.value.requireActiveSubscription()
}
function showSubscriptionDialog() {
return activeContext.value.showSubscriptionDialog()
}
return {
type,
isInitialized,
subscription,
balance,
plans,
currentPlanSlug,
isLoading,
error,
isActiveSubscription,
initialize,
fetchStatus,
fetchBalance,
subscribe,
previewSubscribe,
manageSubscription,
cancelSubscription,
fetchPlans,
requireActiveSubscription,
showSubscriptionDialog
}
}
export const useBillingContext = createSharedComposable(
useBillingContextInternal
)

View File

@@ -0,0 +1,189 @@
import { computed, ref } from 'vue'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import type {
PreviewSubscribeResponse,
SubscribeResponse
} from '@/platform/workspace/api/workspaceApi'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import type {
BalanceInfo,
BillingActions,
BillingState,
SubscriptionInfo
} from './types'
/**
* Adapter for legacy user-scoped billing via /customers/* endpoints.
* Used for personal workspaces.
* @internal - Use useBillingContext() instead of importing directly.
*/
export function useLegacyBilling(): BillingState & BillingActions {
const {
isActiveSubscription: legacyIsActiveSubscription,
subscriptionTier,
subscriptionDuration,
formattedRenewalDate,
formattedEndDate,
isCancelled,
fetchStatus: legacyFetchStatus,
manageSubscription: legacyManageSubscription,
subscribe: legacySubscribe,
showSubscriptionDialog: legacyShowSubscriptionDialog
} = useSubscription()
const firebaseAuthStore = useFirebaseAuthStore()
const isInitialized = ref(false)
const isLoading = ref(false)
const error = ref<string | null>(null)
const isActiveSubscription = computed(() => legacyIsActiveSubscription.value)
const subscription = computed<SubscriptionInfo | null>(() => {
if (!legacyIsActiveSubscription.value && !subscriptionTier.value) {
return null
}
return {
isActive: legacyIsActiveSubscription.value,
tier: subscriptionTier.value,
duration: subscriptionDuration.value,
planSlug: null, // Legacy doesn't use plan slugs
renewalDate: formattedRenewalDate.value || null,
endDate: formattedEndDate.value || null,
isCancelled: isCancelled.value,
hasFunds: (firebaseAuthStore.balance?.amount_micros ?? 0) > 0
}
})
const balance = computed<BalanceInfo | null>(() => {
const legacyBalance = firebaseAuthStore.balance
if (!legacyBalance) return null
return {
amountMicros: legacyBalance.amount_micros ?? 0,
currency: legacyBalance.currency ?? 'usd',
effectiveBalanceMicros:
legacyBalance.effective_balance_micros ??
legacyBalance.amount_micros ??
0,
prepaidBalanceMicros: legacyBalance.prepaid_balance_micros ?? 0,
cloudCreditBalanceMicros: legacyBalance.cloud_credit_balance_micros ?? 0
}
})
// Legacy billing doesn't have workspace-style plans
const plans = computed(() => [])
const currentPlanSlug = computed(() => null)
async function initialize(): Promise<void> {
if (isInitialized.value) return
isLoading.value = true
error.value = null
try {
await Promise.all([fetchStatus(), fetchBalance()])
isInitialized.value = true
} catch (err) {
error.value =
err instanceof Error ? err.message : 'Failed to initialize billing'
throw err
} finally {
isLoading.value = false
}
}
async function fetchStatus(): Promise<void> {
isLoading.value = true
error.value = null
try {
await legacyFetchStatus()
} catch (err) {
error.value =
err instanceof Error ? err.message : 'Failed to fetch subscription'
throw err
} finally {
isLoading.value = false
}
}
async function fetchBalance(): Promise<void> {
isLoading.value = true
error.value = null
try {
await firebaseAuthStore.fetchBalance()
} catch (err) {
error.value =
err instanceof Error ? err.message : 'Failed to fetch balance'
throw err
} finally {
isLoading.value = false
}
}
async function subscribe(
_planSlug: string,
_returnUrl?: string,
_cancelUrl?: string
): Promise<SubscribeResponse | void> {
// Legacy billing uses Stripe checkout flow via useSubscription
await legacySubscribe()
}
async function previewSubscribe(
_planSlug: string
): Promise<PreviewSubscribeResponse | null> {
// Legacy billing doesn't support preview - returns null
return null
}
async function manageSubscription(): Promise<void> {
await legacyManageSubscription()
}
async function cancelSubscription(): Promise<void> {
await legacyManageSubscription()
}
async function fetchPlans(): Promise<void> {
// Legacy billing doesn't have workspace-style plans
// Plans are hardcoded in the UI for legacy subscriptions
}
async function requireActiveSubscription(): Promise<void> {
await fetchStatus()
if (!isActiveSubscription.value) {
legacyShowSubscriptionDialog()
}
}
function showSubscriptionDialog(): void {
legacyShowSubscriptionDialog()
}
return {
// State
isInitialized,
subscription,
balance,
plans,
currentPlanSlug,
isLoading,
error,
isActiveSubscription,
// Actions
initialize,
fetchStatus,
fetchBalance,
subscribe,
previewSubscribe,
manageSubscription,
cancelSubscription,
fetchPlans,
requireActiveSubscription,
showSubscriptionDialog
}
}

View File

@@ -0,0 +1,311 @@
import { computed, onBeforeUnmount, ref, shallowRef } from 'vue'
import { useBillingPlans } from '@/platform/cloud/subscription/composables/useBillingPlans'
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import type {
BillingBalanceResponse,
BillingStatusResponse,
PreviewSubscribeResponse,
SubscribeResponse
} from '@/platform/workspace/api/workspaceApi'
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import type {
BalanceInfo,
BillingActions,
BillingState,
SubscriptionInfo
} from './types'
/**
* Adapter for workspace-scoped billing via /billing/* endpoints.
* Used for team workspaces.
* @internal - Use useBillingContext() instead of importing directly.
*/
export function useWorkspaceBilling(): BillingState & BillingActions {
const billingPlans = useBillingPlans()
const workspaceStore = useTeamWorkspaceStore()
const isInitialized = ref(false)
const isLoading = ref(false)
const error = ref<string | null>(null)
const statusData = shallowRef<BillingStatusResponse | null>(null)
const balanceData = shallowRef<BillingBalanceResponse | null>(null)
const isActiveSubscription = computed(
() => statusData.value?.is_active ?? false
)
const subscription = computed<SubscriptionInfo | null>(() => {
const status = statusData.value
if (!status) return null
return {
isActive: status.is_active,
tier: status.subscription_tier ?? null,
duration: status.subscription_duration ?? null,
planSlug: status.plan_slug ?? null,
renewalDate: null, // Workspace billing uses cancel_at for end date
endDate: status.cancel_at ?? null,
isCancelled: status.subscription_status === 'canceled',
hasFunds: status.has_funds
}
})
const balance = computed<BalanceInfo | null>(() => {
const data = balanceData.value
if (!data) return null
return {
amountMicros: data.amount_micros,
currency: data.currency,
effectiveBalanceMicros: data.effective_balance_micros,
prepaidBalanceMicros: data.prepaid_balance_micros,
cloudCreditBalanceMicros: data.cloud_credit_balance_micros
}
})
const plans = computed(() => billingPlans.plans.value)
const currentPlanSlug = computed(
() => statusData.value?.plan_slug ?? billingPlans.currentPlanSlug.value
)
const pendingCancelOpId = ref<string | null>(null)
let cancelPollTimeout: number | null = null
const stopCancelPolling = () => {
if (cancelPollTimeout !== null) {
window.clearTimeout(cancelPollTimeout)
cancelPollTimeout = null
}
}
async function pollCancelStatus(opId: string): Promise<void> {
stopCancelPolling()
const maxAttempts = 30
let attempt = 0
const poll = async () => {
if (pendingCancelOpId.value !== opId) return
try {
const response = await workspaceApi.getBillingOpStatus(opId)
if (response.status === 'succeeded') {
pendingCancelOpId.value = null
stopCancelPolling()
await fetchStatus()
workspaceStore.updateActiveWorkspace({
isSubscribed: false
})
return
}
if (response.status === 'failed') {
pendingCancelOpId.value = null
stopCancelPolling()
throw new Error(
response.error_message ?? 'Failed to cancel subscription'
)
}
attempt += 1
if (attempt >= maxAttempts) {
pendingCancelOpId.value = null
stopCancelPolling()
await fetchStatus()
return
}
} catch (err) {
pendingCancelOpId.value = null
stopCancelPolling()
throw err
}
cancelPollTimeout = window.setTimeout(
() => {
void poll()
},
Math.min(1000 * 2 ** attempt, 5000)
)
}
await poll()
}
async function initialize(): Promise<void> {
if (isInitialized.value) return
isLoading.value = true
error.value = null
try {
await Promise.all([fetchStatus(), fetchBalance(), fetchPlans()])
isInitialized.value = true
} catch (err) {
error.value =
err instanceof Error ? err.message : 'Failed to initialize billing'
throw err
} finally {
isLoading.value = false
}
}
async function fetchStatus(): Promise<void> {
isLoading.value = true
error.value = null
try {
statusData.value = await workspaceApi.getBillingStatus()
} catch (err) {
error.value =
err instanceof Error ? err.message : 'Failed to fetch billing status'
throw err
} finally {
isLoading.value = false
}
}
async function fetchBalance(): Promise<void> {
isLoading.value = true
error.value = null
try {
balanceData.value = await workspaceApi.getBillingBalance()
} catch (err) {
error.value =
err instanceof Error ? err.message : 'Failed to fetch balance'
throw err
} finally {
isLoading.value = false
}
}
async function subscribe(
planSlug: string,
returnUrl?: string,
cancelUrl?: string
): Promise<SubscribeResponse> {
isLoading.value = true
error.value = null
try {
const response = await workspaceApi.subscribe(
planSlug,
returnUrl,
cancelUrl
)
// Refresh status and balance after subscription
await Promise.all([fetchStatus(), fetchBalance()])
return response
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to subscribe'
throw err
} finally {
isLoading.value = false
}
}
async function previewSubscribe(
planSlug: string
): Promise<PreviewSubscribeResponse | null> {
isLoading.value = true
error.value = null
try {
return await workspaceApi.previewSubscribe(planSlug)
} catch (err) {
error.value =
err instanceof Error ? err.message : 'Failed to preview subscription'
throw err
} finally {
isLoading.value = false
}
}
async function manageSubscription(): Promise<void> {
isLoading.value = true
error.value = null
try {
const returnUrl = window.location.href
const response = await workspaceApi.getPaymentPortalUrl(returnUrl)
if (response.url) {
window.open(response.url, '_blank')
}
} catch (err) {
error.value =
err instanceof Error ? err.message : 'Failed to open billing portal'
throw err
} finally {
isLoading.value = false
}
}
async function cancelSubscription(): Promise<void> {
isLoading.value = true
error.value = null
try {
const response = await workspaceApi.cancelSubscription()
pendingCancelOpId.value = response.billing_op_id
await pollCancelStatus(response.billing_op_id)
} catch (err) {
error.value =
err instanceof Error ? err.message : 'Failed to cancel subscription'
throw err
} finally {
isLoading.value = false
}
}
async function fetchPlans(): Promise<void> {
isLoading.value = true
error.value = null
try {
await billingPlans.fetchPlans()
if (billingPlans.error.value) {
error.value = billingPlans.error.value
}
} finally {
isLoading.value = false
}
}
const subscriptionDialog = useSubscriptionDialog()
async function requireActiveSubscription(): Promise<void> {
await fetchStatus()
if (!isActiveSubscription.value) {
subscriptionDialog.show()
}
}
function showSubscriptionDialog(): void {
subscriptionDialog.show()
}
onBeforeUnmount(() => {
stopCancelPolling()
})
return {
// State
isInitialized,
subscription,
balance,
plans,
currentPlanSlug,
isLoading,
error,
isActiveSubscription,
// Actions
initialize,
fetchStatus,
fetchBalance,
subscribe,
previewSubscribe,
manageSubscription,
cancelSubscription,
fetchPlans,
requireActiveSubscription,
showSubscriptionDialog
}
}