Skip to content

Commit e136c13

Browse files
comfy-pr-botwei-haiclaude
authored
[backport core/1.48] fix(billing): stop reporting "processing" while a payment waits on the customer (#14911)
Backport of #14910 to `core/1.48` 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 6d6af63 commit e136c13

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
@@ -2636,11 +2636,13 @@
26362636
},
26372637
"billingOperation": {
26382638
"subscriptionProcessing": "Processing payment — setting up your workspace...",
2639+
"subscriptionActionRequired": "Verify your payment to finish setting up your workspace",
26392640
"subscriptionSuccess": "Subscription updated successfully",
26402641
"subscriptionFailed": "Subscription update failed",
26412642
"subscriptionFailedDetail": "We couldn't update your subscription. Please try again.",
26422643
"subscriptionTimeout": "Subscription verification timed out",
26432644
"topupProcessing": "Processing payment — adding credits...",
2645+
"topupActionRequired": "Verify your payment to add your credits",
26442646
"topupSuccess": "Credits added successfully",
26452647
"topupFailed": "Top-up failed",
26462648
"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
@@ -115,6 +115,37 @@ export const useBillingOperationStore = defineStore('billingOperation', () => {
115115
return operations.value.get(opId)
116116
}
117117

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

150181
if (type !== 'cancel') {
151-
const messageKey =
152-
type === 'subscription'
153-
? 'billingOperation.subscriptionProcessing'
154-
: 'billingOperation.topupProcessing'
155-
156-
const toastMessage: ToastMessageOptions = {
157-
severity: 'info',
158-
summary: t(messageKey),
159-
group: 'billing-operation'
160-
}
161-
receivedToasts.set(opId, toastMessage)
162-
useToastStore().add(toastMessage)
182+
showProgressToast(opId, type, operation.actionUrl !== null)
163183
}
164184

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

270299
async function handleSuccess(opId: string) {

0 commit comments

Comments
 (0)