feat: pass target tier to billing portal for subscription updates (#7692)

## Summary

Pass target tier to billing portal API for deep linking to Stripe's
subscription update confirmation screen when user has an active
subscription.

## Changes

- **What**: When a user with an active subscription clicks a tier in
PricingTable, pass the target tier (including billing cycle) to
`accessBillingPortal` which sends it as `target_tier` in the request
body. This enables the backend to create a Stripe billing portal deep
link directly to the subscription update confirmation screen.
- **Dependencies**: Requires comfy-api PR for `POST /customers/billing`
`target_tier` support

## Review Focus

- PricingTable now differentiates between new subscriptions (checkout
flow) and existing subscriptions (billing portal with deep link)
- Type derivation uses `Parameters<typeof
authStore.accessBillingPortal>[0]` to avoid duplicating the tier union
(matches codebase pattern)
- Registry types manually updated to include `target_tier` field (will
be regenerated when API is deployed)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7692-feat-pass-target-tier-to-billing-portal-for-subscription-updates-2d06d73d365081b38fe4c81e95dce58c)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: GitHub Action <action@github.com>
This commit is contained in:
Hunter
2025-12-22 13:43:44 -05:00
committed by GitHub
parent 959c1990b5
commit 176c8e110b
6 changed files with 338 additions and 9 deletions

View File

@@ -30,7 +30,9 @@ const mockAddCreditsResponse = {
const mockAccessBillingPortalResponse = {
ok: true,
statusText: 'OK'
statusText: 'OK',
json: () =>
Promise.resolve({ billing_portal_url: 'https://billing.stripe.com/test' })
}
vi.mock('vuefire', () => ({
@@ -129,7 +131,7 @@ describe('useFirebaseAuthStore', () => {
if (url.endsWith('/customers/credit')) {
return Promise.resolve(mockAddCreditsResponse)
}
if (url.endsWith('/customers/billing-portal')) {
if (url.endsWith('/customers/billing')) {
return Promise.resolve(mockAccessBillingPortalResponse)
}
return Promise.reject(new Error('Unexpected API call'))
@@ -542,4 +544,75 @@ describe('useFirebaseAuthStore', () => {
expect(store.loading).toBe(false)
})
})
describe('accessBillingPortal', () => {
it('should call billing endpoint without body when no targetTier provided', async () => {
const result = await store.accessBillingPortal()
expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining('/customers/billing'),
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({
Authorization: 'Bearer mock-id-token',
'Content-Type': 'application/json'
})
})
)
const callArgs = mockFetch.mock.calls.find((call) =>
(call[0] as string).endsWith('/customers/billing')
)
expect(callArgs?.[1]).not.toHaveProperty('body')
expect(result).toEqual({
billing_portal_url: 'https://billing.stripe.com/test'
})
})
it('should include target_tier in request body when targetTier provided', async () => {
await store.accessBillingPortal('creator')
const callArgs = mockFetch.mock.calls.find((call) =>
(call[0] as string).endsWith('/customers/billing')
)
expect(callArgs?.[1]).toHaveProperty('body')
expect(JSON.parse(callArgs?.[1]?.body as string)).toEqual({
target_tier: 'creator'
})
})
it('should handle different checkout tier formats', async () => {
const tiers = [
'standard',
'creator',
'pro',
'standard-yearly',
'creator-yearly',
'pro-yearly'
] as const
for (const tier of tiers) {
mockFetch.mockClear()
await store.accessBillingPortal(tier)
const callArgs = mockFetch.mock.calls.find((call) =>
(call[0] as string).endsWith('/customers/billing')
)
expect(JSON.parse(callArgs?.[1]?.body as string)).toEqual({
target_tier: tier
})
}
})
it('should throw error when API returns error response', async () => {
mockFetch.mockImplementationOnce(() =>
Promise.resolve({
ok: false,
json: () => Promise.resolve({ message: 'Billing portal unavailable' })
})
)
await expect(store.accessBillingPortal()).rejects.toThrow()
})
})
})