feat(billing): add useFeature, useFeatureLimit, useTrialBanner hooks

Phase 2 Task 33. Components can now ask "is this feature on?", "how many
sessions left?", and "what stage is the trial in?" without re-implementing
the read against useBillingStore.

- useFeature(flagKey): boolean — reads enabledFeatures from store
- useFeatureLimit(field): { used, limit, percentage, isAtLimit, isLoading }
  with non-blocking 60s module-level cache and graceful 404 degradation
- useTrialBanner(): derives stage from subscription status + trial countdown,
  returns null on initial load to prevent flicker
- usageApi.getCount(field) — calls /api/v1/usage/{field}; backend endpoint
  is not yet implemented (planned), so the hook degrades to used=0

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-06 20:52:18 -04:00
parent 7a9cb4b03b
commit 0b5ed9aa10
8 changed files with 538 additions and 0 deletions

View File

@@ -10,6 +10,7 @@ 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 usageApi } from './usage'
export { default as adminApi } from './admin'
export { treeMarkdownApi } from './treeMarkdown'
export { default as analyticsApi } from './analytics'

23
frontend/src/api/usage.ts Normal file
View File

@@ -0,0 +1,23 @@
import apiClient from './client'
/**
* Usage counters API.
*
* TODO: backend `/usage/{field}` endpoint not yet implemented (planned).
* Tracked under self-serve signup Phase 2 — Task 33 calls this lazily; today
* it 404s and the consuming hook (`useFeatureLimit`) cleanly degrades to
* `used = 0`.
*/
export const usageApi = {
/**
* Fetch the current count for a usage field (e.g. `active_users`,
* `flowpilot_sessions_this_month`). The field name is the same key used in
* `BillingState.planLimits`.
*/
async getCount(field: string): Promise<{ used: number }> {
const response = await apiClient.get<{ used: number }>(`/usage/${field}`)
return response.data
},
}
export default usageApi

View File

@@ -0,0 +1,44 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import { useFeature } from './useFeature'
import { useBillingStore } from '@/store/billingStore'
describe('useFeature', () => {
beforeEach(() => {
useBillingStore.setState({
subscription: null,
planBilling: null,
planLimits: {},
enabledFeatures: {},
isLoading: false,
error: null,
})
})
it('returns false when flag absent', () => {
const { result } = renderHook(() => useFeature('does_not_exist'))
expect(result.current).toBe(false)
})
it('returns true when flag is enabled', () => {
useBillingStore.setState({ enabledFeatures: { ai_builder: true } })
const { result } = renderHook(() => useFeature('ai_builder'))
expect(result.current).toBe(true)
})
it('returns false when flag is explicitly disabled', () => {
useBillingStore.setState({ enabledFeatures: { ai_builder: false } })
const { result } = renderHook(() => useFeature('ai_builder'))
expect(result.current).toBe(false)
})
it('updates when store changes (subscribes to store)', () => {
const { result } = renderHook(() => useFeature('foo'))
expect(result.current).toBe(false)
act(() => {
useBillingStore.setState({ enabledFeatures: { foo: true } })
})
expect(result.current).toBe(true)
})
})

View File

@@ -0,0 +1,16 @@
import { useBillingStore } from '@/store/billingStore'
/**
* Returns whether a feature flag is enabled for the current account.
*
* Reads from `useBillingStore.enabledFeatures`, which is populated by
* `GET /billing/state`. Returns `false` when the flag is absent (closed-by-default).
*
* The hook subscribes to the store so updates from `refetch()` propagate
* without manual refetch in the component.
*/
export function useFeature(flagKey: string): boolean {
return useBillingStore((state) => Boolean(state.enabledFeatures[flagKey]))
}
export default useFeature

View File

@@ -0,0 +1,112 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { renderHook, waitFor } from '@testing-library/react'
import { useFeatureLimit, clearUsageCache } from './useFeatureLimit'
import { useBillingStore } from '@/store/billingStore'
vi.mock('@/api/usage', () => ({
usageApi: {
getCount: vi.fn(),
},
}))
import { usageApi } from '@/api/usage'
const mockedGetCount = vi.mocked(usageApi.getCount)
describe('useFeatureLimit', () => {
beforeEach(() => {
clearUsageCache()
mockedGetCount.mockReset()
useBillingStore.setState({
subscription: null,
planBilling: null,
planLimits: {},
enabledFeatures: {},
isLoading: false,
error: null,
})
})
it('transitions isLoading -> loaded', async () => {
useBillingStore.setState({ planLimits: { active_users: 10 } })
mockedGetCount.mockResolvedValueOnce({ used: 4 })
const { result } = renderHook(() => useFeatureLimit('active_users'))
// Non-blocking initial state.
expect(result.current.isLoading).toBe(true)
expect(result.current.used).toBe(0)
expect(result.current.limit).toBe(10)
await waitFor(() => {
expect(result.current.isLoading).toBe(false)
})
expect(result.current.used).toBe(4)
expect(result.current.limit).toBe(10)
expect(result.current.percentage).toBe(40)
expect(result.current.isAtLimit).toBe(false)
})
it('flags isAtLimit when used >= limit', async () => {
useBillingStore.setState({ planLimits: { seats: 3 } })
mockedGetCount.mockResolvedValueOnce({ used: 3 })
const { result } = renderHook(() => useFeatureLimit('seats'))
await waitFor(() => expect(result.current.isLoading).toBe(false))
expect(result.current.isAtLimit).toBe(true)
expect(result.current.percentage).toBe(100)
})
it('returns null percentage when limit is null (unlimited)', async () => {
useBillingStore.setState({ planLimits: { sessions: null } })
mockedGetCount.mockResolvedValueOnce({ used: 7 })
const { result } = renderHook(() => useFeatureLimit('sessions'))
await waitFor(() => expect(result.current.isLoading).toBe(false))
expect(result.current.limit).toBe(null)
expect(result.current.percentage).toBe(null)
expect(result.current.isAtLimit).toBe(false)
})
it('resets isLoading=true synchronously when `field` prop changes', async () => {
useBillingStore.setState({ planLimits: { max_trees: 5, max_users: 10 } })
mockedGetCount.mockResolvedValueOnce({ used: 2 }) // for max_trees
mockedGetCount.mockResolvedValueOnce({ used: 3 }) // for max_users (slow)
const { result, rerender } = renderHook(
({ field }: { field: string }) => useFeatureLimit(field),
{ initialProps: { field: 'max_trees' } },
)
// First field resolves.
await waitFor(() => expect(result.current.isLoading).toBe(false))
expect(result.current.used).toBe(2)
expect(result.current.limit).toBe(5)
// Switch field. Next render must report isLoading=true (no stale data
// bleed-through) before the new fetch resolves.
rerender({ field: 'max_users' })
expect(result.current.isLoading).toBe(true)
expect(result.current.used).toBe(0)
expect(result.current.limit).toBe(10)
await waitFor(() => expect(result.current.isLoading).toBe(false))
expect(result.current.used).toBe(3)
expect(result.current.limit).toBe(10)
})
it('degrades to used=0 on fetch error (404 from missing endpoint)', async () => {
useBillingStore.setState({ planLimits: { active_users: 5 } })
mockedGetCount.mockRejectedValueOnce(new Error('Request failed with status 404'))
const { result } = renderHook(() => useFeatureLimit('active_users'))
await waitFor(() => expect(result.current.isLoading).toBe(false))
expect(result.current.used).toBe(0)
expect(result.current.limit).toBe(5)
expect(result.current.percentage).toBe(0)
})
})

View File

@@ -0,0 +1,125 @@
import { useEffect, useRef, useState } from 'react'
import { useBillingStore } from '@/store/billingStore'
import { usageApi } from '@/api/usage'
const CACHE_TTL_MS = 60 * 1000
interface CacheEntry {
used: number
timestamp: number
}
const cache = new Map<string, CacheEntry>()
/** Clear the usage cache (call on logout to prevent stale data across users). */
export function clearUsageCache() {
cache.clear()
}
export interface FeatureLimitResult {
used: number
limit: number | null
/** null when limit is null (unlimited) or unknown */
percentage: number | null
isAtLimit: boolean
isLoading: boolean
}
function coerceLimit(raw: unknown): number | null {
if (typeof raw === 'number' && Number.isFinite(raw)) return raw
if (raw === null || raw === undefined) return null
// The store types planLimits as Record<string, unknown>; the backend
// currently returns numbers, but defensively handle string ints too.
if (typeof raw === 'string') {
const n = Number(raw)
return Number.isFinite(n) ? n : null
}
return null
}
/**
* Returns progress against a quantitative plan limit.
*
* `limit` comes from `useBillingStore.planLimits[field]`, which is read
* synchronously from the store. `used` is fetched lazily from
* `GET /api/v1/usage/{field}` on mount and cached for 60s in a module-level
* map keyed by field.
*
* Render is non-blocking: the hook returns `isLoading=true` (with `used=0`)
* until the usage fetch resolves. On 404 or any error the hook degrades to
* `used=0` with `isLoading=false` rather than surfacing the error — the
* `/usage/{field}` endpoint is not yet implemented on the backend (planned).
*/
export function useFeatureLimit(field: string): FeatureLimitResult {
const limit = useBillingStore((state) => coerceLimit(state.planLimits[field]))
// Initialize from cache on first mount only; subsequent `field` changes
// are handled inside the effect below so the render-phase result reflects
// the new field synchronously (no stale `used`/`isLoading` for one tick).
const initialCached = useRef<CacheEntry | undefined>(undefined)
if (initialCached.current === undefined) {
initialCached.current = cache.get(field)
}
const initialFresh =
initialCached.current && Date.now() - initialCached.current.timestamp < CACHE_TTL_MS
const [used, setUsed] = useState<number>(initialFresh ? initialCached.current!.used : 0)
const [isLoading, setIsLoading] = useState<boolean>(!initialFresh)
// Track the field that the current `used`/`isLoading` state describes.
// When `field` changes, we synchronously reset state in render so callers
// never see stale data for the previous field.
const stateField = useRef<string>(field)
if (stateField.current !== field) {
stateField.current = field
const existing = cache.get(field)
const freshNow = existing && Date.now() - existing.timestamp < CACHE_TTL_MS
if (freshNow) {
setUsed(existing!.used)
setIsLoading(false)
} else {
setUsed(0)
setIsLoading(true)
}
}
useEffect(() => {
const existing = cache.get(field)
if (existing && Date.now() - existing.timestamp < CACHE_TTL_MS) {
setUsed(existing.used)
setIsLoading(false)
return
}
let cancelled = false
setIsLoading(true)
usageApi
.getCount(field)
.then((result) => {
if (cancelled) return
cache.set(field, { used: result.used, timestamp: Date.now() })
setUsed(result.used)
})
.catch(() => {
// TODO: backend /usage/{field} endpoint not yet implemented (planned).
// 404s and other errors degrade to used=0 silently — no toast.
if (cancelled) return
setUsed(0)
})
.finally(() => {
if (cancelled) return
setIsLoading(false)
})
return () => {
cancelled = true
}
}, [field])
const percentage =
limit === null || limit <= 0 ? null : Math.min(100, Math.round((used / limit) * 100))
const isAtLimit = limit !== null && used >= limit
return { used, limit, percentage, isAtLimit, isLoading }
}
export default useFeatureLimit

View File

@@ -0,0 +1,131 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { renderHook } from '@testing-library/react'
import { useTrialBanner } from './useTrialBanner'
import { useBillingStore } from '@/store/billingStore'
import type { SubscriptionState } from '@/types/billing'
const FROZEN_NOW = new Date('2026-05-06T00:00:00Z')
function makeSub(overrides: Partial<SubscriptionState>): SubscriptionState {
return {
status: 'trialing',
plan: 'starter',
current_period_start: '2026-05-01T00:00:00Z',
current_period_end: null,
cancel_at_period_end: false,
seat_limit: null,
has_pro_entitlement: false,
is_paid: false,
...overrides,
}
}
function setSubscription(overrides: Partial<SubscriptionState>) {
useBillingStore.setState({ subscription: makeSub(overrides) })
}
describe('useTrialBanner', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(FROZEN_NOW)
useBillingStore.setState({
subscription: null,
planBilling: null,
planLimits: {},
enabledFeatures: {},
isLoading: false,
error: null,
})
})
afterEach(() => {
vi.useRealTimers()
})
describe('stage matches subscription state matrix', () => {
it('returns null when subscription is null (no flicker on initial load)', () => {
const { result } = renderHook(() => useTrialBanner())
expect(result.current.stage).toBe(null)
expect(result.current.daysRemaining).toBe(null)
})
it('complimentary status -> complimentary stage', () => {
setSubscription({ status: 'complimentary' })
const { result } = renderHook(() => useTrialBanner())
expect(result.current.stage).toBe('complimentary')
})
it('active status -> paid stage', () => {
setSubscription({ status: 'active' })
const { result } = renderHook(() => useTrialBanner())
expect(result.current.stage).toBe('paid')
})
it('past_due status -> past_due stage', () => {
setSubscription({ status: 'past_due' })
const { result } = renderHook(() => useTrialBanner())
expect(result.current.stage).toBe('past_due')
})
it('canceled status -> canceled stage', () => {
setSubscription({ status: 'canceled' })
const { result } = renderHook(() => useTrialBanner())
expect(result.current.stage).toBe('canceled')
})
it('trialing >3 days remaining -> pristine', () => {
// 7 days from frozen now.
setSubscription({
status: 'trialing',
current_period_end: '2026-05-13T00:00:00Z',
})
const { result } = renderHook(() => useTrialBanner())
expect(result.current.stage).toBe('pristine')
expect(result.current.daysRemaining).toBe(7)
})
it('trialing 1-3 days remaining -> warning', () => {
// 2 days from frozen now.
setSubscription({
status: 'trialing',
current_period_end: '2026-05-08T00:00:00Z',
})
const { result } = renderHook(() => useTrialBanner())
expect(result.current.stage).toBe('warning')
expect(result.current.daysRemaining).toBe(2)
})
it('trialing exactly 24 hours remaining -> warning (boundary, not urgent)', () => {
// Exactly 1.0 fractional day from frozen now — must sit on the warning
// side per spec (13 days inclusive of 1).
setSubscription({
status: 'trialing',
current_period_end: '2026-05-07T00:00:00Z',
})
const { result } = renderHook(() => useTrialBanner())
expect(result.current.stage).toBe('warning')
expect(result.current.daysRemaining).toBe(1)
})
it('trialing <1 day remaining -> urgent', () => {
// 12 hours from frozen now -> Math.ceil(0.5) = 1 day.
setSubscription({
status: 'trialing',
current_period_end: '2026-05-06T12:00:00Z',
})
const { result } = renderHook(() => useTrialBanner())
expect(result.current.stage).toBe('urgent')
expect(result.current.daysRemaining).toBe(1)
})
it('trialing past period_end -> expired', () => {
setSubscription({
status: 'trialing',
current_period_end: '2026-05-01T00:00:00Z',
})
const { result } = renderHook(() => useTrialBanner())
expect(result.current.stage).toBe('expired')
expect(result.current.daysRemaining).toBe(0)
})
})
})

View File

@@ -0,0 +1,86 @@
import { useBillingStore } from '@/store/billingStore'
export type TrialBannerStage =
| 'pristine'
| 'warning'
| 'urgent'
| 'expired'
| 'complimentary'
| 'paid'
| 'past_due'
| 'canceled'
export interface TrialBannerResult {
stage: TrialBannerStage | null
daysRemaining: number | null
}
const MS_PER_DAY = 24 * 60 * 60 * 1000
/**
* Derives the trial-banner display stage from the current subscription.
*
* Returns `{ stage: null, daysRemaining: null }` when subscription data is
* not yet loaded — this prevents the banner flickering on initial render.
*
* Subscribes to `useBillingStore` so updates from `refetch()` after a Stripe
* checkout propagate automatically.
*/
export function useTrialBanner(): TrialBannerResult {
const subscription = useBillingStore((state) => state.subscription)
if (!subscription) {
return { stage: null, daysRemaining: null }
}
switch (subscription.status) {
case 'complimentary':
return { stage: 'complimentary', daysRemaining: null }
case 'active':
return { stage: 'paid', daysRemaining: null }
case 'past_due':
return { stage: 'past_due', daysRemaining: null }
case 'canceled':
return { stage: 'canceled', daysRemaining: null }
case 'trialing': {
const end = subscription.current_period_end
? new Date(subscription.current_period_end).getTime()
: null
if (end === null || Number.isNaN(end)) {
// Trialing without a period end is malformed; treat as expired so the
// upgrade prompt still surfaces rather than silently swallowing it.
return { stage: 'expired', daysRemaining: null }
}
const now = Date.now()
if (end <= now) {
return { stage: 'expired', daysRemaining: 0 }
}
const msRemaining = end - now
// Use fractional days for stage thresholds so exactly 24h remaining
// sits on the warning side (1.0), not urgent. The displayed integer
// countdown still uses Math.ceil so "0.5 days" renders as "1 day".
const fractionalDays = msRemaining / MS_PER_DAY
const daysRemaining = Math.ceil(fractionalDays)
// Spec thresholds:
// >3 days remaining → pristine
// 13 days → warning (inclusive of exactly 1)
// <1 day → urgent
let stage: TrialBannerStage = 'pristine'
if (fractionalDays < 1) stage = 'urgent'
else if (fractionalDays <= 3) stage = 'warning'
return { stage, daysRemaining }
}
case 'incomplete':
// Not in the spec's matrix; surface as null so the banner stays hidden
// until checkout actually resolves.
return { stage: null, daysRemaining: null }
default: {
// Defensive fallthrough for unknown statuses — keep the banner hidden.
const _exhaustive: never = subscription.status as never
void _exhaustive
return { stage: null, daysRemaining: null }
}
}
}
export default useTrialBanner