Skip to content
Open
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
37 changes: 37 additions & 0 deletions src/platform/workspace/stores/billingOperationStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -660,6 +660,43 @@ describe('billingOperationStore', () => {

expect(mockToastRemove).toHaveBeenCalledWith(receivedToast)
})

it('resolves the terminal promise even if a success side effect throws', async () => {
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
id: 'op-1',
status: 'succeeded',
started_at: new Date().toISOString()
})
mockToastAdd.mockImplementationOnce(() => {})
mockToastAdd.mockImplementationOnce(() => {
throw new Error('toast rendering failed')
})

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

await vi.advanceTimersByTimeAsync(0)

await expect(terminal).resolves.toMatchObject({ status: 'succeeded' })
})

it('resolves the terminal promise even if reconciliation throws synchronously', async () => {
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
id: 'op-1',
status: 'succeeded',
started_at: new Date().toISOString()
})
mockReconcileSubscriptionSuccess.mockImplementationOnce(() => {
throw new Error('reconcile failed')
})

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

await vi.advanceTimersByTimeAsync(0)

await expect(terminal).resolves.toMatchObject({ status: 'succeeded' })
})
})

describe('polling failure', () => {
Expand Down
183 changes: 94 additions & 89 deletions src/platform/workspace/stores/billingOperationStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -329,116 +329,121 @@ export const useBillingOperationStore = defineStore('billingOperation', () => {
if (!operation) return

updateOperationStatus(opId, 'succeeded', null)
cleanup(opId)

const telemetry = useTelemetry()
const now = Date.now()
const operationDurationMs = now - operation.operationStartedAt
telemetry?.trackBillingEvent({
operation: 'operation',
stage: 'succeeded',
outcome: 'success',
billing_op_id: opId,
operation_type: operation.type,
tier: operation.tier,
cycle: operation.cycle,
checkout_type: operation.checkoutType,
payment_intent_source: operation.paymentIntentSource,
duration_ms: operationDurationMs
})
try {
cleanup(opId)

if (
operation.type === 'subscription' &&
operation.businessAttemptStartedAt !== undefined
) {
const durationMs = now - operation.businessAttemptStartedAt
const telemetry = useTelemetry()
const now = Date.now()
const operationDurationMs = now - operation.operationStartedAt
telemetry?.trackBillingEvent({
operation: 'subscription_checkout',
operation: 'operation',
stage: 'succeeded',
outcome: 'success',
billing_op_id: opId,
operation_type: operation.type,
tier: operation.tier,
cycle: operation.cycle,
checkout_type: operation.checkoutType,
payment_intent_source: operation.paymentIntentSource,
billing_op_id: opId,
duration_ms: durationMs
duration_ms: operationDurationMs
})
// Also fires the legacy event for providers (Mixpanel, GTM) that don't
// implement trackBillingEvent. Gated to actual new/upgraded
// subscriptions — a downgrade-to-personal is churn, not a conversion,
// and this event drives a GA4 "subscription succeeded" conversion goal.
if (!operation.downgradeToPersonal) {
telemetry?.trackMonthlySubscriptionSucceeded({

if (
operation.type === 'subscription' &&
operation.businessAttemptStartedAt !== undefined
) {
const durationMs = now - operation.businessAttemptStartedAt
telemetry?.trackBillingEvent({
operation: 'subscription_checkout',
stage: 'succeeded',
outcome: 'success',
tier: operation.tier,
cycle: operation.cycle,
checkout_type: operation.checkoutType,
payment_intent_source: operation.paymentIntentSource,
billing_op_id: opId
billing_op_id: opId,
duration_ms: durationMs
})
// Also fires the legacy event for providers (Mixpanel, GTM) that don't
// implement trackBillingEvent. Gated to actual new/upgraded
// subscriptions — a downgrade-to-personal is churn, not a conversion,
// and this event drives a GA4 "subscription succeeded" conversion goal.
if (!operation.downgradeToPersonal) {
telemetry?.trackMonthlySubscriptionSucceeded({
tier: operation.tier,
cycle: operation.cycle,
checkout_type: operation.checkoutType,
payment_intent_source: operation.paymentIntentSource,
billing_op_id: opId
})
}
} else if (
operation.type === 'topup' &&
operation.businessAttemptStartedAt !== undefined
) {
telemetry?.trackBillingEvent({
operation: 'topup',
stage: 'succeeded',
outcome: 'success',
billing_op_id: opId,
duration_ms: now - operation.businessAttemptStartedAt
})
}
// Mirrors handleFailure's structure: not gated on businessAttemptStartedAt,
// since a downgrade always has its own startedAt for duration_ms below.
if (operation.downgradeToPersonal) {
telemetry?.trackBillingEvent({
operation: 'downgrade_to_personal',
stage: 'succeeded',
outcome: 'success',
member_removal_count:
operation.downgradeToPersonal.memberRemovalCount,
member_removal_failures:
operation.downgradeToPersonal.memberRemovalFailures,
target_tier: operation.downgradeToPersonal.targetTier,
duration_ms: now - operation.downgradeToPersonal.startedAt
})
}
} else if (
operation.type === 'topup' &&
operation.businessAttemptStartedAt !== undefined
) {
telemetry?.trackBillingEvent({
operation: 'topup',
stage: 'succeeded',
outcome: 'success',
billing_op_id: opId,
duration_ms: now - operation.businessAttemptStartedAt
})
}
// Mirrors handleFailure's structure: not gated on businessAttemptStartedAt,
// since a downgrade always has its own startedAt for duration_ms below.
if (operation.downgradeToPersonal) {
telemetry?.trackBillingEvent({
operation: 'downgrade_to_personal',
stage: 'succeeded',
outcome: 'success',
member_removal_count: operation.downgradeToPersonal.memberRemovalCount,
member_removal_failures:
operation.downgradeToPersonal.memberRemovalFailures,
target_tier: operation.downgradeToPersonal.targetTier,
duration_ms: now - operation.downgradeToPersonal.startedAt
})
}

const billingContext = useBillingContext()
if (operation.type === 'subscription') {
await Promise.allSettled([billingContext.reconcileSubscriptionSuccess()])
} else {
await Promise.allSettled([
billingContext.fetchStatus(),
billingContext.fetchBalance()
])
}

if (operation.type === 'cancel') {
useTeamWorkspaceStore().updateActiveWorkspace({ isSubscribed: false })
resolveTerminal(opId)
return
}
const billingContext = useBillingContext()
if (operation.type === 'subscription') {
await Promise.allSettled([
billingContext.reconcileSubscriptionSuccess()
])
} else {
await Promise.allSettled([
billingContext.fetchStatus(),
billingContext.fetchBalance()
])
}
Comment on lines +409 to +419

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider logging the swallowed side-effect error.

The finally block guarantees resolveTerminal, but the original error still propagates out of handleSuccess. poll awaits handleSuccess inside its own try, so the rejection reaches the catch at line 258. There, currentOperation !== operation is true because updateOperationStatus replaced the map entry, so poll returns and discards the error.

Result: a reconciliation or telemetry failure leaves no diagnostic signal. Add a catch that logs the error before rethrowing or before returning, so failures in the success path stay observable.

♻️ Proposed observability improvement
     } finally {
       resolveTerminal(opId)
     }
+    } catch (error) {
+      console.error(`Billing operation ${opId} success handling failed`, error)
     } finally {
       resolveTerminal(opId)
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const billingContext = useBillingContext()
if (operation.type === 'subscription') {
await Promise.allSettled([billingContext.reconcileSubscriptionSuccess()])
} else {
await Promise.allSettled([
billingContext.fetchStatus(),
billingContext.fetchBalance()
])
}
const billingContext = useBillingContext()
if (operation.type === 'subscription') {
await Promise.allSettled([billingContext.reconcileSubscriptionSuccess()])
} else {
await Promise.allSettled([
billingContext.fetchStatus(),
billingContext.fetchBalance()
])
}
} catch (error) {
console.error(`Billing operation ${opId} success handling failed`, error)
} finally {
resolveTerminal(opId)
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/platform/workspace/stores/billingOperationStore.ts` around lines 408 -
416, Update the success-side effects around
billingContext.reconcileSubscriptionSuccess, fetchStatus, and fetchBalance to
catch and log any rejected error before preserving the existing propagation or
return behavior. Keep the finally-based resolveTerminal flow unchanged, and use
the store’s existing logging mechanism.


// A subscription checkout shows its own success step in the pricing dialog,
// so leave it open. Top-ups have no such step: close and surface settings.
if (operation.type === 'topup') {
useDialogStore().closeDialog({ key: 'top-up-credits' })
useSettingsDialog().show('workspace')
}
if (operation.type === 'cancel') {
useTeamWorkspaceStore().updateActiveWorkspace({ isSubscribed: false })
return
}

const toastStore = useToastStore()
const messageKey =
operation.type === 'subscription'
? 'billingOperation.subscriptionSuccess'
: 'billingOperation.topupSuccess'
// A subscription checkout shows its own success step in the pricing dialog,
// so leave it open. Top-ups have no such step: close and surface settings.
if (operation.type === 'topup') {
useDialogStore().closeDialog({ key: 'top-up-credits' })
useSettingsDialog().show('workspace')
}

toastStore.add({
severity: 'success',
summary: t(messageKey),
life: 5000
})
const toastStore = useToastStore()
const messageKey =
operation.type === 'subscription'
? 'billingOperation.subscriptionSuccess'
: 'billingOperation.topupSuccess'

resolveTerminal(opId)
toastStore.add({
severity: 'success',
summary: t(messageKey),
life: 5000
})
} finally {
resolveTerminal(opId)
}
}

function handleFailure(opId: string, errorMessage: string | null) {
Expand Down
Loading