feat: make subscription success backend-driven

This commit is contained in:
Benjamin Lu
2026-04-15 15:36:26 -07:00
parent a8e1fa8bef
commit ee1d8233a4
9 changed files with 487 additions and 82 deletions

View File

@@ -155,6 +155,7 @@ import SubscribeButton from '@/platform/cloud/subscription/components/SubscribeB
import SubscriptionBenefits from '@/platform/cloud/subscription/components/SubscriptionBenefits.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { isCloud } from '@/platform/distribution/types'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { useTelemetry } from '@/platform/telemetry'
import { useCommandStore } from '@/stores/commandStore'
import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
@@ -169,7 +170,8 @@ const emit = defineEmits<{
close: [subscribed: boolean]
}>()
const { fetchStatus, isActiveSubscription } = useBillingContext()
const { isActiveSubscription } = useBillingContext()
const { syncStatusAfterCheckout } = useSubscription()
const isSubscriptionEnabled = (): boolean =>
Boolean(isCloud && window.__CONFIG__?.subscription_required)
@@ -190,48 +192,20 @@ const telemetry = useTelemetry()
// Always show custom pricing table for cloud subscriptions
const showCustomPricingTable = computed(() => isSubscriptionEnabled())
const POLL_INTERVAL_MS = 3000
const MAX_POLL_ATTEMPTS = 3
let pollInterval: number | null = null
let pollAttempts = 0
const stopPolling = () => {
if (pollInterval) {
clearInterval(pollInterval)
pollInterval = null
const refreshSubscriptionStatus = async () => {
try {
await syncStatusAfterCheckout()
} catch (error) {
console.error(
'[SubscriptionDialog] Failed to refresh subscription status',
error
)
}
}
const startPolling = () => {
stopPolling()
pollAttempts = 0
const poll = async () => {
try {
await fetchStatus()
pollAttempts++
if (pollAttempts >= MAX_POLL_ATTEMPTS) {
stopPolling()
}
} catch (error) {
console.error(
'[SubscriptionDialog] Failed to poll subscription status',
error
)
stopPolling()
}
}
void poll()
pollInterval = window.setInterval(() => {
void poll()
}, POLL_INTERVAL_MS)
}
const handleWindowFocus = () => {
if (showCustomPricingTable.value) {
startPolling()
void refreshSubscriptionStatus()
}
}
@@ -242,7 +216,6 @@ watch(
window.addEventListener('focus', handleWindowFocus)
} else {
window.removeEventListener('focus', handleWindowFocus)
stopPolling()
}
},
{ immediate: true }
@@ -252,7 +225,6 @@ watch(
() => isActiveSubscription.value,
(isActive) => {
if (isActive && showCustomPricingTable.value) {
telemetry?.trackMonthlySubscriptionSucceeded()
emit('close', true)
}
}
@@ -263,7 +235,6 @@ const handleSubscribed = () => {
}
const handleChooseTeam = () => {
stopPolling()
if (onChooseTeam) {
onChooseTeam()
} else {
@@ -272,7 +243,6 @@ const handleChooseTeam = () => {
}
const handleClose = () => {
stopPolling()
onClose()
}
@@ -295,7 +265,6 @@ const handleViewEnterprise = () => {
}
onBeforeUnmount(() => {
stopPolling()
window.removeEventListener('focus', handleWindowFocus)
})
</script>

View File

@@ -28,6 +28,7 @@ const {
})),
mockTelemetry: {
trackSubscription: vi.fn(),
trackMonthlySubscriptionSucceeded: vi.fn(),
trackMonthlySubscriptionCancelled: vi.fn()
},
mockUserId: { value: 'user-123' }
@@ -134,20 +135,40 @@ describe('useSubscription', () => {
vi.clearAllMocks()
mockIsLoggedIn.value = false
mockTelemetry.trackSubscription.mockReset()
mockTelemetry.trackMonthlySubscriptionSucceeded.mockReset()
mockTelemetry.trackMonthlySubscriptionCancelled.mockReset()
mockUserId.value = 'user-123'
mockIsCloud.value = true
window.__CONFIG__ = {
subscription_required: true
} as typeof window.__CONFIG__
vi.mocked(global.fetch).mockResolvedValue({
ok: true,
json: async () => ({
is_active: false,
subscription_id: '',
renewal_date: ''
})
} as Response)
localStorage.clear()
vi.mocked(global.fetch).mockImplementation(async (input) => {
const url = String(input)
if (url.includes('/customers/pending-subscription-success/')) {
return {
ok: true,
status: 204
} as Response
}
if (url.includes('/customers/pending-subscription-success')) {
return {
ok: true,
status: 204
} as Response
}
return {
ok: true,
json: async () => ({
is_active: false,
subscription_id: '',
renewal_date: ''
})
} as Response
})
})
describe('computed properties', () => {
@@ -275,6 +296,146 @@ describe('useSubscription', () => {
await expect(fetchStatus()).rejects.toThrow()
})
it('syncs and consumes pending subscription success when requested', async () => {
vi.mocked(global.fetch).mockImplementation(async (input) => {
const url = String(input)
if (url.includes('/customers/cloud-subscription-status')) {
return {
ok: true,
json: async () => ({
is_active: true,
subscription_id: 'sub_123',
renewal_date: '2025-11-16'
})
} as Response
}
if (url.endsWith('/customers/pending-subscription-success')) {
return {
ok: true,
status: 200,
json: async () => ({
id: 'event-123',
transaction_id: 'stripe-event-123',
value: 35,
currency: 'USD',
tier: 'creator',
cycle: 'monthly',
checkout_type: 'change',
previous_tier: 'standard'
})
} as Response
}
if (
url.endsWith(
'/customers/pending-subscription-success/event-123/consume'
)
) {
return {
ok: true,
status: 204
} as Response
}
throw new Error(`Unexpected fetch URL: ${url}`)
})
const { syncStatusAfterCheckout } = useSubscriptionWithScope()
await syncStatusAfterCheckout()
expect(
mockTelemetry.trackMonthlySubscriptionSucceeded
).toHaveBeenCalledWith(
expect.objectContaining({
transaction_id: 'stripe-event-123',
value: 35,
currency: 'USD',
tier: 'creator',
cycle: 'monthly',
checkout_type: 'change',
previous_tier: 'standard'
})
)
expect(global.fetch).toHaveBeenCalledWith(
expect.stringContaining(
'/customers/pending-subscription-success/event-123/consume'
),
expect.objectContaining({
method: 'POST'
})
)
})
it('does not retrack a subscription success already delivered in this browser', async () => {
localStorage.setItem(
'comfy.subscription_success.delivered_transactions',
JSON.stringify(['stripe-event-123'])
)
vi.mocked(global.fetch).mockImplementation(async (input) => {
const url = String(input)
if (url.includes('/customers/cloud-subscription-status')) {
return {
ok: true,
json: async () => ({
is_active: true,
subscription_id: 'sub_123',
renewal_date: '2025-11-16'
})
} as Response
}
if (url.endsWith('/customers/pending-subscription-success')) {
return {
ok: true,
status: 200,
json: async () => ({
id: 'event-123',
transaction_id: 'stripe-event-123',
value: 20,
currency: 'USD',
tier: 'standard',
cycle: 'monthly',
checkout_type: 'new'
})
} as Response
}
if (
url.endsWith(
'/customers/pending-subscription-success/event-123/consume'
)
) {
return {
ok: true,
status: 204
} as Response
}
throw new Error(`Unexpected fetch URL: ${url}`)
})
const { syncStatusAfterCheckout } = useSubscriptionWithScope()
await syncStatusAfterCheckout()
expect(
mockTelemetry.trackMonthlySubscriptionSucceeded
).not.toHaveBeenCalled()
expect(global.fetch).toHaveBeenCalledWith(
expect.stringContaining(
'/customers/pending-subscription-success/event-123/consume'
),
expect.objectContaining({
method: 'POST'
})
)
})
})
describe('subscribe', () => {

View File

@@ -1,4 +1,4 @@
import { computed, ref, watch } from 'vue'
import { computed, onScopeDispose, ref, watch } from 'vue'
import { createSharedComposable } from '@vueuse/core'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
@@ -13,6 +13,7 @@ import type { CheckoutAttributionMetadata } from '@/platform/telemetry/types'
import { AuthStoreError, useAuthStore } from '@/stores/authStore'
import { useDialogService } from '@/services/dialogService'
import { TIER_TO_KEY } from '@/platform/cloud/subscription/constants/tierPricing'
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
import type { operations } from '@/types/comfyRegistryTypes'
import { useSubscriptionCancellationWatcher } from './useSubscriptionCancellationWatcher'
@@ -24,6 +25,82 @@ export type CloudSubscriptionStatusResponse = NonNullable<
operations['GetCloudSubscriptionStatus']['responses']['200']['content']['application/json']
>
type TrackedSubscriptionTierKey = Exclude<TierKey, 'free'>
type PendingSubscriptionSuccessResponse = {
id: string
transaction_id: string
value: number
currency: string
tier: TrackedSubscriptionTierKey
cycle: 'monthly' | 'yearly'
checkout_type: 'new' | 'change'
previous_tier?: TrackedSubscriptionTierKey | null
}
type FetchSubscriptionStatusOptions = {
syncPendingSuccess?: boolean
}
const SUBSCRIPTION_SUCCESS_DELIVERED_STORAGE_KEY =
'comfy.subscription_success.delivered_transactions'
function readDeliveredSubscriptionSuccessTransactions(): string[] {
if (typeof window === 'undefined') {
return []
}
try {
const rawValue = window.localStorage.getItem(
SUBSCRIPTION_SUCCESS_DELIVERED_STORAGE_KEY
)
if (!rawValue) {
return []
}
const parsedValue = JSON.parse(rawValue)
if (!Array.isArray(parsedValue)) {
return []
}
return parsedValue.filter(
(transactionId): transactionId is string =>
typeof transactionId === 'string' && transactionId.length > 0
)
} catch {
return []
}
}
function hasDeliveredSubscriptionSuccess(transactionId: string): boolean {
return readDeliveredSubscriptionSuccessTransactions().includes(transactionId)
}
function markSubscriptionSuccessAsDelivered(transactionId: string): void {
if (typeof window === 'undefined') {
return
}
const nextTransactions = [
transactionId,
...readDeliveredSubscriptionSuccessTransactions().filter(
(existingTransactionId) => existingTransactionId !== transactionId
)
].slice(0, 20)
try {
window.localStorage.setItem(
SUBSCRIPTION_SUCCESS_DELIVERED_STORAGE_KEY,
JSON.stringify(nextTransactions)
)
} catch (error) {
console.warn(
'[Subscription] Failed to persist delivered subscription success transaction',
error
)
}
}
function useSubscriptionInternal() {
const subscriptionStatus = ref<CloudSubscriptionStatusResponse | null>(null)
const telemetry = useTelemetry()
@@ -111,8 +188,111 @@ function useSubscriptionInternal() {
return getCheckoutAttribution()
}
const buildAuthHeaders = async (): Promise<Record<string, string>> => {
const authHeader = await getAuthHeader()
if (!authHeader) {
throw new AuthStoreError(t('toastMessages.userNotAuthenticated'))
}
return {
...authHeader,
'Content-Type': 'application/json'
}
}
const fetchPendingSubscriptionSuccess = async (
headers: Record<string, string>
): Promise<PendingSubscriptionSuccessResponse | null> => {
const response = await fetch(
buildApiUrl('/customers/pending-subscription-success'),
{
headers
}
)
if (response.status === 204) {
return null
}
if (!response.ok) {
throw new Error(
`Failed to fetch pending subscription success: ${response.status}`
)
}
return response.json()
}
const consumePendingSubscriptionSuccess = async (
headers: Record<string, string>,
id: string
): Promise<void> => {
const response = await fetch(
buildApiUrl(`/customers/pending-subscription-success/${id}/consume`),
{
method: 'POST',
headers
}
)
if (!response.ok && response.status !== 404) {
throw new Error(
`Failed to consume pending subscription success: ${response.status}`
)
}
}
const syncPendingSubscriptionSuccess = async (
headers: Record<string, string>
): Promise<void> => {
const pendingSuccess = await fetchPendingSubscriptionSuccess(headers)
if (!pendingSuccess) {
return
}
if (hasDeliveredSubscriptionSuccess(pendingSuccess.transaction_id)) {
await consumePendingSubscriptionSuccess(headers, pendingSuccess.id)
return
}
telemetry?.trackMonthlySubscriptionSucceeded({
...(authStore.userId ? { user_id: authStore.userId } : {}),
transaction_id: pendingSuccess.transaction_id,
value: pendingSuccess.value,
currency: pendingSuccess.currency,
tier: pendingSuccess.tier,
cycle: pendingSuccess.cycle,
checkout_type: pendingSuccess.checkout_type,
...(pendingSuccess.previous_tier
? { previous_tier: pendingSuccess.previous_tier }
: {}),
ecommerce: {
transaction_id: pendingSuccess.transaction_id,
value: pendingSuccess.value,
currency: pendingSuccess.currency,
items: [
{
item_name: pendingSuccess.tier,
item_category: 'subscription',
item_variant: pendingSuccess.cycle,
price: pendingSuccess.value,
quantity: 1
}
]
}
})
markSubscriptionSuccessAsDelivered(pendingSuccess.transaction_id)
await consumePendingSubscriptionSuccess(headers, pendingSuccess.id)
}
const fetchStatus = wrapWithErrorHandlingAsync(
fetchSubscriptionStatus,
() => fetchSubscriptionStatus(),
reportError
)
const syncStatusAfterCheckout = wrapWithErrorHandlingAsync(
() => fetchSubscriptionStatus({ syncPendingSuccess: true }),
reportError
)
@@ -188,19 +368,15 @@ function useSubscriptionInternal() {
* Fetch the current cloud subscription status for the authenticated user
* @returns Subscription status or null if no subscription exists
*/
async function fetchSubscriptionStatus(): Promise<CloudSubscriptionStatusResponse | null> {
const authHeader = await getAuthHeader()
if (!authHeader) {
throw new AuthStoreError(t('toastMessages.userNotAuthenticated'))
}
async function fetchSubscriptionStatus(
options?: FetchSubscriptionStatusOptions
): Promise<CloudSubscriptionStatusResponse | null> {
const headers = await buildAuthHeaders()
const response = await fetch(
buildApiUrl('/customers/cloud-subscription-status'),
{
headers: {
...authHeader,
'Content-Type': 'application/json'
}
headers
}
)
@@ -216,15 +392,47 @@ function useSubscriptionInternal() {
const statusData = await response.json()
subscriptionStatus.value = statusData
if (options?.syncPendingSuccess && statusData.is_active) {
await syncPendingSubscriptionSuccess(headers)
}
return statusData
}
const handleDeliveredSubscriptionSuccessChange = (event: StorageEvent) => {
if (
event.key !== SUBSCRIPTION_SUCCESS_DELIVERED_STORAGE_KEY ||
!isCloud ||
!isLoggedIn.value
) {
return
}
void fetchSubscriptionStatus().catch((error) => {
console.error(
'[Subscription] Failed to refresh subscription status after cross-tab success delivery:',
error
)
})
}
if (typeof window !== 'undefined') {
window.addEventListener('storage', handleDeliveredSubscriptionSuccessChange)
onScopeDispose(() => {
window.removeEventListener(
'storage',
handleDeliveredSubscriptionSuccessChange
)
})
}
watch(
() => isLoggedIn.value,
async (loggedIn) => {
if (loggedIn && isCloud) {
try {
await fetchSubscriptionStatus()
await fetchSubscriptionStatus({ syncPendingSuccess: true })
} catch (error) {
// Network errors are expected during navigation/component unmount
// and when offline - log for debugging but don't surface to user
@@ -243,20 +451,14 @@ function useSubscriptionInternal() {
const initiateSubscriptionCheckout =
async (): Promise<CloudSubscriptionCheckoutResponse> => {
const authHeader = await getAuthHeader()
if (!authHeader) {
throw new AuthStoreError(t('toastMessages.userNotAuthenticated'))
}
const headers = await buildAuthHeaders()
const checkoutAttribution = await getCheckoutAttributionForCloud()
const response = await fetch(
buildApiUrl('/customers/cloud-subscription-checkout'),
{
method: 'POST',
headers: {
...authHeader,
'Content-Type': 'application/json'
},
headers,
body: JSON.stringify(checkoutAttribution)
}
)
@@ -293,6 +495,7 @@ function useSubscriptionInternal() {
// Actions
subscribe,
fetchStatus,
syncStatusAfterCheckout,
showSubscriptionDialog,
manageSubscription,
requireActiveSubscription,

View File

@@ -18,6 +18,7 @@ import type {
PageVisibilityMetadata,
SettingChangedMetadata,
SubscriptionMetadata,
SubscriptionSuccessMetadata,
SurveyResponses,
TabCountMetadata,
TelemetryDispatcher,
@@ -80,8 +81,12 @@ export class TelemetryRegistry implements TelemetryDispatcher {
this.dispatch((provider) => provider.trackBeginCheckout?.(metadata))
}
trackMonthlySubscriptionSucceeded(): void {
this.dispatch((provider) => provider.trackMonthlySubscriptionSucceeded?.())
trackMonthlySubscriptionSucceeded(
metadata?: SubscriptionSuccessMetadata
): void {
this.dispatch((provider) =>
provider.trackMonthlySubscriptionSucceeded?.(metadata)
)
}
trackMonthlySubscriptionCancelled(): void {

View File

@@ -92,9 +92,34 @@ describe('GtmTelemetryProvider', () => {
it('pushes subscription_success for subscription activation', () => {
const provider = createInitializedProvider()
provider.trackMonthlySubscriptionSucceeded()
provider.trackMonthlySubscriptionSucceeded({
transaction_id: 'stripe-event-123',
value: 35,
currency: 'USD',
tier: 'creator',
cycle: 'monthly',
checkout_type: 'change',
previous_tier: 'standard',
ecommerce: {
transaction_id: 'stripe-event-123',
value: 35,
currency: 'USD',
items: [
{
item_name: 'creator',
item_category: 'subscription',
item_variant: 'monthly',
price: 35,
quantity: 1
}
]
}
})
expect(lastDataLayerEntry()).toMatchObject({
event: 'subscription_success'
event: 'subscription_success',
transaction_id: 'stripe-event-123',
tier: 'creator',
checkout_type: 'change'
})
})

View File

@@ -16,6 +16,7 @@ import type {
SettingChangedMetadata,
ShareFlowMetadata,
SubscriptionMetadata,
SubscriptionSuccessMetadata,
SurveyResponses,
TabCountMetadata,
TelemetryProvider,
@@ -167,8 +168,17 @@ export class GtmTelemetryProvider implements TelemetryProvider {
this.pushEvent('signup_opened')
}
trackMonthlySubscriptionSucceeded(): void {
this.pushEvent('subscription_success')
trackMonthlySubscriptionSucceeded(
metadata?: SubscriptionSuccessMetadata
): void {
if (metadata?.ecommerce) {
window.dataLayer?.push({ ecommerce: null })
}
this.pushEvent(
'subscription_success',
metadata ? { ...metadata } : undefined
)
}
trackRunButton(options?: {

View File

@@ -31,6 +31,7 @@ import type {
RunButtonProperties,
SettingChangedMetadata,
SubscriptionMetadata,
SubscriptionSuccessMetadata,
SurveyResponses,
TabCountMetadata,
TelemetryEventName,
@@ -235,8 +236,10 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
this.trackEvent(TelemetryEvents.ADD_API_CREDIT_BUTTON_CLICKED)
}
trackMonthlySubscriptionSucceeded(): void {
this.trackEvent(TelemetryEvents.MONTHLY_SUBSCRIPTION_SUCCEEDED)
trackMonthlySubscriptionSucceeded(
metadata?: SubscriptionSuccessMetadata
): void {
this.trackEvent(TelemetryEvents.MONTHLY_SUBSCRIPTION_SUCCEEDED, metadata)
}
/**

View File

@@ -26,6 +26,7 @@ import type {
RunButtonProperties,
SettingChangedMetadata,
SubscriptionMetadata,
SubscriptionSuccessMetadata,
SurveyResponses,
TabCountMetadata,
TelemetryEventName,
@@ -255,8 +256,10 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
this.trackEvent(TelemetryEvents.ADD_API_CREDIT_BUTTON_CLICKED)
}
trackMonthlySubscriptionSucceeded(): void {
this.trackEvent(TelemetryEvents.MONTHLY_SUBSCRIPTION_SUCCEEDED)
trackMonthlySubscriptionSucceeded(
metadata?: SubscriptionSuccessMetadata
): void {
this.trackEvent(TelemetryEvents.MONTHLY_SUBSCRIPTION_SUCCEEDED, metadata)
}
trackMonthlySubscriptionCancelled(): void {

View File

@@ -344,6 +344,29 @@ export interface BeginCheckoutMetadata
previous_tier?: TierKey
}
export interface SubscriptionSuccessMetadata extends Record<string, unknown> {
user_id?: string
transaction_id: string
value: number
currency: string
tier: Exclude<TierKey, 'free'>
cycle: BillingCycle
checkout_type: 'new' | 'change'
previous_tier?: Exclude<TierKey, 'free'>
ecommerce: {
transaction_id: string
value: number
currency: string
items: Array<{
item_name: string
item_category: 'subscription'
item_variant: BillingCycle
price: number
quantity: 1
}>
}
}
/**
* Telemetry provider interface for individual providers.
* All methods are optional - providers only implement what they need.
@@ -360,7 +383,9 @@ export interface TelemetryProvider {
metadata?: SubscriptionMetadata
): void
trackBeginCheckout?(metadata: BeginCheckoutMetadata): void
trackMonthlySubscriptionSucceeded?(): void
trackMonthlySubscriptionSucceeded?(
metadata?: SubscriptionSuccessMetadata
): void
trackMonthlySubscriptionCancelled?(): void
trackAddApiCreditButtonClicked?(): void
trackApiCreditTopupButtonPurchaseClicked?(amount: number): void
@@ -559,3 +584,4 @@ export type TelemetryEventProperties =
| WorkflowSavedMetadata
| DefaultViewSetMetadata
| SubscriptionMetadata
| SubscriptionSuccessMetadata