mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-27 17:52:16 +00:00
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:
504
src/stores/billingOperationStore.test.ts
Normal file
504
src/stores/billingOperationStore.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
244
src/stores/billingOperationStore.ts
Normal file
244
src/stores/billingOperationStore.ts
Normal 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
|
||||
}
|
||||
})
|
||||
@@ -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'))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user