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,504 @@
import { setActivePinia, createPinia } from 'pinia'
import { beforeEach, describe, expect, it, vi, afterEach } from 'vitest'
import type { BillingOpStatusResponse } from '@/platform/workspace/api/workspaceApi'
const mockFetchStatus = vi.fn()
const mockFetchBalance = vi.fn()
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({
fetchStatus: mockFetchStatus,
fetchBalance: mockFetchBalance
})
}))
const mockToastAdd = vi.fn()
const mockToastRemove = vi.fn()
vi.mock('@/platform/updates/common/toastStore', () => ({
useToastStore: () => ({
add: mockToastAdd,
remove: mockToastRemove
})
}))
vi.mock('@/platform/workspace/api/workspaceApi', () => ({
workspaceApi: {
getBillingOpStatus: vi.fn()
}
}))
vi.mock('@/i18n', () => ({
t: (key: string) => key
}))
vi.mock('@/services/dialogService', () => ({
useDialogService: () => ({
showSettingsDialog: vi.fn()
})
}))
vi.mock('@/stores/dialogStore', () => ({
useDialogStore: () => ({
closeDialog: vi.fn()
})
}))
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
import { useBillingOperationStore } from './billingOperationStore'
describe('billingOperationStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
describe('startOperation', () => {
it('creates a pending operation', () => {
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
id: 'op-1',
status: 'pending',
started_at: new Date().toISOString()
})
const store = useBillingOperationStore()
store.startOperation('op-1', 'subscription')
expect(store.operations.size).toBe(1)
const operation = store.getOperation('op-1')
expect(operation).toBeDefined()
expect(operation?.status).toBe('pending')
expect(operation?.type).toBe('subscription')
expect(store.hasPendingOperations).toBe(true)
})
it('does not create duplicate operations', () => {
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
id: 'op-1',
status: 'pending',
started_at: new Date().toISOString()
})
const store = useBillingOperationStore()
store.startOperation('op-1', 'subscription')
store.startOperation('op-1', 'topup')
expect(store.operations.size).toBe(1)
expect(store.getOperation('op-1')?.type).toBe('subscription')
})
it('shows immediate processing toast for subscription operations', () => {
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
id: 'op-1',
status: 'pending',
started_at: new Date().toISOString()
})
const store = useBillingOperationStore()
store.startOperation('op-1', 'subscription')
expect(mockToastAdd).toHaveBeenCalledWith({
severity: 'info',
summary: 'billingOperation.subscriptionProcessing',
group: 'billing-operation'
})
})
it('shows immediate processing toast for topup operations', () => {
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
id: 'op-1',
status: 'pending',
started_at: new Date().toISOString()
})
const store = useBillingOperationStore()
store.startOperation('op-1', 'topup')
expect(mockToastAdd).toHaveBeenCalledWith({
severity: 'info',
summary: 'billingOperation.topupProcessing',
group: 'billing-operation'
})
})
})
describe('polling success', () => {
it('updates status and shows toast on success', async () => {
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
id: 'op-1',
status: 'succeeded',
started_at: new Date().toISOString(),
completed_at: new Date().toISOString()
})
const store = useBillingOperationStore()
store.startOperation('op-1', 'subscription')
await vi.advanceTimersByTimeAsync(0)
const operation = store.getOperation('op-1')
expect(operation?.status).toBe('succeeded')
expect(store.hasPendingOperations).toBe(false)
expect(mockFetchStatus).toHaveBeenCalled()
expect(mockFetchBalance).toHaveBeenCalled()
expect(mockToastAdd).toHaveBeenCalledWith({
severity: 'success',
summary: 'billingOperation.subscriptionSuccess',
life: 5000
})
})
it('shows topup success message for topup operations', async () => {
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
id: 'op-1',
status: 'succeeded',
started_at: new Date().toISOString()
})
const store = useBillingOperationStore()
store.startOperation('op-1', 'topup')
await vi.advanceTimersByTimeAsync(0)
expect(mockToastAdd).toHaveBeenCalledWith({
severity: 'success',
summary: 'billingOperation.topupSuccess',
life: 5000
})
})
it('removes the received toast when operation succeeds', async () => {
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
id: 'op-1',
status: 'succeeded',
started_at: new Date().toISOString()
})
const store = useBillingOperationStore()
store.startOperation('op-1', 'subscription')
const receivedToast = mockToastAdd.mock.calls[0][0]
await vi.advanceTimersByTimeAsync(0)
expect(mockToastRemove).toHaveBeenCalledWith(receivedToast)
})
})
describe('polling failure', () => {
it('updates status and shows error toast on failure', async () => {
const errorMessage = 'Payment declined'
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
id: 'op-1',
status: 'failed',
error_message: errorMessage,
started_at: new Date().toISOString()
})
const store = useBillingOperationStore()
store.startOperation('op-1', 'subscription')
await vi.advanceTimersByTimeAsync(0)
const operation = store.getOperation('op-1')
expect(operation?.status).toBe('failed')
expect(operation?.errorMessage).toBe(errorMessage)
expect(store.hasPendingOperations).toBe(false)
expect(mockToastAdd).toHaveBeenCalledWith({
severity: 'error',
summary: 'billingOperation.subscriptionFailed',
detail: errorMessage,
life: 5000
})
})
it('uses default message when no error_message in response', async () => {
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
id: 'op-1',
status: 'failed',
started_at: new Date().toISOString()
})
const store = useBillingOperationStore()
store.startOperation('op-1', 'topup')
await vi.advanceTimersByTimeAsync(0)
expect(mockToastAdd).toHaveBeenCalledWith({
severity: 'error',
summary: 'billingOperation.topupFailed',
detail: undefined,
life: 5000
})
})
})
describe('polling timeout', () => {
it('times out after 2 minutes and shows error toast', async () => {
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
id: 'op-1',
status: 'pending',
started_at: new Date().toISOString()
})
const store = useBillingOperationStore()
store.startOperation('op-1', 'subscription')
await vi.advanceTimersByTimeAsync(0)
await vi.advanceTimersByTimeAsync(121_000)
await vi.runAllTimersAsync()
const operation = store.getOperation('op-1')
expect(operation?.status).toBe('timeout')
expect(store.hasPendingOperations).toBe(false)
expect(mockToastAdd).toHaveBeenCalledWith({
severity: 'error',
summary: 'billingOperation.subscriptionTimeout',
life: 5000
})
})
it('shows topup timeout message for topup operations', async () => {
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
id: 'op-1',
status: 'pending',
started_at: new Date().toISOString()
})
const store = useBillingOperationStore()
store.startOperation('op-1', 'topup')
await vi.advanceTimersByTimeAsync(121_000)
await vi.runAllTimersAsync()
expect(mockToastAdd).toHaveBeenCalledWith({
severity: 'error',
summary: 'billingOperation.topupTimeout',
life: 5000
})
})
})
describe('exponential backoff', () => {
it('uses exponential backoff for polling intervals', async () => {
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
id: 'op-1',
status: 'pending',
started_at: new Date().toISOString()
})
const store = useBillingOperationStore()
store.startOperation('op-1', 'subscription')
await vi.advanceTimersByTimeAsync(0)
expect(workspaceApi.getBillingOpStatus).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(1500)
expect(workspaceApi.getBillingOpStatus).toHaveBeenCalledTimes(2)
await vi.advanceTimersByTimeAsync(2250)
expect(workspaceApi.getBillingOpStatus).toHaveBeenCalledTimes(3)
})
it('caps polling interval at 8 seconds', async () => {
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
id: 'op-1',
status: 'pending',
started_at: new Date().toISOString()
})
const store = useBillingOperationStore()
store.startOperation('op-1', 'subscription')
await vi.advanceTimersByTimeAsync(60_000)
const callCountBefore = vi.mocked(workspaceApi.getBillingOpStatus).mock
.calls.length
await vi.advanceTimersByTimeAsync(8000)
expect(
vi.mocked(workspaceApi.getBillingOpStatus).mock.calls.length
).toBeGreaterThan(callCountBefore)
})
})
describe('network errors', () => {
it('continues polling on network errors', async () => {
vi.mocked(workspaceApi.getBillingOpStatus)
.mockRejectedValueOnce(new Error('Network error'))
.mockRejectedValueOnce(new Error('Network error'))
.mockResolvedValueOnce({
id: 'op-1',
status: 'succeeded',
started_at: new Date().toISOString()
} satisfies BillingOpStatusResponse)
const store = useBillingOperationStore()
store.startOperation('op-1', 'subscription')
await vi.advanceTimersByTimeAsync(0)
expect(store.getOperation('op-1')?.status).toBe('pending')
await vi.advanceTimersByTimeAsync(1500)
expect(store.getOperation('op-1')?.status).toBe('pending')
await vi.advanceTimersByTimeAsync(2250)
expect(store.getOperation('op-1')?.status).toBe('succeeded')
})
})
describe('clearOperation', () => {
it('removes operation from the store', async () => {
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
id: 'op-1',
status: 'succeeded',
started_at: new Date().toISOString()
})
const store = useBillingOperationStore()
store.startOperation('op-1', 'subscription')
await vi.advanceTimersByTimeAsync(0)
expect(store.operations.size).toBe(1)
store.clearOperation('op-1')
expect(store.operations.size).toBe(0)
expect(store.getOperation('op-1')).toBeUndefined()
})
})
describe('multiple operations', () => {
it('can track multiple operations concurrently', async () => {
vi.mocked(workspaceApi.getBillingOpStatus).mockImplementation(
async (opId: string) => ({
id: opId,
status: 'pending' as const,
started_at: new Date().toISOString()
})
)
const store = useBillingOperationStore()
store.startOperation('op-1', 'subscription')
store.startOperation('op-2', 'topup')
expect(store.operations.size).toBe(2)
expect(store.hasPendingOperations).toBe(true)
vi.mocked(workspaceApi.getBillingOpStatus).mockImplementation(
async (opId: string) => ({
id: opId,
status:
opId === 'op-1' ? ('succeeded' as const) : ('pending' as const),
started_at: new Date().toISOString()
})
)
await vi.advanceTimersByTimeAsync(1500)
expect(store.getOperation('op-1')?.status).toBe('succeeded')
expect(store.getOperation('op-2')?.status).toBe('pending')
expect(store.hasPendingOperations).toBe(true)
})
})
describe('isSettingUp', () => {
it('returns true when there is a pending subscription operation', () => {
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
id: 'op-1',
status: 'pending',
started_at: new Date().toISOString()
})
const store = useBillingOperationStore()
store.startOperation('op-1', 'subscription')
expect(store.isSettingUp).toBe(true)
})
it('returns false when there is no pending subscription operation', async () => {
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
id: 'op-1',
status: 'succeeded',
started_at: new Date().toISOString()
})
const store = useBillingOperationStore()
store.startOperation('op-1', 'subscription')
await vi.advanceTimersByTimeAsync(0)
expect(store.isSettingUp).toBe(false)
})
it('returns false when only topup operations are pending', () => {
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
id: 'op-1',
status: 'pending',
started_at: new Date().toISOString()
})
const store = useBillingOperationStore()
store.startOperation('op-1', 'topup')
expect(store.isSettingUp).toBe(false)
})
})
describe('isAddingCredits', () => {
it('returns true when there is a pending topup operation', () => {
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
id: 'op-1',
status: 'pending',
started_at: new Date().toISOString()
})
const store = useBillingOperationStore()
store.startOperation('op-1', 'topup')
expect(store.isAddingCredits).toBe(true)
})
it('returns false when there is no pending topup operation', async () => {
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
id: 'op-1',
status: 'succeeded',
started_at: new Date().toISOString()
})
const store = useBillingOperationStore()
store.startOperation('op-1', 'topup')
await vi.advanceTimersByTimeAsync(0)
expect(store.isAddingCredits).toBe(false)
})
it('returns false when only subscription operations are pending', () => {
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
id: 'op-1',
status: 'pending',
started_at: new Date().toISOString()
})
const store = useBillingOperationStore()
store.startOperation('op-1', 'subscription')
expect(store.isAddingCredits).toBe(false)
})
})
})

View File

@@ -0,0 +1,244 @@
import type { ToastMessageOptions } from 'primevue/toast'
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { t } from '@/i18n'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
import { useDialogService } from '@/services/dialogService'
import { useDialogStore } from '@/stores/dialogStore'
const INITIAL_INTERVAL_MS = 1000
const MAX_INTERVAL_MS = 8000
const BACKOFF_MULTIPLIER = 1.5
const TIMEOUT_MS = 120_000 // 2 minutes
type OperationType = 'subscription' | 'topup'
type OperationStatus = 'pending' | 'succeeded' | 'failed' | 'timeout'
interface BillingOperation {
opId: string
type: OperationType
status: OperationStatus
errorMessage: string | null
startedAt: number
}
export const useBillingOperationStore = defineStore('billingOperation', () => {
const operations = ref<Map<string, BillingOperation>>(new Map())
const timeouts = new Map<string, ReturnType<typeof setTimeout>>()
const intervals = new Map<string, number>()
const receivedToasts = new Map<string, ToastMessageOptions>()
const hasPendingOperations = computed(() =>
[...operations.value.values()].some((op) => op.status === 'pending')
)
const isSettingUp = computed(() =>
[...operations.value.values()].some(
(op) => op.status === 'pending' && op.type === 'subscription'
)
)
const isAddingCredits = computed(() =>
[...operations.value.values()].some(
(op) => op.status === 'pending' && op.type === 'topup'
)
)
function getOperation(opId: string) {
return operations.value.get(opId)
}
function startOperation(opId: string, type: OperationType) {
if (operations.value.has(opId)) return
const operation: BillingOperation = {
opId,
type,
status: 'pending',
errorMessage: null,
startedAt: Date.now()
}
operations.value = new Map(operations.value).set(opId, operation)
intervals.set(opId, INITIAL_INTERVAL_MS)
// Show immediate feedback toast (persists until operation completes)
const messageKey =
type === 'subscription'
? 'billingOperation.subscriptionProcessing'
: 'billingOperation.topupProcessing'
const toastMessage: ToastMessageOptions = {
severity: 'info',
summary: t(messageKey),
group: 'billing-operation'
}
receivedToasts.set(opId, toastMessage)
useToastStore().add(toastMessage)
void poll(opId)
}
async function poll(opId: string) {
const operation = operations.value.get(opId)
if (!operation || operation.status !== 'pending') return
if (Date.now() - operation.startedAt > TIMEOUT_MS) {
handleTimeout(opId)
return
}
try {
const response = await workspaceApi.getBillingOpStatus(opId)
if (response.status === 'succeeded') {
await handleSuccess(opId)
return
}
if (response.status === 'failed') {
handleFailure(opId, response.error_message ?? null)
return
}
scheduleNextPoll(opId)
} catch {
if (Date.now() - operation.startedAt > TIMEOUT_MS) {
handleTimeout(opId)
return
}
scheduleNextPoll(opId)
}
}
function scheduleNextPoll(opId: string) {
const currentInterval = intervals.get(opId) ?? INITIAL_INTERVAL_MS
const nextInterval = Math.min(
currentInterval * BACKOFF_MULTIPLIER,
MAX_INTERVAL_MS
)
intervals.set(opId, nextInterval)
const timeoutId = setTimeout(() => void poll(opId), nextInterval)
timeouts.set(opId, timeoutId)
}
async function handleSuccess(opId: string) {
const operation = operations.value.get(opId)
if (!operation) return
updateOperationStatus(opId, 'succeeded', null)
cleanup(opId)
const billingContext = useBillingContext()
await Promise.all([
billingContext.fetchStatus(),
billingContext.fetchBalance()
])
// Close any open billing dialogs and show settings
const dialogStore = useDialogStore()
dialogStore.closeDialog({ key: 'subscription-required' })
dialogStore.closeDialog({ key: 'top-up-credits' })
void useDialogService().showSettingsDialog('workspace')
const toastStore = useToastStore()
const messageKey =
operation.type === 'subscription'
? 'billingOperation.subscriptionSuccess'
: 'billingOperation.topupSuccess'
toastStore.add({
severity: 'success',
summary: t(messageKey),
life: 5000
})
}
function handleFailure(opId: string, errorMessage: string | null) {
const operation = operations.value.get(opId)
if (!operation) return
const defaultMessage =
operation.type === 'subscription'
? t('billingOperation.subscriptionFailed')
: t('billingOperation.topupFailed')
updateOperationStatus(opId, 'failed', errorMessage ?? defaultMessage)
cleanup(opId)
useToastStore().add({
severity: 'error',
summary: defaultMessage,
detail: errorMessage ?? undefined,
life: 5000
})
}
function handleTimeout(opId: string) {
const operation = operations.value.get(opId)
if (!operation) return
const message =
operation.type === 'subscription'
? t('billingOperation.subscriptionTimeout')
: t('billingOperation.topupTimeout')
updateOperationStatus(opId, 'timeout', message)
cleanup(opId)
useToastStore().add({
severity: 'error',
summary: message,
life: 5000
})
}
function updateOperationStatus(
opId: string,
status: OperationStatus,
errorMessage: string | null
) {
const operation = operations.value.get(opId)
if (!operation) return
const updated = { ...operation, status, errorMessage }
operations.value = new Map(operations.value).set(opId, updated)
}
function cleanup(opId: string) {
const timeoutId = timeouts.get(opId)
if (timeoutId) {
clearTimeout(timeoutId)
timeouts.delete(opId)
}
intervals.delete(opId)
// Remove the "received" toast
const receivedToast = receivedToasts.get(opId)
if (receivedToast) {
useToastStore().remove(receivedToast)
receivedToasts.delete(opId)
}
}
function clearOperation(opId: string) {
cleanup(opId)
const newMap = new Map(operations.value)
newMap.delete(opId)
operations.value = newMap
}
return {
operations,
hasPendingOperations,
isSettingUp,
isAddingCredits,
getOperation,
startOperation,
clearOperation
}
})

View File

@@ -277,7 +277,7 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
}
const createCustomer = async (): Promise<CreateCustomerResponse> => {
const authHeader = await getAuthHeader()
const authHeader = await getFirebaseAuthHeader()
if (!authHeader) {
throw new FirebaseAuthStoreError(t('toastMessages.userNotAuthenticated'))
}