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
71 changes: 69 additions & 2 deletions src/platform/workspace/stores/billingOperationStore.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { setActivePinia, createPinia } from 'pinia'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi, afterEach } from 'vitest'
import { ref } from 'vue'

Expand Down Expand Up @@ -80,7 +81,7 @@ import { useBillingOperationStore } from './billingOperationStore'

describe('billingOperationStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
setActivePinia(createTestingPinia({ stubActions: false }))
vi.clearAllMocks()
vi.useFakeTimers()
mockActiveWorkspaceId.value = 'workspace-1'
Expand Down Expand Up @@ -438,6 +439,72 @@ describe('billingOperationStore', () => {
})
})

it('stays silent when a checkout was superseded by a new plan choice', async () => {
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
id: 'op-1',
status: 'failed',
error_message: 'checkout_superseded',
started_at: new Date().toISOString()
})

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

await vi.advanceTimersByTimeAsync(0)

expect(store.getOperation('op-1')?.status).toBe('failed')
expect(mockToastAdd).not.toHaveBeenCalledWith(
expect.objectContaining({ severity: 'error' })
)
expect(mockTrackBillingEvent).toHaveBeenCalledWith(
expect.objectContaining({
stage: 'failed',
billing_op_id: 'op-1',
failure_category: 'stale_operation'
})
)
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.

it('categorises both events when a superseded op was a downgrade', async () => {
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
id: 'op-1',
status: 'failed',
error_message: 'checkout_superseded',
started_at: new Date().toISOString()
})

const store = useBillingOperationStore()
void store.startOperation('op-1', 'subscription', {
downgradeToPersonal: {
memberRemovalCount: 2,
memberRemovalFailures: 0,
targetTier: 'free'
}
})

await vi.advanceTimersByTimeAsync(0)

// Both emissions must agree: a downgrade that was merely replaced is not
// an unexplained billing failure in either event stream.
expect(mockTrackBillingEvent).toHaveBeenCalledWith(
expect.objectContaining({
operation: 'operation',
stage: 'failed',
failure_category: 'stale_operation'
})
)
expect(mockTrackBillingEvent).toHaveBeenCalledWith(
expect.objectContaining({
operation: 'downgrade_to_personal',
stage: 'failed',
failure_category: 'stale_operation'
})
)
expect(mockTrackBillingEvent).not.toHaveBeenCalledWith(
expect.objectContaining({ failure_category: 'unknown' })
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
})

it('uses default message when no error_message in response', async () => {
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
id: 'op-1',
Expand Down
12 changes: 9 additions & 3 deletions src/platform/workspace/stores/billingOperationStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ const BACKOFF_MULTIPLIER = 1.5
const TIMEOUT_MS = 120_000
const SUBSCRIPTION_ACTION_DISCOVERY_TIMEOUT_MS = 5 * 60_000
const AUTHENTICATION_TIMEOUT_MS = 23 * 60 * 60_000
// Failure reason for a checkout the user replaced by picking a different plan
// mid-flow. The operation is terminal-failed only because it never completed —
// its replacement is proceeding normally, so there is nothing to report.
const CHECKOUT_SUPERSEDED_REASON = 'checkout_superseded'

type OperationType = 'subscription' | 'topup' | 'cancel'
type OperationStatus = 'pending' | 'succeeded' | 'failed' | 'timeout'
Expand Down Expand Up @@ -356,6 +360,8 @@ export const useBillingOperationStore = defineStore('billingOperation', () => {
const operation = operations.value.get(opId)
if (!operation) return

const superseded = errorMessage === CHECKOUT_SUPERSEDED_REASON
const failureCategory = superseded ? 'stale_operation' : 'unknown'
const defaultMessage = failureMessage(operation.type)
const detail =
operation.type === 'subscription'
Expand All @@ -376,7 +382,7 @@ export const useBillingOperationStore = defineStore('billingOperation', () => {
cycle: operation.cycle,
checkout_type: operation.checkoutType,
payment_intent_source: operation.paymentIntentSource,
failure_category: 'unknown'
failure_category: failureCategory
})
if (operation.downgradeToPersonal) {
telemetry?.trackBillingEvent({
Expand All @@ -387,11 +393,11 @@ export const useBillingOperationStore = defineStore('billingOperation', () => {
member_removal_failures:
operation.downgradeToPersonal.memberRemovalFailures,
target_tier: operation.downgradeToPersonal.targetTier,
failure_category: 'unknown'
failure_category: failureCategory
})
}

if (operation.type !== 'cancel') {
if (operation.type !== 'cancel' && !superseded) {
useToastStore().add({
severity: 'error',
summary: defaultMessage,
Expand Down
Loading