Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
13 changes: 12 additions & 1 deletion src/components/toast/GlobalToast.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,18 @@
<Toast group="billing-operation" position="top-right">
<template #message="slotProps">
<div class="flex items-center gap-2">
<i class="pi pi-spin pi-spinner text-primary" />
<!-- A spinner claims work is underway; an operation waiting on the
customer is not underway, so it gets a prompt icon instead. -->
<i
v-if="slotProps.message.severity === 'warn'"
class="pi pi-exclamation-circle text-warning-background"
aria-hidden="true"
/>
<i
v-else
class="pi pi-spin pi-spinner text-primary"
aria-hidden="true"
/>
<span>{{ slotProps.message.summary }}</span>
</div>
</template>
Expand Down
2 changes: 2 additions & 0 deletions src/locales/en/main.json
Original file line number Diff line number Diff line change
Expand Up @@ -2695,11 +2695,13 @@
},
"billingOperation": {
"subscriptionProcessing": "Processing payment — setting up your workspace...",
"subscriptionActionRequired": "Verify your payment to finish setting up your workspace",
"subscriptionSuccess": "Subscription updated successfully",
"subscriptionFailed": "Subscription update failed",
"subscriptionFailedDetail": "We couldn't update your subscription. Please try again.",
"subscriptionTimeout": "Subscription verification timed out",
"topupProcessing": "Processing payment — adding credits...",
"topupActionRequired": "Verify your payment to add your credits",
"topupSuccess": "Credits added successfully",
"topupFailed": "Top-up failed",
"topupTimeout": "Top-up verification timed out",
Expand Down
118 changes: 118 additions & 0 deletions src/platform/workspace/stores/billingOperationStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -698,6 +698,124 @@ describe('billingOperationStore', () => {
)
})

it('replaces the processing toast when the operation parks on a bank challenge', async () => {
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
id: 'op-1',
status: 'pending',
started_at: new Date().toISOString(),
action_url: 'https://verify.example/sensitive-token'
})

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

const processingToast = {
severity: 'info',
summary: 'billingOperation.subscriptionProcessing',
group: 'billing-operation'
}
expect(mockToastAdd).toHaveBeenCalledWith(processingToast)

await vi.advanceTimersByTimeAsync(0)

expect(mockToastRemove).toHaveBeenCalledWith(processingToast)
expect(mockToastAdd).toHaveBeenCalledWith({
severity: 'warn',
summary: 'billingOperation.subscriptionActionRequired',
group: 'billing-operation'
})
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.

it('announces verification immediately when the action URL is known at start', () => {
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
id: 'op-1',
status: 'pending',
started_at: new Date().toISOString()
})

const store = useBillingOperationStore()
void store.startOperation(
'op-1',
'topup',
undefined,
'https://verify.example/sensitive-token'
)

expect(mockToastAdd).toHaveBeenCalledWith({
severity: 'warn',
summary: 'billingOperation.topupActionRequired',
group: 'billing-operation'
})
expect(mockToastAdd).not.toHaveBeenCalledWith(
expect.objectContaining({
summary: 'billingOperation.topupProcessing'
})
)
})

it('returns to processing when the action URL clears while still pending', async () => {
vi.mocked(workspaceApi.getBillingOpStatus)
.mockResolvedValueOnce({
id: 'op-1',
status: 'pending',
started_at: new Date().toISOString(),
action_url: 'https://verify.example/sensitive-token'
})
.mockResolvedValue({
id: 'op-1',
status: 'pending',
started_at: new Date().toISOString()
})

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

const actionRequiredToast = {
severity: 'warn',
summary: 'billingOperation.subscriptionActionRequired',
group: 'billing-operation'
}
expect(mockToastAdd).toHaveBeenCalledWith(actionRequiredToast)

// The verification action is gone, so the prompt pointing at it must go
// too rather than asking for something the customer can no longer do.
await vi.advanceTimersByTimeAsync(30_000)

expect(mockToastRemove).toHaveBeenCalledWith(actionRequiredToast)
expect(mockToastAdd).toHaveBeenLastCalledWith({
severity: 'info',
summary: 'billingOperation.subscriptionProcessing',
group: 'billing-operation'
})
})

it('does not re-announce verification on later polls', async () => {
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
id: 'op-1',
status: 'pending',
started_at: new Date().toISOString(),
action_url: 'https://verify.example/sensitive-token'
})

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

const actionRequiredAdds = () =>
mockToastAdd.mock.calls.filter(
(call) =>
call[0]?.summary === 'billingOperation.subscriptionActionRequired'
).length

expect(actionRequiredAdds()).toBe(1)

await vi.advanceTimersByTimeAsync(30_000)
await vi.advanceTimersByTimeAsync(30_000)

expect(actionRequiredAdds()).toBe(1)
})

it('rejects an action URL received after the discovery deadline', async () => {
const actionUrl = 'https://verify.example/sensitive-token'
let resolveStatus!: (response: BillingOpStatusResponse) => void
Expand Down
53 changes: 41 additions & 12 deletions src/platform/workspace/stores/billingOperationStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,37 @@ export const useBillingOperationStore = defineStore('billingOperation', () => {
return operations.value.get(opId)
}

// An operation parked on a bank challenge is waiting on the customer, not on
// us, so it must not keep announcing "processing" — that reads as "nothing to
// do here" next to the verification prompt the same state renders.
function showProgressToast(
opId: string,
type: Exclude<OperationType, 'cancel'>,
actionRequired: boolean
) {
const toastStore = useToastStore()
const previous = receivedToasts.get(opId)
if (previous) toastStore.remove(previous)

const messageKey =
type === 'subscription'
? actionRequired
? 'billingOperation.subscriptionActionRequired'
: 'billingOperation.subscriptionProcessing'
: actionRequired
? 'billingOperation.topupActionRequired'
: 'billingOperation.topupProcessing'

const toastMessage: ToastMessageOptions = {
// 'warn' selects the prompt icon over the spinner in GlobalToast.
severity: actionRequired ? 'warn' : 'info',
summary: t(messageKey),
group: 'billing-operation'
}
receivedToasts.set(opId, toastMessage)
toastStore.add(toastMessage)
}

function startOperation(
opId: string,
type: OperationType,
Expand Down Expand Up @@ -151,18 +182,7 @@ export const useBillingOperationStore = defineStore('billingOperation', () => {
intervals.set(opId, INITIAL_INTERVAL_MS)

if (type !== 'cancel') {
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)
showProgressToast(opId, type, operation.actionUrl !== null)
}

const terminal = new Promise<BillingOperation>((resolve) => {
Expand Down Expand Up @@ -268,6 +288,15 @@ export const useBillingOperationStore = defineStore('billingOperation', () => {
authenticationRequiredSeen:
operation.authenticationRequiredSeen || actionUrl !== null
})
// Tracks the CURRENT action_url, which the contract defines as present
// exactly while the operation cannot proceed without the customer — so the
// toast never outlives the verification action it points at. Swapped only
// when that answer changes, or a dismissed toast would return every poll.
const wasActionRequired = operation.actionUrl !== null
const isActionRequired = actionUrl !== null
if (operation.type !== 'cancel' && wasActionRequired !== isActionRequired) {
showProgressToast(opId, operation.type, isActionRequired)
}
}

async function handleSuccess(opId: string) {
Expand Down
Loading