Compare commits

...

1 Commits

Author SHA1 Message Date
Benjamin Lu
0e485601d3 feat(billing): send checkout_attempt_id to the subscription checkout API
The checkout attempt UUID already stamps the client-side funnel events
(begin_checkout, app:monthly_subscription_succeeded) but never reached the
backend, so the server-authoritative billing success events could not be
joined to the client funnel deterministically. Create the pending attempt
before the checkout request and include its id in the request body; the
backend stores it in Stripe subscription metadata and echoes it on
billing:subscription_created / billing:subscription_succeeded.
2026-07-07 00:16:03 -07:00
4 changed files with 110 additions and 75 deletions

View File

@@ -346,25 +346,30 @@ describe('useSubscription', () => {
headers: expect.objectContaining({
Authorization: 'Bearer test-token',
'Content-Type': 'application/json'
}),
body: JSON.stringify({
im_ref: 'impact-click-001',
utm_source: 'impact'
})
})
)
const requestBody = JSON.parse(
vi.mocked(global.fetch).mock.calls[0][1]?.body as string
)
expect(requestBody).toEqual({
im_ref: 'impact-click-001',
utm_source: 'impact',
checkout_attempt_id: expect.any(String)
})
expect(windowOpenSpy).toHaveBeenCalledWith(checkoutUrl, '_blank')
expect(
JSON.parse(
localStorage.getItem(PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY) ??
'{}'
)
).toMatchObject({
const persistedAttempt = JSON.parse(
localStorage.getItem(PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY) ?? '{}'
)
expect(persistedAttempt).toMatchObject({
tier: 'standard',
cycle: 'monthly',
checkout_type: 'new'
})
// The id sent to the backend is the persisted attempt's id — the funnel
// join key echoed back on the server-side billing success events.
expect(requestBody.checkout_attempt_id).toBe(persistedAttempt.attempt_id)
windowOpenSpy.mockRestore()
})

View File

@@ -26,8 +26,9 @@ import {
PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY,
clearPendingSubscriptionCheckoutAttempt,
consumePendingSubscriptionCheckoutSuccess,
createPendingSubscriptionCheckoutAttempt,
hasPendingSubscriptionCheckoutAttempt,
recordPendingSubscriptionCheckoutAttempt
persistPendingSubscriptionCheckoutAttempt
} from '@/platform/cloud/subscription/utils/subscriptionCheckoutTracker'
import { useSubscriptionCancellationWatcher } from './useSubscriptionCancellationWatcher'
@@ -207,7 +208,27 @@ function useSubscriptionInternal() {
)
const subscribe = wrapWithErrorHandlingAsync(async () => {
const response = await initiateSubscriptionCheckout()
// Created before the request so the attempt id rides the checkout API
// call into Stripe subscription metadata; the backend echoes it on the
// server-side billing success events. Only persisted once the checkout
// window actually opens, matching the previous behavior.
const pendingAttempt = createPendingSubscriptionCheckoutAttempt({
tier: 'standard',
cycle: 'monthly',
checkout_type: isSubscribedOrIsNotCloud.value ? 'change' : 'new',
...(subscriptionTier.value
? { previous_tier: TIER_TO_KEY[subscriptionTier.value] }
: {}),
...(subscriptionDuration.value === 'ANNUAL'
? { previous_cycle: 'yearly' as const }
: subscriptionDuration.value === 'MONTHLY'
? { previous_cycle: 'monthly' as const }
: {})
})
const response = await initiateSubscriptionCheckout(
pendingAttempt.attempt_id
)
if (!response.checkout_url) {
throw new Error(
@@ -222,19 +243,7 @@ function useSubscriptionInternal() {
return
}
recordPendingSubscriptionCheckoutAttempt({
tier: 'standard',
cycle: 'monthly',
checkout_type: isSubscribedOrIsNotCloud.value ? 'change' : 'new',
...(subscriptionTier.value
? { previous_tier: TIER_TO_KEY[subscriptionTier.value] }
: {}),
...(subscriptionDuration.value === 'ANNUAL'
? { previous_cycle: 'yearly' as const }
: subscriptionDuration.value === 'MONTHLY'
? { previous_cycle: 'monthly' as const }
: {})
})
persistPendingSubscriptionCheckoutAttempt(pendingAttempt)
}, reportError)
const showSubscriptionDialog = (options?: SubscriptionDialogOptions) => {
@@ -406,33 +415,37 @@ function useSubscriptionInternal() {
{ immediate: true }
)
const initiateSubscriptionCheckout =
async (): Promise<CloudSubscriptionCheckoutResponse> => {
const headers = await buildAuthHeaders()
const checkoutAttribution = await getCheckoutAttributionForCloud()
const initiateSubscriptionCheckout = async (
checkoutAttemptId: string
): Promise<CloudSubscriptionCheckoutResponse> => {
const headers = await buildAuthHeaders()
const checkoutAttribution = await getCheckoutAttributionForCloud()
const response = await fetchWithUnifiedRemint(
buildApiUrl('/customers/cloud-subscription-checkout'),
{
method: 'POST',
headers,
body: JSON.stringify(checkoutAttribution)
},
isCloud && flags.unifiedCloudAuthEnabled
const response = await fetchWithUnifiedRemint(
buildApiUrl('/customers/cloud-subscription-checkout'),
{
method: 'POST',
headers,
body: JSON.stringify({
...checkoutAttribution,
checkout_attempt_id: checkoutAttemptId
})
},
isCloud && flags.unifiedCloudAuthEnabled
)
if (!response.ok) {
const errorData = await response.json()
throw new AuthStoreError(
t('toastMessages.failedToInitiateSubscription', {
error: errorData.message
})
)
if (!response.ok) {
const errorData = await response.json()
throw new AuthStoreError(
t('toastMessages.failedToInitiateSubscription', {
error: errorData.message
})
)
}
return response.json()
}
return response.json()
}
return {
// State
isActiveSubscription: isSubscribedOrIsNotCloud,

View File

@@ -161,21 +161,29 @@ describe('performSubscriptionCheckout', () => {
expect.stringContaining(
'/customers/cloud-subscription-checkout/pro-yearly'
),
expect.objectContaining({
method: 'POST',
body: JSON.stringify({
ga_client_id: 'ga-client-id',
ga_session_id: 'ga-session-id',
ga_session_number: 'ga-session-number',
im_ref: 'impact-click-123',
utm_source: 'impact',
utm_medium: 'affiliate',
utm_campaign: 'spring-launch',
gclid: 'gclid-123',
gbraid: 'gbraid-456',
wbraid: 'wbraid-789'
})
})
expect.objectContaining({ method: 'POST' })
)
const requestBody = JSON.parse(
vi.mocked(global.fetch).mock.calls[0][1]?.body as string
)
expect(requestBody).toEqual({
ga_client_id: 'ga-client-id',
ga_session_id: 'ga-session-id',
ga_session_number: 'ga-session-number',
im_ref: 'impact-click-123',
utm_source: 'impact',
utm_medium: 'affiliate',
utm_campaign: 'spring-launch',
gclid: 'gclid-123',
gbraid: 'gbraid-456',
wbraid: 'wbraid-789',
checkout_attempt_id: expect.any(String)
})
// The id sent to the backend must be the same one on begin_checkout and
// the persisted attempt — it is the funnel join key echoed back on the
// server-side billing success events.
expect(requestBody.checkout_attempt_id).toBe(
beginCheckoutMetadata.checkout_attempt_id
)
expect(openSpy).toHaveBeenCalledWith(checkoutUrl, '_blank')
})
@@ -201,11 +209,14 @@ describe('performSubscriptionCheckout', () => {
)
expect(global.fetch).toHaveBeenCalledWith(
expect.stringContaining('/customers/cloud-subscription-checkout/pro'),
expect.objectContaining({
method: 'POST',
body: JSON.stringify({})
})
expect.objectContaining({ method: 'POST' })
)
// Attribution failed, but the funnel join key is still sent.
expect(
JSON.parse(vi.mocked(global.fetch).mock.calls[0][1]?.body as string)
).toEqual({
checkout_attempt_id: expect.any(String)
})
expect(mockTelemetry.trackBeginCheckout).toHaveBeenCalledWith({
user_id: 'user-123',
tier: 'pro',

View File

@@ -84,7 +84,20 @@ export async function performSubscriptionCheckout(
error
)
}
const checkoutPayload = { ...checkoutAttribution }
// Created before the request so the attempt id rides the checkout API call
// into Stripe subscription metadata; the backend echoes it on the
// server-side billing success events, joining them to begin_checkout /
// app:monthly_subscription_succeeded (which carry the same id below).
const pendingAttempt = createPendingSubscriptionCheckoutAttempt({
tier: tierKey,
cycle: currentBillingCycle,
checkout_type: 'new',
payment_intent_source: paymentIntentSource
})
const checkoutPayload = {
...checkoutAttribution,
checkout_attempt_id: pendingAttempt.attempt_id
}
const response = await fetchWithUnifiedRemint(
`${getComfyApiBaseUrl()}/customers/cloud-subscription-checkout/${checkoutTier}`,
@@ -122,13 +135,6 @@ export async function performSubscriptionCheckout(
const data = await response.json()
if (data.checkout_url) {
const pendingAttempt = createPendingSubscriptionCheckoutAttempt({
tier: tierKey,
cycle: currentBillingCycle,
checkout_type: 'new',
payment_intent_source: paymentIntentSource
})
if (userId.value) {
telemetry?.trackBeginCheckout(
withPendingCheckoutAttemptId(