Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ const mockTrackTopUpPurchase = vi.fn()
const mockTrackBillingEvent = vi.fn()
const mockCanTopUp = vi.hoisted(() => ({ value: true }))
const mockShouldUseWorkspaceBilling = vi.hoisted(() => ({ value: true }))
const mockTopupActionOperation = vi.hoisted(() => ({
value: undefined as { actionUrl: string } | undefined
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}))

vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({
Expand All @@ -31,13 +34,17 @@ vi.mock('@/platform/workspace/stores/billingOperationStore', () => ({
useBillingOperationStore: () => ({
hasPendingOperations: true,
isAddingCredits: false,
get topupActionOperation() {
return mockTopupActionOperation.value
},
startOperation: mockStartOperation
})
}))

vi.mock('@/platform/workspace/composables/useWorkspaceUI', () => ({
useWorkspaceUI: () => ({
permissions: {
__v_isRef: true,
get value() {
return { canTopUp: mockCanTopUp.value }
}
Expand Down Expand Up @@ -96,7 +103,10 @@ const i18n = createI18n({
messages: {
en: {
g: { close: 'Close' },
subscription: { addCredits: 'Add credits' },
subscription: {
addCredits: 'Add credits',
preview: { completeVerification: 'Complete verification' }
},
credits: {
topUp: {
addMoreCredits: 'Add more credits',
Expand Down Expand Up @@ -156,6 +166,7 @@ describe('TopUpCreditsDialogContentWorkspace', () => {
vi.clearAllMocks()
mockCanTopUp.value = true
mockShouldUseWorkspaceBilling.value = true
mockTopupActionOperation.value = undefined
mockFetchBalance.mockResolvedValue(undefined)
mockFetchStatus.mockResolvedValue(undefined)
})
Expand All @@ -166,6 +177,38 @@ describe('TopUpCreditsDialogContentWorkspace', () => {
expect(screen.getByRole('button', { name: 'Add credits' })).toBeEnabled()
})

it('opens topup verification without exposing its URL', async () => {
const actionUrl = 'https://verify.example/sensitive-token'
const open = vi.spyOn(window, 'open').mockReturnValue({} as Window)
mockTopupActionOperation.value = { actionUrl }

const { container } = renderDialog()

expect(container.innerHTML).not.toContain(actionUrl)
await userEvent.click(
screen.getByRole('button', { name: 'Complete verification' })
)
expect(open).toHaveBeenCalledWith(
actionUrl,
'_blank',
'noopener,noreferrer'
)
open.mockRestore()
})

it('hides topup verification after permission is revoked', () => {
mockCanTopUp.value = false
mockTopupActionOperation.value = {
actionUrl: 'https://verify.example/sensitive-token'
}

renderDialog()

expect(
screen.queryByRole('button', { name: 'Complete verification' })
).not.toBeInTheDocument()
})

it('refreshes both balance and status after a completed top-up', async () => {
mockTopup.mockResolvedValue(topupResponse('completed'))

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -125,10 +125,19 @@
</p>

<div class="flex flex-col gap-8 p-8">
<Button
Comment thread
dante01yoon marked this conversation as resolved.
Outdated
v-if="topupActionUrl && permissions.canTopUp"
variant="primary"
size="lg"
class="h-10 justify-center"
@click="openTopupVerification"
>
{{ $t('subscription.preview.completeVerification') }}
</Button>
<Button
:disabled="!isValidAmount || loading || isPolling"
:loading="loading || isPolling"
variant="primary"
:variant="topupActionUrl ? 'tertiary' : 'primary'"
size="lg"
class="h-10 justify-center"
@click="handleBuy"
Expand Down Expand Up @@ -184,6 +193,9 @@ const { permissions } = useWorkspaceUI()

const billingOperationStore = useBillingOperationStore()
const isPolling = computed(() => billingOperationStore.isAddingCredits)
const topupActionUrl = computed(
() => billingOperationStore.topupActionOperation?.actionUrl ?? null
)

// Constants
const PRESET_AMOUNTS = [10, 25, 50, 100]
Expand Down Expand Up @@ -245,6 +257,11 @@ function handlePresetClick(amount: number) {
selectedPreset.value = amount
}

function openTopupVerification() {
if (!topupActionUrl.value) return
window.open(topupActionUrl.value, '_blank', 'noopener,noreferrer')
}

function handleClose(clearTracking = true) {
if (clearTracking) {
clearTopupTracking()
Expand Down
45 changes: 45 additions & 0 deletions src/platform/workspace/stores/billingOperationStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -662,6 +662,26 @@ describe('billingOperationStore', () => {
expect(store.getOperation('op-1')?.actionUrl).toBeNull()
})

it('exposes topup actions only for the active workspace', async () => {
const actionUrl = 'https://verify.example/sensitive-token'
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
id: 'op-1',
status: 'pending',
started_at: new Date().toISOString(),
action_url: actionUrl
})

const store = useBillingOperationStore()
void store.startOperation('op-1', 'topup')
await vi.advanceTimersByTimeAsync(0)

expect(store.topupActionOperation?.actionUrl).toBe(actionUrl)

mockActiveWorkspaceId.value = 'workspace-2'

expect(store.topupActionOperation).toBeUndefined()
})

it('only exposes subscription actions for the active workspace', async () => {
const actionUrl = 'https://verify.example/sensitive-token'
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
Expand Down Expand Up @@ -737,6 +757,31 @@ describe('billingOperationStore', () => {
summary: 'billingOperation.topupTimeout'
})
})

it('keeps polling a topup while authentication is required', async () => {
const actionUrl = 'https://verify.example/sensitive-token'
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
id: 'op-1',
status: 'pending',
started_at: new Date().toISOString(),
action_url: actionUrl
})

const store = useBillingOperationStore()
void store.startOperation('op-1', 'topup')

await vi.advanceTimersByTimeAsync(121_000)

expect(store.getOperation('op-1')).toMatchObject({
status: 'pending',
actionUrl,
authenticationRequiredSeen: true
})
expect(mockToastAdd).not.toHaveBeenCalledWith({
severity: 'error',
summary: 'billingOperation.topupTimeout'
})
})
})

describe('cancel operations', () => {
Expand Down
23 changes: 18 additions & 5 deletions src/platform/workspace/stores/billingOperationStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ const ACTION_REQUIRED_INTERVAL_MS = 30_000
const BACKOFF_MULTIPLIER = 1.5
const TIMEOUT_MS = 120_000
const SUBSCRIPTION_ACTION_DISCOVERY_TIMEOUT_MS = 5 * 60_000
const SUBSCRIPTION_AUTHENTICATION_TIMEOUT_MS = 23 * 60 * 60_000
const AUTHENTICATION_TIMEOUT_MS = 23 * 60 * 60_000

type OperationType = 'subscription' | 'topup' | 'cancel'
type OperationStatus = 'pending' | 'succeeded' | 'failed' | 'timeout'
Expand Down Expand Up @@ -97,6 +97,16 @@ export const useBillingOperationStore = defineStore('billingOperation', () => {
)
)

const topupActionOperation = computed(() =>
[...operations.value.values()].find(
(op) =>
op.status === 'pending' &&
op.type === 'topup' &&
op.workspaceId === workspaceStore.activeWorkspaceId &&
op.actionUrl !== null
)
)

function getOperation(opId: string) {
return operations.value.get(opId)
}
Expand Down Expand Up @@ -228,10 +238,12 @@ export const useBillingOperationStore = defineStore('billingOperation', () => {

function hasTimedOut(operation: BillingOperation): boolean {
const elapsed = Date.now() - operation.startedAt
if (operation.type !== 'subscription') return elapsed > TIMEOUT_MS
return operation.authenticationRequiredSeen
? elapsed > SUBSCRIPTION_AUTHENTICATION_TIMEOUT_MS
: elapsed > SUBSCRIPTION_ACTION_DISCOVERY_TIMEOUT_MS
if (operation.type !== 'cancel' && operation.authenticationRequiredSeen) {
return elapsed > AUTHENTICATION_TIMEOUT_MS
}
return operation.type === 'subscription'
? elapsed > SUBSCRIPTION_ACTION_DISCOVERY_TIMEOUT_MS
: elapsed > TIMEOUT_MS
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

function stopIfTimedOut(opId: string, operation: BillingOperation): boolean {
Expand Down Expand Up @@ -498,6 +510,7 @@ export const useBillingOperationStore = defineStore('billingOperation', () => {
isSettingUp,
isAddingCredits,
subscriptionActionOperation,
topupActionOperation,
getOperation,
startOperation,
clearOperation
Expand Down
Loading