Skip to content

Commit 29da0a1

Browse files
comfy-pr-botwei-haiclaude
authored
[backport cloud/1.49] fix(billing): stop reporting "processing" while a payment waits on the customer (#14913)
Backport of #14910 to `cloud/1.49` Automatically created by backport workflow. Co-authored-by: Wei Hai <hai.vincent@gmail.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 60c9b4a commit 29da0a1

4 files changed

Lines changed: 173 additions & 13 deletions

File tree

src/components/toast/GlobalToast.vue

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,18 @@
33
<Toast group="billing-operation" position="top-right">
44
<template #message="slotProps">
55
<div class="flex items-center gap-2">
6-
<i class="pi pi-spin pi-spinner text-primary" />
6+
<!-- A spinner claims work is underway; an operation waiting on the
7+
customer is not underway, so it gets a prompt icon instead. -->
8+
<i
9+
v-if="slotProps.message.severity === 'warn'"
10+
class="pi pi-exclamation-circle text-warning-background"
11+
aria-hidden="true"
12+
/>
13+
<i
14+
v-else
15+
class="pi pi-spin pi-spinner text-primary"
16+
aria-hidden="true"
17+
/>
718
<span>{{ slotProps.message.summary }}</span>
819
</div>
920
</template>

src/locales/en/main.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2690,11 +2690,13 @@
26902690
},
26912691
"billingOperation": {
26922692
"subscriptionProcessing": "Processing payment — setting up your workspace...",
2693+
"subscriptionActionRequired": "Verify your payment to finish setting up your workspace",
26932694
"subscriptionSuccess": "Subscription updated successfully",
26942695
"subscriptionFailed": "Subscription update failed",
26952696
"subscriptionFailedDetail": "We couldn't update your subscription. Please try again.",
26962697
"subscriptionTimeout": "Subscription verification timed out",
26972698
"topupProcessing": "Processing payment — adding credits...",
2699+
"topupActionRequired": "Verify your payment to add your credits",
26982700
"topupSuccess": "Credits added successfully",
26992701
"topupFailed": "Top-up failed",
27002702
"topupTimeout": "Top-up verification timed out",

src/platform/workspace/stores/billingOperationStore.test.ts

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -662,6 +662,124 @@ describe('billingOperationStore', () => {
662662
)
663663
})
664664

665+
it('replaces the processing toast when the operation parks on a bank challenge', async () => {
666+
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
667+
id: 'op-1',
668+
status: 'pending',
669+
started_at: new Date().toISOString(),
670+
action_url: 'https://verify.example/sensitive-token'
671+
})
672+
673+
const store = useBillingOperationStore()
674+
void store.startOperation('op-1', 'subscription')
675+
676+
const processingToast = {
677+
severity: 'info',
678+
summary: 'billingOperation.subscriptionProcessing',
679+
group: 'billing-operation'
680+
}
681+
expect(mockToastAdd).toHaveBeenCalledWith(processingToast)
682+
683+
await vi.advanceTimersByTimeAsync(0)
684+
685+
expect(mockToastRemove).toHaveBeenCalledWith(processingToast)
686+
expect(mockToastAdd).toHaveBeenCalledWith({
687+
severity: 'warn',
688+
summary: 'billingOperation.subscriptionActionRequired',
689+
group: 'billing-operation'
690+
})
691+
})
692+
693+
it('announces verification immediately when the action URL is known at start', () => {
694+
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
695+
id: 'op-1',
696+
status: 'pending',
697+
started_at: new Date().toISOString()
698+
})
699+
700+
const store = useBillingOperationStore()
701+
void store.startOperation(
702+
'op-1',
703+
'topup',
704+
undefined,
705+
'https://verify.example/sensitive-token'
706+
)
707+
708+
expect(mockToastAdd).toHaveBeenCalledWith({
709+
severity: 'warn',
710+
summary: 'billingOperation.topupActionRequired',
711+
group: 'billing-operation'
712+
})
713+
expect(mockToastAdd).not.toHaveBeenCalledWith(
714+
expect.objectContaining({
715+
summary: 'billingOperation.topupProcessing'
716+
})
717+
)
718+
})
719+
720+
it('returns to processing when the action URL clears while still pending', async () => {
721+
vi.mocked(workspaceApi.getBillingOpStatus)
722+
.mockResolvedValueOnce({
723+
id: 'op-1',
724+
status: 'pending',
725+
started_at: new Date().toISOString(),
726+
action_url: 'https://verify.example/sensitive-token'
727+
})
728+
.mockResolvedValue({
729+
id: 'op-1',
730+
status: 'pending',
731+
started_at: new Date().toISOString()
732+
})
733+
734+
const store = useBillingOperationStore()
735+
void store.startOperation('op-1', 'subscription')
736+
await vi.advanceTimersByTimeAsync(0)
737+
738+
const actionRequiredToast = {
739+
severity: 'warn',
740+
summary: 'billingOperation.subscriptionActionRequired',
741+
group: 'billing-operation'
742+
}
743+
expect(mockToastAdd).toHaveBeenCalledWith(actionRequiredToast)
744+
745+
// The verification action is gone, so the prompt pointing at it must go
746+
// too rather than asking for something the customer can no longer do.
747+
await vi.advanceTimersByTimeAsync(30_000)
748+
749+
expect(mockToastRemove).toHaveBeenCalledWith(actionRequiredToast)
750+
expect(mockToastAdd).toHaveBeenLastCalledWith({
751+
severity: 'info',
752+
summary: 'billingOperation.subscriptionProcessing',
753+
group: 'billing-operation'
754+
})
755+
})
756+
757+
it('does not re-announce verification on later polls', async () => {
758+
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
759+
id: 'op-1',
760+
status: 'pending',
761+
started_at: new Date().toISOString(),
762+
action_url: 'https://verify.example/sensitive-token'
763+
})
764+
765+
const store = useBillingOperationStore()
766+
void store.startOperation('op-1', 'subscription')
767+
await vi.advanceTimersByTimeAsync(0)
768+
769+
const actionRequiredAdds = () =>
770+
mockToastAdd.mock.calls.filter(
771+
(call) =>
772+
call[0]?.summary === 'billingOperation.subscriptionActionRequired'
773+
).length
774+
775+
expect(actionRequiredAdds()).toBe(1)
776+
777+
await vi.advanceTimersByTimeAsync(30_000)
778+
await vi.advanceTimersByTimeAsync(30_000)
779+
780+
expect(actionRequiredAdds()).toBe(1)
781+
})
782+
665783
it('rejects an action URL received after the discovery deadline', async () => {
666784
const actionUrl = 'https://verify.example/sensitive-token'
667785
let resolveStatus!: (response: BillingOpStatusResponse) => void

src/platform/workspace/stores/billingOperationStore.ts

Lines changed: 41 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,37 @@ export const useBillingOperationStore = defineStore('billingOperation', () => {
118118
return operations.value.get(opId)
119119
}
120120

121+
// An operation parked on a bank challenge is waiting on the customer, not on
122+
// us, so it must not keep announcing "processing" — that reads as "nothing to
123+
// do here" next to the verification prompt the same state renders.
124+
function showProgressToast(
125+
opId: string,
126+
type: Exclude<OperationType, 'cancel'>,
127+
actionRequired: boolean
128+
) {
129+
const toastStore = useToastStore()
130+
const previous = receivedToasts.get(opId)
131+
if (previous) toastStore.remove(previous)
132+
133+
const messageKey =
134+
type === 'subscription'
135+
? actionRequired
136+
? 'billingOperation.subscriptionActionRequired'
137+
: 'billingOperation.subscriptionProcessing'
138+
: actionRequired
139+
? 'billingOperation.topupActionRequired'
140+
: 'billingOperation.topupProcessing'
141+
142+
const toastMessage: ToastMessageOptions = {
143+
// 'warn' selects the prompt icon over the spinner in GlobalToast.
144+
severity: actionRequired ? 'warn' : 'info',
145+
summary: t(messageKey),
146+
group: 'billing-operation'
147+
}
148+
receivedToasts.set(opId, toastMessage)
149+
toastStore.add(toastMessage)
150+
}
151+
121152
function startOperation(
122153
opId: string,
123154
type: OperationType,
@@ -151,18 +182,7 @@ export const useBillingOperationStore = defineStore('billingOperation', () => {
151182
intervals.set(opId, INITIAL_INTERVAL_MS)
152183

153184
if (type !== 'cancel') {
154-
const messageKey =
155-
type === 'subscription'
156-
? 'billingOperation.subscriptionProcessing'
157-
: 'billingOperation.topupProcessing'
158-
159-
const toastMessage: ToastMessageOptions = {
160-
severity: 'info',
161-
summary: t(messageKey),
162-
group: 'billing-operation'
163-
}
164-
receivedToasts.set(opId, toastMessage)
165-
useToastStore().add(toastMessage)
185+
showProgressToast(opId, type, operation.actionUrl !== null)
166186
}
167187

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

273302
async function handleSuccess(opId: string) {

0 commit comments

Comments
 (0)