diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 5084a2aa..1d0ca2f4 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -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' diff --git a/frontend/src/api/usage.ts b/frontend/src/api/usage.ts new file mode 100644 index 00000000..f08f7f44 --- /dev/null +++ b/frontend/src/api/usage.ts @@ -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 diff --git a/frontend/src/hooks/useFeature.test.ts b/frontend/src/hooks/useFeature.test.ts new file mode 100644 index 00000000..38f35d29 --- /dev/null +++ b/frontend/src/hooks/useFeature.test.ts @@ -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) + }) +}) diff --git a/frontend/src/hooks/useFeature.ts b/frontend/src/hooks/useFeature.ts new file mode 100644 index 00000000..12f971d7 --- /dev/null +++ b/frontend/src/hooks/useFeature.ts @@ -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 diff --git a/frontend/src/hooks/useFeatureLimit.test.ts b/frontend/src/hooks/useFeatureLimit.test.ts new file mode 100644 index 00000000..8561463d --- /dev/null +++ b/frontend/src/hooks/useFeatureLimit.test.ts @@ -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) + }) +}) diff --git a/frontend/src/hooks/useFeatureLimit.ts b/frontend/src/hooks/useFeatureLimit.ts new file mode 100644 index 00000000..4d6b05a9 --- /dev/null +++ b/frontend/src/hooks/useFeatureLimit.ts @@ -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() + +/** 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; 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(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(initialFresh ? initialCached.current!.used : 0) + const [isLoading, setIsLoading] = useState(!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(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 diff --git a/frontend/src/hooks/useTrialBanner.test.ts b/frontend/src/hooks/useTrialBanner.test.ts new file mode 100644 index 00000000..93cc2843 --- /dev/null +++ b/frontend/src/hooks/useTrialBanner.test.ts @@ -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 { + 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) { + 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 (1–3 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) + }) + }) +}) diff --git a/frontend/src/hooks/useTrialBanner.ts b/frontend/src/hooks/useTrialBanner.ts new file mode 100644 index 00000000..cc3ebe47 --- /dev/null +++ b/frontend/src/hooks/useTrialBanner.ts @@ -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 + // 1–3 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