Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
81 changes: 81 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,87 @@ 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('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
59 changes: 45 additions & 14 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.authenticationRequiredSeen)
}

const terminal = new Promise<BillingOperation>((resolve) => {
Expand Down Expand Up @@ -262,12 +282,23 @@ export const useBillingOperationStore = defineStore('billingOperation', () => {
function updateOperationActionUrl(opId: string, actionUrl: string | null) {
const operation = operations.value.get(opId)
if (!operation || operation.status !== 'pending') return
const authenticationRequiredSeen =
operation.authenticationRequiredSeen || actionUrl !== null
operations.value = new Map(operations.value).set(opId, {
...operation,
actionUrl,
authenticationRequiredSeen:
operation.authenticationRequiredSeen || actionUrl !== null
authenticationRequiredSeen
})
// Only on the first transition: the toast is otherwise stable for the life
// of the operation, and re-adding it on every poll would resurface a
// notification the customer has already dismissed.
if (
operation.type !== 'cancel' &&
authenticationRequiredSeen &&
!operation.authenticationRequiredSeen
) {
showProgressToast(opId, operation.type, true)
Comment thread
wei-hai marked this conversation as resolved.
Outdated
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}

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