From 7a9cb4b03b4dd11c0cdd55d28a2460e9853a8935 Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Wed, 6 May 2026 20:44:20 -0400 Subject: [PATCH] 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 --- frontend/src/api/billing.ts | 27 +++++ frontend/src/api/index.ts | 1 + frontend/src/components/layout/AppLayout.tsx | 4 + frontend/src/hooks/useBillingPoll.ts | 32 +++++ frontend/src/store/authStore.ts | 7 ++ frontend/src/store/billingStore.test.ts | 118 +++++++++++++++++++ frontend/src/store/billingStore.ts | 82 +++++++++++++ frontend/src/types/billing.ts | 51 ++++++++ frontend/src/types/index.ts | 8 ++ 9 files changed, 330 insertions(+) create mode 100644 frontend/src/api/billing.ts create mode 100644 frontend/src/hooks/useBillingPoll.ts create mode 100644 frontend/src/store/billingStore.test.ts create mode 100644 frontend/src/store/billingStore.ts create mode 100644 frontend/src/types/billing.ts diff --git a/frontend/src/api/billing.ts b/frontend/src/api/billing.ts new file mode 100644 index 00000000..2ba56173 --- /dev/null +++ b/frontend/src/api/billing.ts @@ -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 { + const response = await apiClient.get('/billing/state') + return transformBillingState(response.data) + }, +} + +export default billingApi diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 50440df8..5084a2aa 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -9,6 +9,7 @@ export { default as foldersApi } from './folders' export { default as stepsApi } from './steps' export { default as stepCategoriesApi } from './stepCategories' export { default as accountsApi } from './accounts' +export { default as billingApi } from './billing' export { default as adminApi } from './admin' export { treeMarkdownApi } from './treeMarkdown' export { default as analyticsApi } from './analytics' diff --git a/frontend/src/components/layout/AppLayout.tsx b/frontend/src/components/layout/AppLayout.tsx index 4a4b08a6..ab4722d2 100644 --- a/frontend/src/components/layout/AppLayout.tsx +++ b/frontend/src/components/layout/AppLayout.tsx @@ -4,6 +4,7 @@ import { Menu, X, LayoutGrid, Clock, AlertTriangle, GitBranch, Wand2, BarChart3, import { useAuthStore } from '@/store/authStore' import { usePermissions } from '@/hooks/usePermissions' import { useUserPreferencesStore } from '@/store/userPreferencesStore' +import { useBillingPoll } from '@/hooks/useBillingPoll' import { BrandLogo } from '@/components/common/BrandLogo' import { TopBar } from './TopBar' import { Sidebar } from './Sidebar' @@ -13,6 +14,9 @@ import { FeedbackWidget } from '@/components/common/FeedbackWidget' import { cn } from '@/lib/utils' export function AppLayout() { + // Poll /billing/state every 60s while authenticated. Hook no-ops when logged out. + useBillingPoll() + const location = useLocation() const navigate = useNavigate() const { user, logout } = useAuthStore() diff --git a/frontend/src/hooks/useBillingPoll.ts b/frontend/src/hooks/useBillingPoll.ts new file mode 100644 index 00000000..cfc397fd --- /dev/null +++ b/frontend/src/hooks/useBillingPoll.ts @@ -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 diff --git a/frontend/src/store/authStore.ts b/frontend/src/store/authStore.ts index 3465b626..68e40459 100644 --- a/frontend/src/store/authStore.ts +++ b/frontend/src/store/authStore.ts @@ -6,6 +6,7 @@ import { authApi } from '@/api/auth' import { identifyUser, resetAnalytics, analytics } from '@/lib/analytics' import { apiClient } from '@/api/client' import { clearCachedQuota } from '@/hooks/useCachedQuota' +import { useBillingStore } from '@/store/billingStore' interface AuthState { user: User | null @@ -85,6 +86,7 @@ export const useAuthStore = create()( localStorage.removeItem('access_token') localStorage.removeItem('refresh_token') clearCachedQuota() + useBillingStore.getState().reset() Sentry.setUser(null) resetAnalytics() set({ user: null, token: null, account: null, subscription: null, isAuthenticated: false, error: null }) @@ -117,6 +119,11 @@ export const useAuthStore = create()( 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 }) + + // 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) { const message = error instanceof Error ? error.message : 'Failed to fetch user' set({ error: message, isLoading: false }) diff --git a/frontend/src/store/billingStore.test.ts b/frontend/src/store/billingStore.test.ts new file mode 100644 index 00000000..e3977b70 --- /dev/null +++ b/frontend/src/store/billingStore.test.ts @@ -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 + +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((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) + }) +}) diff --git a/frontend/src/store/billingStore.ts b/frontend/src/store/billingStore.ts new file mode 100644 index 00000000..594a7a9e --- /dev/null +++ b/frontend/src/store/billingStore.ts @@ -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 + enabledFeatures: Record + isLoading: boolean + error: string | null +} + +interface BillingActions { + /** Fetch billing state. Sets `isLoading` while in flight. */ + fetch: () => Promise + /** Same as `fetch` but intended for explicit refresh after Stripe Checkout. */ + refetch: () => Promise + /** 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((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 diff --git a/frontend/src/types/billing.ts b/frontend/src/types/billing.ts new file mode 100644 index 00000000..f0654038 --- /dev/null +++ b/frontend/src/types/billing.ts @@ -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 + enabledFeatures: Record +} + +/** Raw snake_case payload returned by the backend. */ +export interface BillingStateApiResponse { + subscription: SubscriptionState | null + plan_billing: PlanBillingState | null + plan_limits: Record + enabled_features: Record +} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index bfd9e759..5dc98ebb 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -93,6 +93,14 @@ export type { KBQuotaResponse, } from './kbAccelerator' +export type { + SubscriptionStatus, + SubscriptionState as BillingSubscriptionState, + PlanBillingState, + BillingStatePayload, + BillingStateApiResponse, +} from './billing' + export * from './scripts' export * from './script-builder' export * from './integrations'