feat(billing): add useBillingStore and /billing/state integration
T32: Single frontend source of truth for subscription / plan / feature state. New Zustand `useBillingStore` fetches `/billing/state` (auto-fetch on login via authStore, reset on logout), exposes `refetch` for post-Checkout refresh, and is supported by a `useBillingPoll` hook that re-fetches every 60s while authenticated. The new `billingApi` client transforms the snake_case backend payload to camelCase at a single boundary so the rest of the frontend never sees `plan_billing` or `enabled_features`. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
27
frontend/src/api/billing.ts
Normal file
27
frontend/src/api/billing.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import apiClient from './client'
|
||||||
|
import type { BillingStateApiResponse, BillingStatePayload } from '@/types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single boundary where the snake_case backend payload is transformed
|
||||||
|
* into the camelCase shape used by the rest of the frontend.
|
||||||
|
*
|
||||||
|
* Keeping the transform here means the store, hooks, and components
|
||||||
|
* never see snake_case keys.
|
||||||
|
*/
|
||||||
|
function transformBillingState(raw: BillingStateApiResponse): BillingStatePayload {
|
||||||
|
return {
|
||||||
|
subscription: raw.subscription ?? null,
|
||||||
|
planBilling: raw.plan_billing ?? null,
|
||||||
|
planLimits: raw.plan_limits ?? {},
|
||||||
|
enabledFeatures: raw.enabled_features ?? {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const billingApi = {
|
||||||
|
async getState(): Promise<BillingStatePayload> {
|
||||||
|
const response = await apiClient.get<BillingStateApiResponse>('/billing/state')
|
||||||
|
return transformBillingState(response.data)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default billingApi
|
||||||
@@ -9,6 +9,7 @@ export { default as foldersApi } from './folders'
|
|||||||
export { default as stepsApi } from './steps'
|
export { default as stepsApi } from './steps'
|
||||||
export { default as stepCategoriesApi } from './stepCategories'
|
export { default as stepCategoriesApi } from './stepCategories'
|
||||||
export { default as accountsApi } from './accounts'
|
export { default as accountsApi } from './accounts'
|
||||||
|
export { default as billingApi } from './billing'
|
||||||
export { default as adminApi } from './admin'
|
export { default as adminApi } from './admin'
|
||||||
export { treeMarkdownApi } from './treeMarkdown'
|
export { treeMarkdownApi } from './treeMarkdown'
|
||||||
export { default as analyticsApi } from './analytics'
|
export { default as analyticsApi } from './analytics'
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Menu, X, LayoutGrid, Clock, AlertTriangle, GitBranch, Wand2, BarChart3,
|
|||||||
import { useAuthStore } from '@/store/authStore'
|
import { useAuthStore } from '@/store/authStore'
|
||||||
import { usePermissions } from '@/hooks/usePermissions'
|
import { usePermissions } from '@/hooks/usePermissions'
|
||||||
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
|
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
|
||||||
|
import { useBillingPoll } from '@/hooks/useBillingPoll'
|
||||||
import { BrandLogo } from '@/components/common/BrandLogo'
|
import { BrandLogo } from '@/components/common/BrandLogo'
|
||||||
import { TopBar } from './TopBar'
|
import { TopBar } from './TopBar'
|
||||||
import { Sidebar } from './Sidebar'
|
import { Sidebar } from './Sidebar'
|
||||||
@@ -13,6 +14,9 @@ import { FeedbackWidget } from '@/components/common/FeedbackWidget'
|
|||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
export function AppLayout() {
|
export function AppLayout() {
|
||||||
|
// Poll /billing/state every 60s while authenticated. Hook no-ops when logged out.
|
||||||
|
useBillingPoll()
|
||||||
|
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { user, logout } = useAuthStore()
|
const { user, logout } = useAuthStore()
|
||||||
|
|||||||
32
frontend/src/hooks/useBillingPoll.ts
Normal file
32
frontend/src/hooks/useBillingPoll.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { useEffect } from 'react'
|
||||||
|
import { useAuthStore } from '@/store/authStore'
|
||||||
|
import { useBillingStore } from '@/store/billingStore'
|
||||||
|
|
||||||
|
const POLL_INTERVAL_MS = 60_000
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-fetches billing state every 60s while a user is logged in.
|
||||||
|
*
|
||||||
|
* Mount once at the top of the authenticated dashboard tree. Polling
|
||||||
|
* automatically pauses when the auth store reports no logged-in user.
|
||||||
|
*
|
||||||
|
* Note: this is a v1 simple-interval implementation; a later task may
|
||||||
|
* swap to SSE / visibility-aware polling.
|
||||||
|
*/
|
||||||
|
export function useBillingPoll(): void {
|
||||||
|
const isAuthenticated = useAuthStore((s) => s.isAuthenticated)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAuthenticated) return
|
||||||
|
|
||||||
|
const id = window.setInterval(() => {
|
||||||
|
void useBillingStore.getState().refetch()
|
||||||
|
}, POLL_INTERVAL_MS)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.clearInterval(id)
|
||||||
|
}
|
||||||
|
}, [isAuthenticated])
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useBillingPoll
|
||||||
@@ -6,6 +6,7 @@ import { authApi } from '@/api/auth'
|
|||||||
import { identifyUser, resetAnalytics, analytics } from '@/lib/analytics'
|
import { identifyUser, resetAnalytics, analytics } from '@/lib/analytics'
|
||||||
import { apiClient } from '@/api/client'
|
import { apiClient } from '@/api/client'
|
||||||
import { clearCachedQuota } from '@/hooks/useCachedQuota'
|
import { clearCachedQuota } from '@/hooks/useCachedQuota'
|
||||||
|
import { useBillingStore } from '@/store/billingStore'
|
||||||
|
|
||||||
interface AuthState {
|
interface AuthState {
|
||||||
user: User | null
|
user: User | null
|
||||||
@@ -85,6 +86,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
localStorage.removeItem('access_token')
|
localStorage.removeItem('access_token')
|
||||||
localStorage.removeItem('refresh_token')
|
localStorage.removeItem('refresh_token')
|
||||||
clearCachedQuota()
|
clearCachedQuota()
|
||||||
|
useBillingStore.getState().reset()
|
||||||
Sentry.setUser(null)
|
Sentry.setUser(null)
|
||||||
resetAnalytics()
|
resetAnalytics()
|
||||||
set({ user: null, token: null, account: null, subscription: null, isAuthenticated: false, error: null })
|
set({ user: null, token: null, account: null, subscription: null, isAuthenticated: false, error: null })
|
||||||
@@ -117,6 +119,11 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
identifyUser({ id: user.id, email: user.email, role: user.role, is_super_admin: user.is_super_admin, account_id: account?.id })
|
identifyUser({ id: user.id, email: user.email, role: user.role, is_super_admin: user.is_super_admin, account_id: account?.id })
|
||||||
|
|
||||||
set({ user, account, subscription, isLoading: false })
|
set({ user, account, subscription, isLoading: false })
|
||||||
|
|
||||||
|
// Kick off billing-state fetch alongside auth — fire-and-forget so
|
||||||
|
// a billing error never breaks login. The billing store records
|
||||||
|
// its own error state.
|
||||||
|
void useBillingStore.getState().fetch()
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const message = error instanceof Error ? error.message : 'Failed to fetch user'
|
const message = error instanceof Error ? error.message : 'Failed to fetch user'
|
||||||
set({ error: message, isLoading: false })
|
set({ error: message, isLoading: false })
|
||||||
|
|||||||
118
frontend/src/store/billingStore.test.ts
Normal file
118
frontend/src/store/billingStore.test.ts
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { useBillingStore } from './billingStore'
|
||||||
|
import { billingApi } from '@/api/billing'
|
||||||
|
import type { BillingStatePayload } from '@/types'
|
||||||
|
|
||||||
|
vi.mock('@/api/billing', () => ({
|
||||||
|
billingApi: {
|
||||||
|
getState: vi.fn(),
|
||||||
|
},
|
||||||
|
default: {
|
||||||
|
getState: vi.fn(),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
const mockGetState = billingApi.getState as ReturnType<typeof vi.fn>
|
||||||
|
|
||||||
|
const INITIAL_PAYLOAD: BillingStatePayload = {
|
||||||
|
subscription: {
|
||||||
|
status: 'trialing',
|
||||||
|
plan: 'pro',
|
||||||
|
current_period_start: '2026-05-01T00:00:00Z',
|
||||||
|
current_period_end: '2026-05-15T00:00:00Z',
|
||||||
|
cancel_at_period_end: false,
|
||||||
|
seat_limit: 5,
|
||||||
|
has_pro_entitlement: true,
|
||||||
|
is_paid: false,
|
||||||
|
},
|
||||||
|
planBilling: {
|
||||||
|
display_name: 'Pro',
|
||||||
|
description: 'Pro plan',
|
||||||
|
monthly_price_cents: 4900,
|
||||||
|
annual_price_cents: 49000,
|
||||||
|
},
|
||||||
|
planLimits: { seats: 5 },
|
||||||
|
enabledFeatures: { ai_assistant: true },
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('useBillingStore', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
// Reset store to empty initial state.
|
||||||
|
useBillingStore.setState({
|
||||||
|
subscription: null,
|
||||||
|
planBilling: null,
|
||||||
|
planLimits: {},
|
||||||
|
enabledFeatures: {},
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('useBillingStore fetches on login and populates subscription', async () => {
|
||||||
|
mockGetState.mockResolvedValueOnce(INITIAL_PAYLOAD)
|
||||||
|
|
||||||
|
// Sanity: starts empty.
|
||||||
|
expect(useBillingStore.getState().subscription).toBeNull()
|
||||||
|
|
||||||
|
await useBillingStore.getState().fetch()
|
||||||
|
|
||||||
|
const state = useBillingStore.getState()
|
||||||
|
expect(mockGetState).toHaveBeenCalledOnce()
|
||||||
|
expect(state.subscription).toEqual(INITIAL_PAYLOAD.subscription)
|
||||||
|
expect(state.planBilling).toEqual(INITIAL_PAYLOAD.planBilling)
|
||||||
|
expect(state.planLimits).toEqual(INITIAL_PAYLOAD.planLimits)
|
||||||
|
expect(state.enabledFeatures).toEqual(INITIAL_PAYLOAD.enabledFeatures)
|
||||||
|
expect(state.isLoading).toBe(false)
|
||||||
|
expect(state.error).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('useBillingStore resets on logout', async () => {
|
||||||
|
mockGetState.mockResolvedValueOnce(INITIAL_PAYLOAD)
|
||||||
|
await useBillingStore.getState().fetch()
|
||||||
|
expect(useBillingStore.getState().subscription).not.toBeNull()
|
||||||
|
|
||||||
|
useBillingStore.getState().reset()
|
||||||
|
|
||||||
|
const state = useBillingStore.getState()
|
||||||
|
expect(state.subscription).toBeNull()
|
||||||
|
expect(state.planBilling).toBeNull()
|
||||||
|
expect(state.planLimits).toEqual({})
|
||||||
|
expect(state.enabledFeatures).toEqual({})
|
||||||
|
expect(state.isLoading).toBe(false)
|
||||||
|
expect(state.error).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('useBillingStore refetch overwrites stale data', async () => {
|
||||||
|
mockGetState.mockResolvedValueOnce(INITIAL_PAYLOAD)
|
||||||
|
await useBillingStore.getState().fetch()
|
||||||
|
expect(useBillingStore.getState().subscription?.status).toBe('trialing')
|
||||||
|
|
||||||
|
const updatedPayload: BillingStatePayload = {
|
||||||
|
...INITIAL_PAYLOAD,
|
||||||
|
subscription: {
|
||||||
|
...INITIAL_PAYLOAD.subscription!,
|
||||||
|
status: 'active',
|
||||||
|
is_paid: true,
|
||||||
|
},
|
||||||
|
enabledFeatures: { ai_assistant: true, advanced_reports: true },
|
||||||
|
}
|
||||||
|
// Hold the refetch promise open so we can observe mid-flight isLoading=true.
|
||||||
|
let resolveSecond: (value: BillingStatePayload) => void = () => {}
|
||||||
|
mockGetState.mockImplementationOnce(
|
||||||
|
() => new Promise<BillingStatePayload>((resolve) => { resolveSecond = resolve })
|
||||||
|
)
|
||||||
|
|
||||||
|
const refetchPromise = useBillingStore.getState().refetch()
|
||||||
|
expect(useBillingStore.getState().isLoading).toBe(true)
|
||||||
|
resolveSecond(updatedPayload)
|
||||||
|
await refetchPromise
|
||||||
|
|
||||||
|
const state = useBillingStore.getState()
|
||||||
|
expect(mockGetState).toHaveBeenCalledTimes(2)
|
||||||
|
expect(state.subscription?.status).toBe('active')
|
||||||
|
expect(state.subscription?.is_paid).toBe(true)
|
||||||
|
expect(state.enabledFeatures).toEqual({ ai_assistant: true, advanced_reports: true })
|
||||||
|
expect(state.isLoading).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
82
frontend/src/store/billingStore.ts
Normal file
82
frontend/src/store/billingStore.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import { create } from 'zustand'
|
||||||
|
import { billingApi } from '@/api/billing'
|
||||||
|
import type {
|
||||||
|
BillingSubscriptionState,
|
||||||
|
PlanBillingState,
|
||||||
|
} from '@/types'
|
||||||
|
|
||||||
|
interface BillingState {
|
||||||
|
subscription: BillingSubscriptionState | null
|
||||||
|
planBilling: PlanBillingState | null
|
||||||
|
planLimits: Record<string, unknown>
|
||||||
|
enabledFeatures: Record<string, boolean>
|
||||||
|
isLoading: boolean
|
||||||
|
error: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BillingActions {
|
||||||
|
/** Fetch billing state. Sets `isLoading` while in flight. */
|
||||||
|
fetch: () => Promise<void>
|
||||||
|
/** Same as `fetch` but intended for explicit refresh after Stripe Checkout. */
|
||||||
|
refetch: () => Promise<void>
|
||||||
|
/** Reset to empty initial state — call on logout. */
|
||||||
|
reset: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BillingStore = BillingState & BillingActions
|
||||||
|
|
||||||
|
const INITIAL_STATE: BillingState = {
|
||||||
|
subscription: null,
|
||||||
|
planBilling: null,
|
||||||
|
planLimits: {},
|
||||||
|
enabledFeatures: {},
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useBillingStore = create<BillingStore>((set) => ({
|
||||||
|
...INITIAL_STATE,
|
||||||
|
|
||||||
|
fetch: async () => {
|
||||||
|
set({ isLoading: true, error: null })
|
||||||
|
try {
|
||||||
|
const data = await billingApi.getState()
|
||||||
|
set({
|
||||||
|
subscription: data.subscription,
|
||||||
|
planBilling: data.planBilling,
|
||||||
|
planLimits: data.planLimits,
|
||||||
|
enabledFeatures: data.enabledFeatures,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
})
|
||||||
|
} catch (error: unknown) {
|
||||||
|
// 401s are handled globally by the apiClient response interceptor
|
||||||
|
// (token-refresh + logout), so we just record any other error here.
|
||||||
|
const message = error instanceof Error ? error.message : 'Failed to load billing state'
|
||||||
|
set({ isLoading: false, error: message })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
refetch: async () => {
|
||||||
|
// Same semantics as fetch — separate name documents intent at the call site.
|
||||||
|
set({ isLoading: true, error: null })
|
||||||
|
try {
|
||||||
|
const data = await billingApi.getState()
|
||||||
|
set({
|
||||||
|
subscription: data.subscription,
|
||||||
|
planBilling: data.planBilling,
|
||||||
|
planLimits: data.planLimits,
|
||||||
|
enabledFeatures: data.enabledFeatures,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
})
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Failed to load billing state'
|
||||||
|
set({ isLoading: false, error: message })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
reset: () => set({ ...INITIAL_STATE }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
export default useBillingStore
|
||||||
51
frontend/src/types/billing.ts
Normal file
51
frontend/src/types/billing.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
/**
|
||||||
|
* Billing state types for the unified `/billing/state` endpoint.
|
||||||
|
*
|
||||||
|
* The backend returns snake_case keys (`plan_billing`, `enabled_features`);
|
||||||
|
* the API client (`frontend/src/api/billing.ts`) transforms the payload to
|
||||||
|
* camelCase before it reaches the rest of the frontend.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type SubscriptionStatus =
|
||||||
|
| 'trialing'
|
||||||
|
| 'active'
|
||||||
|
| 'past_due'
|
||||||
|
| 'canceled'
|
||||||
|
| 'incomplete'
|
||||||
|
| 'complimentary'
|
||||||
|
|
||||||
|
export interface SubscriptionState {
|
||||||
|
status: SubscriptionStatus
|
||||||
|
plan: string
|
||||||
|
/** ISO 8601 string or null */
|
||||||
|
current_period_start: string | null
|
||||||
|
/** ISO 8601 string or null */
|
||||||
|
current_period_end: string | null
|
||||||
|
cancel_at_period_end: boolean
|
||||||
|
seat_limit: number | null
|
||||||
|
has_pro_entitlement: boolean
|
||||||
|
is_paid: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlanBillingState {
|
||||||
|
display_name: string
|
||||||
|
description: string | null
|
||||||
|
monthly_price_cents: number | null
|
||||||
|
annual_price_cents: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Camel-cased billing-state payload, post-transform. */
|
||||||
|
export interface BillingStatePayload {
|
||||||
|
subscription: SubscriptionState | null
|
||||||
|
planBilling: PlanBillingState | null
|
||||||
|
planLimits: Record<string, unknown>
|
||||||
|
enabledFeatures: Record<string, boolean>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Raw snake_case payload returned by the backend. */
|
||||||
|
export interface BillingStateApiResponse {
|
||||||
|
subscription: SubscriptionState | null
|
||||||
|
plan_billing: PlanBillingState | null
|
||||||
|
plan_limits: Record<string, unknown>
|
||||||
|
enabled_features: Record<string, boolean>
|
||||||
|
}
|
||||||
@@ -93,6 +93,14 @@ export type {
|
|||||||
KBQuotaResponse,
|
KBQuotaResponse,
|
||||||
} from './kbAccelerator'
|
} from './kbAccelerator'
|
||||||
|
|
||||||
|
export type {
|
||||||
|
SubscriptionStatus,
|
||||||
|
SubscriptionState as BillingSubscriptionState,
|
||||||
|
PlanBillingState,
|
||||||
|
BillingStatePayload,
|
||||||
|
BillingStateApiResponse,
|
||||||
|
} from './billing'
|
||||||
|
|
||||||
export * from './scripts'
|
export * from './scripts'
|
||||||
export * from './script-builder'
|
export * from './script-builder'
|
||||||
export * from './integrations'
|
export * from './integrations'
|
||||||
|
|||||||
Reference in New Issue
Block a user