diff --git a/.github/workflows/deploy-production.yml b/.github/workflows/deploy-production.yml index 77dc2402..339ca28a 100644 --- a/.github/workflows/deploy-production.yml +++ b/.github/workflows/deploy-production.yml @@ -65,6 +65,52 @@ jobs: # Apply only new migrations (does not reset existing data) supabase db push --linked + - name: Sync Stripe prices to billing_plans + env: + STRIPE_SECRET_KEY: ${{ secrets.PRODUCTION_STRIPE_SECRET_KEY }} + SUPABASE_URL: ${{ secrets.PRODUCTION_SUPABASE_URL }} + SUPABASE_SERVICE_ROLE_KEY: ${{ secrets.PRODUCTION_SUPABASE_SERVICE_ROLE_KEY }} + run: | + set -euo pipefail + npm run billing:sync-stripe + + - name: Ensure Stripe webhook endpoint (production) + id: stripe_webhook_production + env: + STRIPE_SECRET_KEY: ${{ secrets.PRODUCTION_STRIPE_SECRET_KEY }} + STRIPE_WEBHOOK_URL: ${{ secrets.PRODUCTION_SUPABASE_URL }}/functions/v1/stripe-webhook + STRIPE_WEBHOOK_SECRET: ${{ secrets.PRODUCTION_STRIPE_WEBHOOK_SECRET }} + WEBHOOK_DESCRIPTION: BeakerStack production (${{ secrets.PRODUCTION_SUPABASE_PROJECT_REF }}) + run: node scripts/ensure-stripe-webhook-endpoint.mjs + + - name: Deploy Supabase Edge Functions + env: + SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }} + PRODUCTION_SUPABASE_PROJECT_REF: ${{ secrets.PRODUCTION_SUPABASE_PROJECT_REF }} + PRODUCTION_SUPABASE_DB_PASSWORD: ${{ secrets.PRODUCTION_SUPABASE_DB_PASSWORD }} + PRODUCTION_SUPABASE_URL: ${{ secrets.PRODUCTION_SUPABASE_URL }} + PRODUCTION_SUPABASE_ANON_KEY: ${{ secrets.PRODUCTION_SUPABASE_ANON_KEY }} + PRODUCTION_SUPABASE_SERVICE_ROLE_KEY: ${{ secrets.PRODUCTION_SUPABASE_SERVICE_ROLE_KEY }} + PRODUCTION_STRIPE_SECRET_KEY: ${{ secrets.PRODUCTION_STRIPE_SECRET_KEY }} + PRODUCTION_STRIPE_WEBHOOK_SECRET: ${{ steps.stripe_webhook_production.outputs.resolved_webhook_secret }} + PRODUCTION_BILLING_ALLOWED_ORIGINS: ${{ secrets.PRODUCTION_BILLING_ALLOWED_ORIGINS }} + run: | + set -euo pipefail + cd supabase + supabase link \ + --project-ref "${PRODUCTION_SUPABASE_PROJECT_REF}" \ + --password "${PRODUCTION_SUPABASE_DB_PASSWORD}" \ + --yes + supabase secrets set \ + STRIPE_SECRET_KEY="${PRODUCTION_STRIPE_SECRET_KEY}" \ + STRIPE_WEBHOOK_SECRET="${PRODUCTION_STRIPE_WEBHOOK_SECRET}" \ + BILLING_SUPABASE_URL="${PRODUCTION_SUPABASE_URL}" \ + BILLING_SUPABASE_ANON_KEY="${PRODUCTION_SUPABASE_ANON_KEY}" \ + BILLING_SUPABASE_SERVICE_ROLE_KEY="${PRODUCTION_SUPABASE_SERVICE_ROLE_KEY}" \ + BILLING_ALLOWED_ORIGINS="${PRODUCTION_BILLING_ALLOWED_ORIGINS}" + supabase functions deploy stripe-webhook billing-stripe \ + --project-ref "${PRODUCTION_SUPABASE_PROJECT_REF}" + - name: Resolve infrastructure outputs id: infra shell: bash diff --git a/.github/workflows/deploy-staging.yml b/.github/workflows/deploy-staging.yml index 983a945a..baf57c3b 100644 --- a/.github/workflows/deploy-staging.yml +++ b/.github/workflows/deploy-staging.yml @@ -65,6 +65,52 @@ jobs: # Apply only new migrations (does not reset existing data) supabase db push --linked + - name: Sync Stripe prices to billing_plans + env: + STRIPE_SECRET_KEY: ${{ secrets.STAGING_STRIPE_SECRET_KEY }} + SUPABASE_URL: ${{ secrets.STAGING_SUPABASE_URL }} + SUPABASE_SERVICE_ROLE_KEY: ${{ secrets.STAGING_SUPABASE_SERVICE_ROLE_KEY }} + run: | + set -euo pipefail + npm run billing:sync-stripe + + - name: Ensure Stripe webhook endpoint (staging) + id: stripe_webhook_staging + env: + STRIPE_SECRET_KEY: ${{ secrets.STAGING_STRIPE_SECRET_KEY }} + STRIPE_WEBHOOK_URL: ${{ secrets.STAGING_SUPABASE_URL }}/functions/v1/stripe-webhook + STRIPE_WEBHOOK_SECRET: ${{ secrets.STAGING_STRIPE_WEBHOOK_SECRET }} + WEBHOOK_DESCRIPTION: BeakerStack staging (${{ secrets.STAGING_SUPABASE_PROJECT_REF }}) + run: node scripts/ensure-stripe-webhook-endpoint.mjs + + - name: Deploy Supabase Edge Functions + env: + SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }} + STAGING_SUPABASE_PROJECT_REF: ${{ secrets.STAGING_SUPABASE_PROJECT_REF }} + STAGING_SUPABASE_DB_PASSWORD: ${{ secrets.STAGING_SUPABASE_DB_PASSWORD }} + STAGING_SUPABASE_URL: ${{ secrets.STAGING_SUPABASE_URL }} + STAGING_SUPABASE_ANON_KEY: ${{ secrets.STAGING_SUPABASE_ANON_KEY }} + STAGING_SUPABASE_SERVICE_ROLE_KEY: ${{ secrets.STAGING_SUPABASE_SERVICE_ROLE_KEY }} + STAGING_STRIPE_SECRET_KEY: ${{ secrets.STAGING_STRIPE_SECRET_KEY }} + STAGING_STRIPE_WEBHOOK_SECRET: ${{ steps.stripe_webhook_staging.outputs.resolved_webhook_secret }} + STAGING_BILLING_ALLOWED_ORIGINS: ${{ secrets.STAGING_BILLING_ALLOWED_ORIGINS }} + run: | + set -euo pipefail + cd supabase + supabase link \ + --project-ref "${STAGING_SUPABASE_PROJECT_REF}" \ + --password "${STAGING_SUPABASE_DB_PASSWORD}" \ + --yes + supabase secrets set \ + STRIPE_SECRET_KEY="${STAGING_STRIPE_SECRET_KEY}" \ + STRIPE_WEBHOOK_SECRET="${STAGING_STRIPE_WEBHOOK_SECRET}" \ + BILLING_SUPABASE_URL="${STAGING_SUPABASE_URL}" \ + BILLING_SUPABASE_ANON_KEY="${STAGING_SUPABASE_ANON_KEY}" \ + BILLING_SUPABASE_SERVICE_ROLE_KEY="${STAGING_SUPABASE_SERVICE_ROLE_KEY}" \ + BILLING_ALLOWED_ORIGINS="${STAGING_BILLING_ALLOWED_ORIGINS}" + supabase functions deploy stripe-webhook billing-stripe \ + --project-ref "${STAGING_SUPABASE_PROJECT_REF}" + - name: Resolve infrastructure outputs id: infra shell: bash diff --git a/.github/workflows/pr-preview-environment.yml b/.github/workflows/pr-preview-environment.yml index 3e74836a..4f66e319 100644 --- a/.github/workflows/pr-preview-environment.yml +++ b/.github/workflows/pr-preview-environment.yml @@ -121,6 +121,55 @@ jobs: --baseline-ref "${{ github.base_ref }}" \ --skip-if-unchanged + - name: Sync Stripe prices to preview billing_plans + env: + STRIPE_SECRET_KEY: ${{ secrets.PREVIEW_STRIPE_SECRET_KEY }} + SUPABASE_URL: ${{ secrets.PREVIEW_SUPABASE_URL }} + SUPABASE_SERVICE_ROLE_KEY: ${{ secrets.PR_TESTING_SUPABASE_SERVICE_ROLE_KEY }} + run: | + set -euo pipefail + npm run billing:sync-stripe + + - name: Ensure Stripe webhook endpoint (preview) + id: stripe_webhook_preview + env: + STRIPE_SECRET_KEY: ${{ secrets.PREVIEW_STRIPE_SECRET_KEY }} + STRIPE_WEBHOOK_URL: ${{ secrets.PREVIEW_SUPABASE_URL }}/functions/v1/stripe-webhook + STRIPE_WEBHOOK_SECRET: ${{ secrets.PREVIEW_STRIPE_WEBHOOK_SECRET }} + WEBHOOK_DESCRIPTION: BeakerStack preview (${{ secrets.SUPABASE_PREVIEW_PROJECT_REF }}) + run: node scripts/ensure-stripe-webhook-endpoint.mjs + + - name: Deploy preview billing edge functions + shell: bash + env: + SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }} + SUPABASE_PREVIEW_PROJECT_REF: ${{ secrets.SUPABASE_PREVIEW_PROJECT_REF }} + SUPABASE_PREVIEW_DB_PASSWORD: ${{ secrets.SUPABASE_PREVIEW_DB_PASSWORD }} + PREVIEW_SUPABASE_URL: ${{ secrets.PREVIEW_SUPABASE_URL }} + PREVIEW_SUPABASE_ANON_KEY: ${{ secrets.PREVIEW_SUPABASE_ANON_KEY }} + PR_TESTING_SUPABASE_SERVICE_ROLE_KEY: ${{ secrets.PR_TESTING_SUPABASE_SERVICE_ROLE_KEY }} + PREVIEW_STRIPE_SECRET_KEY: ${{ secrets.PREVIEW_STRIPE_SECRET_KEY }} + PREVIEW_STRIPE_WEBHOOK_SECRET: ${{ steps.stripe_webhook_preview.outputs.resolved_webhook_secret }} + PREVIEW_BILLING_ALLOWED_ORIGINS: ${{ secrets.PREVIEW_BILLING_ALLOWED_ORIGINS }} + run: | + set -euo pipefail + cd supabase + # Path-based previews use this host; empty allowlist breaks checkout redirect validation. + BILLING_ORIGINS="${PREVIEW_BILLING_ALLOWED_ORIGINS:-https://deploy.beakerstack.com}" + supabase link \ + --project-ref "${SUPABASE_PREVIEW_PROJECT_REF}" \ + --password "${SUPABASE_PREVIEW_DB_PASSWORD}" \ + --yes + supabase secrets set \ + STRIPE_SECRET_KEY="${PREVIEW_STRIPE_SECRET_KEY}" \ + STRIPE_WEBHOOK_SECRET="${PREVIEW_STRIPE_WEBHOOK_SECRET}" \ + BILLING_SUPABASE_URL="${PREVIEW_SUPABASE_URL}" \ + BILLING_SUPABASE_ANON_KEY="${PREVIEW_SUPABASE_ANON_KEY}" \ + BILLING_SUPABASE_SERVICE_ROLE_KEY="${PR_TESTING_SUPABASE_SERVICE_ROLE_KEY}" \ + BILLING_ALLOWED_ORIGINS="${BILLING_ORIGINS}" + supabase functions deploy stripe-webhook billing-stripe \ + --project-ref "${SUPABASE_PREVIEW_PROJECT_REF}" + - name: Build and deploy web preview id: web shell: bash diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 32b47d49..8d6f1046 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -207,6 +207,7 @@ jobs: apps/web/coverage apps/mobile/coverage packages/shared-tests/coverage + packages/billing/coverage if-no-files-found: warn - name: Upload test results diff --git a/.gitignore b/.gitignore index 32540ad7..47c4b8ab 100644 --- a/.gitignore +++ b/.gitignore @@ -283,3 +283,9 @@ apps/mobile/google-services.json # AWS build artifacts .aws-build/ + +# Agent / IDE skill trees (Stripe docs mirror + symlinks); install locally, do not commit +.agents/ +.augment/skills/ +.claude/skills/ +skills-lock.json diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 483eb365..c3d42462 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -550,10 +550,12 @@ You can: ### Migration Development Workflow +Schema migrations live **only** under **`supabase/migrations/` at the repository root**. Author migrations (`supabase migration new …`), run **`supabase start`** and **`supabase db reset`** from the **repo root**, even when you are only working on the mobile app. The `apps/mobile/supabase/` tree can keep a mobile-specific `config.toml` for local auth callbacks; it does not hold a second copy of SQL migrations (see `apps/mobile/supabase/migrations/README.md`). + #### For Simple Migrations (New Columns, Indexes) ```bash -# 1. Develop locally +# 1. Develop locally (from repository root) supabase start supabase migration new add_user_bio diff --git a/README.md b/README.md index 34f0157e..4ef3470b 100644 --- a/README.md +++ b/README.md @@ -32,11 +32,12 @@ The specific machinery (tiered Supabase, AWS static hosting with PR paths, EAS c **[Get started → QUICKSTART.md](QUICKSTART.md)** — **Use this template**, local “hello world” in minutes, full-cloud checklist when you are ready. -| Need | Doc | -| --------------------- | ---------------------------------- | -| Full topic index | [docs/README.md](docs/README.md) | -| Environments & design | [ARCHITECTURE.md](ARCHITECTURE.md) | -| Contributing | [CONTRIBUTING.md](CONTRIBUTING.md) | +| Need | Doc | +| --------------------- | ------------------------------------------------------------ | +| Full topic index | [docs/README.md](docs/README.md) | +| Stripe billing setup | [docs/stripe-billing-setup.md](docs/stripe-billing-setup.md) | +| Environments & design | [ARCHITECTURE.md](ARCHITECTURE.md) | +| Contributing | [CONTRIBUTING.md](CONTRIBUTING.md) | ## Features @@ -100,6 +101,7 @@ BeakerStack/ ### Database +- **`supabase/migrations/` is canonical at the repo root** — run `supabase migration new …`, `supabase start`, and `supabase db reset` from the repository root (mobile developers: see [`apps/mobile/supabase/migrations/README.md`](apps/mobile/supabase/migrations/README.md)). - `supabase start` / `supabase stop` — Local Supabase - `npm run gen:types` — TypeScript types from DB - `supabase db reset` — Reset local DB diff --git a/apps/mobile/README.md b/apps/mobile/README.md new file mode 100644 index 00000000..f5d11afd --- /dev/null +++ b/apps/mobile/README.md @@ -0,0 +1,9 @@ +# Mobile app (Expo) + +Most scripts run from the **repository root** (`npm run mobile`, etc.). See [`docs/guides/MOBILE.md`](../../docs/guides/MOBILE.md). + +## Local Supabase schema + +Database migrations live only under **`supabase/migrations/`** at the repo root. From the BeakerStack checkout root, run `supabase start`, `supabase migration new …`, and `supabase db reset` — do not expect duplicated `.sql` files under [`supabase/migrations/`](./supabase/migrations/) (see the policy note there). + +Optional [`supabase/config.toml`](./supabase/config.toml) here keeps mobile-oriented auth redirect URLs separate from the root config; it is not the source of migration SQL. diff --git a/apps/mobile/__tests__/App.test.tsx b/apps/mobile/__tests__/App.test.tsx index ce2e203a..49395105 100644 --- a/apps/mobile/__tests__/App.test.tsx +++ b/apps/mobile/__tests__/App.test.tsx @@ -158,6 +158,31 @@ jest.mock('@beakerstack/shared/components/forms/FormError.native', () => ({ }, })); +// Avoid loading @beakerstack/billing in Jest (package uses TS paths Jest does not resolve like Metro) +jest.mock('../src/screens/BillingScreen', () => { + const { View, Text } = require('react-native'); + return { + __esModule: true, + default: () => ( + + Billing + + ), + }; +}); + +jest.mock('../src/screens/DashboardScreen', () => { + const { View, Text } = require('react-native'); + return { + __esModule: true, + default: () => ( + + Dashboard + + ), + }; +}); + import { render } from '@testing-library/react-native'; import { describe, it, expect } from '@jest/globals'; import App from '../App'; diff --git a/apps/mobile/__tests__/components/AvatarUpload.test.tsx b/apps/mobile/__tests__/components/AvatarUpload.test.tsx index 15c22aad..72479b34 100644 --- a/apps/mobile/__tests__/components/AvatarUpload.test.tsx +++ b/apps/mobile/__tests__/components/AvatarUpload.test.tsx @@ -17,6 +17,16 @@ jest.mock('expo-image-picker', () => ({ MediaTypeOptions: { Images: 'Images', }, + UIImagePickerPresentationStyle: { + FULL_SCREEN: 'fullScreen', + PAGE_SHEET: 'pageSheet', + FORM_SHEET: 'formSheet', + CURRENT_CONTEXT: 'currentContext', + OVER_FULL_SCREEN: 'overFullScreen', + OVER_CURRENT_CONTEXT: 'overCurrentContext', + POPOVER: 'popover', + AUTOMATIC: 'automatic', + }, })); // Mock expo-file-system diff --git a/apps/mobile/__tests__/navigation/AppNavigator.test.tsx b/apps/mobile/__tests__/navigation/AppNavigator.test.tsx index 247e3150..c7ce1dce 100644 --- a/apps/mobile/__tests__/navigation/AppNavigator.test.tsx +++ b/apps/mobile/__tests__/navigation/AppNavigator.test.tsx @@ -53,6 +53,15 @@ jest.mock('../../src/screens/ProfileScreen', () => { ); }); +jest.mock('../../src/screens/BillingScreen', () => { + const { View, Text } = require('react-native'); + return () => ( + + Billing Screen + + ); +}); + describe('AppNavigator', () => { beforeEach(() => { jest.clearAllMocks(); @@ -89,18 +98,27 @@ describe('AppNavigator', () => { global.__DEV__ = originalDev; }); - it.skip('does not expose navigation ref in production', () => { - // Skip - __DEV__ is read-only in Jest environment - const originalDev = __DEV__; - // @ts-expect-error test-only assignment to global __DEV__ - global.__DEV__ = false; - - render(); - - // Navigation ref should not be exposed in production - expect((global as any).navigationRef).toBeUndefined(); - - // @ts-expect-error test-only assignment to global __DEV__ - global.__DEV__ = originalDev; + it('does not expose navigation ref when __DEV__ is false', () => { + const g = globalThis as { __DEV__?: boolean; navigationRef?: unknown }; + const originalDev = g.__DEV__; + try { + delete (g as { navigationRef?: unknown }).navigationRef; + + Object.defineProperty(g, '__DEV__', { + value: false, + configurable: true, + writable: true, + }); + + render(); + + expect(g.navigationRef).toBeUndefined(); + } finally { + Object.defineProperty(g, '__DEV__', { + value: originalDev, + configurable: true, + writable: true, + }); + } }); }); diff --git a/apps/mobile/__tests__/screens/BillingScreen.test.tsx b/apps/mobile/__tests__/screens/BillingScreen.test.tsx new file mode 100644 index 00000000..3a538366 --- /dev/null +++ b/apps/mobile/__tests__/screens/BillingScreen.test.tsx @@ -0,0 +1,251 @@ +import React from 'react'; +import { fireEvent, render, waitFor } from '@testing-library/react-native'; +import { NavigationContainer } from '@react-navigation/native'; +import BillingScreen from '../../src/screens/BillingScreen'; + +const mockGoBack = jest.fn(); + +jest.mock('@react-navigation/native', () => { + const actual = jest.requireActual( + '@react-navigation/native' + ); + return { + ...actual, + useNavigation: () => ({ + goBack: mockGoBack, + }), + }; +}); + +jest.mock('@beakerstack/billing', () => ({ + defineBillingConfig: (c: unknown) => c, + BillingProvider: ({ children }: { children: React.ReactNode }) => children, + useUsage: jest.fn(() => ({ + used: 0, + limit: 30, + remaining: 30, + resetsAt: '', + exceeded: false, + loading: false, + error: null, + refresh: jest.fn().mockResolvedValue(undefined), + })), + useRecordUsage: jest.fn(() => ({ + record: jest.fn().mockResolvedValue(undefined), + pending: false, + })), +})); + +jest.mock('@beakerstack/billing/native', () => { + const { Text } = require('react-native'); + return { + CustomerPortalLink: ({ children }: { children: React.ReactNode }) => ( + <>{children} + ), + FeatureGate: ({ children }: { children: React.ReactNode }) => ( + <>{children} + ), + PricingTable: () => pricing, + SubscriptionStatus: () => status, + UpgradePrompt: ({ reason }: { reason: string }) => {reason}, + UsageIndicator: () => usage, + }; +}); + +jest.mock('react-native-safe-area-context', () => { + const { View } = require('react-native'); + return { + SafeAreaView: View, + }; +}); + +jest.mock('../../src/lib/supabase', () => ({ + supabase: { + rpc: jest.fn().mockResolvedValue({ data: null, error: null }), + }, +})); + +jest.mock('expo-constants', () => ({ + default: { + expoConfig: { + extra: { + supabaseUrl: 'http://localhost:54321', + supabaseAnonKey: 'test-anon-key', + }, + }, + }, +})); + +function restoreBillingMocks(): void { + const billing = jest.requireMock('@beakerstack/billing') as { + useUsage: jest.Mock; + useRecordUsage: jest.Mock; + }; + billing.useUsage.mockImplementation(() => ({ + used: 0, + limit: 30, + remaining: 30, + resetsAt: '', + exceeded: false, + loading: false, + error: null, + refresh: jest.fn().mockResolvedValue(undefined), + })); + billing.useRecordUsage.mockImplementation(() => ({ + record: jest.fn().mockResolvedValue(undefined), + pending: false, + })); +} + +describe('BillingScreen', () => { + beforeEach(() => { + mockGoBack.mockClear(); + restoreBillingMocks(); + ( + jest.requireMock('../../src/lib/supabase').supabase.rpc as jest.Mock + ).mockReset(); + ( + jest.requireMock('../../src/lib/supabase').supabase.rpc as jest.Mock + ).mockResolvedValue({ data: null, error: null }); + if (typeof process !== 'undefined' && process.env) { + process.env.EXPO_PUBLIC_BILLING_DEMO_MODE = ''; + } + }); + + const renderScreen = () => + render( + + + + ); + + it('renders billing title, subtitle, and non-demo hint', () => { + const { getByText } = renderScreen(); + + expect(getByText('Billing')).toBeTruthy(); + expect(getByText(/For the full \/billing experience/i)).toBeTruthy(); + expect(getByText(/Set EXPO_PUBLIC_BILLING_DEMO_MODE=true/i)).toBeTruthy(); + expect(getByText('Subscription')).toBeTruthy(); + expect(getByText('AI summarize (metered)')).toBeTruthy(); + expect(getByText('Use one summarize')).toBeTruthy(); + expect(getByText('Feature B (Max)')).toBeTruthy(); + expect(getByText('Feature B enabled')).toBeTruthy(); + }); + + it('calls navigation.goBack when Back is pressed', async () => { + const { getByText } = renderScreen(); + + fireEvent.press(getByText('← Back')); + + await waitFor(() => { + expect(mockGoBack).toHaveBeenCalledTimes(1); + }); + }); + + it('shows UpgradePrompt when meter is exceeded', () => { + const billing = jest.requireMock('@beakerstack/billing') as { + useUsage: jest.Mock; + }; + billing.useUsage.mockImplementation(() => ({ + used: 30, + limit: 30, + remaining: 0, + resetsAt: '', + exceeded: true, + loading: false, + error: null, + refresh: jest.fn().mockResolvedValue(undefined), + })); + + const { getByText } = renderScreen(); + expect(getByText('Monthly limit reached.')).toBeTruthy(); + }); + + it('shows pending ellipsis on metered button and disables press', () => { + const billing = jest.requireMock('@beakerstack/billing') as { + useRecordUsage: jest.Mock; + }; + billing.useRecordUsage.mockImplementation(() => ({ + record: jest.fn().mockResolvedValue(undefined), + pending: true, + })); + + const { getByText } = renderScreen(); + expect(getByText('…')).toBeTruthy(); + }); + + it('invokes record and refresh from metered block', async () => { + const billing = jest.requireMock('@beakerstack/billing') as { + useRecordUsage: jest.Mock; + useUsage: jest.Mock; + }; + const record = jest.fn().mockResolvedValue(undefined); + const refresh = jest.fn().mockResolvedValue(undefined); + billing.useRecordUsage.mockImplementation(() => ({ + record, + pending: false, + })); + billing.useUsage.mockImplementation(() => ({ + used: 0, + limit: 30, + remaining: 30, + resetsAt: '', + exceeded: false, + loading: false, + error: null, + refresh, + })); + + const { getByText } = renderScreen(); + + fireEvent.press(getByText('Use one summarize')); + fireEvent.press(getByText('Refresh usage')); + + await waitFor(() => { + expect(record).toHaveBeenCalledWith(1); + expect(refresh).toHaveBeenCalled(); + }); + }); + + it('shows demo controls and success message when Simulate Pro succeeds', async () => { + process.env.EXPO_PUBLIC_BILLING_DEMO_MODE = 'true'; + const { supabase } = jest.requireMock('../../src/lib/supabase') as { + supabase: { rpc: jest.Mock }; + }; + supabase.rpc.mockResolvedValue({ data: null, error: null }); + + const { getByText } = renderScreen(); + + expect(getByText('Demo mode — not real billing')).toBeTruthy(); + fireEvent.press(getByText('Simulate Pro')); + + await waitFor(() => { + expect(getByText(/Simulated beakerstack_pro/i)).toBeTruthy(); + }); + expect(supabase.rpc).toHaveBeenCalledWith( + 'billing_demo_simulate_upgrade', + expect.objectContaining({ + p_product_id: 'beakerstack', + p_plan_id: 'beakerstack_pro', + }) + ); + }); + + it('shows RPC error message from demo simulate upgrade', async () => { + process.env.EXPO_PUBLIC_BILLING_DEMO_MODE = 'true'; + const { supabase } = jest.requireMock('../../src/lib/supabase') as { + supabase: { rpc: jest.Mock }; + }; + supabase.rpc.mockResolvedValue({ + data: null, + error: { message: 'Upgrade blocked' }, + }); + + const { getByText } = renderScreen(); + fireEvent.press(getByText('Simulate Pro')); + + await waitFor(() => { + expect(getByText('Upgrade blocked')).toBeTruthy(); + }); + }); +}); diff --git a/apps/mobile/__tests__/screens/DashboardScreen.test.tsx b/apps/mobile/__tests__/screens/DashboardScreen.test.tsx index db0801aa..a6c18010 100644 --- a/apps/mobile/__tests__/screens/DashboardScreen.test.tsx +++ b/apps/mobile/__tests__/screens/DashboardScreen.test.tsx @@ -1,15 +1,57 @@ import React from 'react'; -import { render, waitFor } from '@testing-library/react-native'; -import DashboardScreen from '../../src/screens/DashboardScreen'; +import { fireEvent, render, waitFor } from '@testing-library/react-native'; import { AuthProvider } from '@beakerstack/shared/contexts/AuthContext'; import { ProfileProvider } from '@beakerstack/shared/contexts/ProfileContext'; import type { SupabaseClient } from '@supabase/supabase-js'; -import { - DASHBOARD_TITLE, - DASHBOARD_SUBTITLE, -} from '@beakerstack/shared/utils/strings'; +import { supabase } from '../../src/lib/supabase'; +import DashboardScreen from '../../src/screens/DashboardScreen'; + +jest.mock('@beakerstack/billing', () => { + return { + defineBillingConfig: (c: unknown) => c, + BillingProvider: ({ children }: { children: React.ReactNode }) => children, + mapUnknownError: (e: unknown) => ({ + kind: 'unknown' as const, + message: e instanceof Error ? e.message : String(e), + }), + useBillingContext: () => ({ + refreshSubscription: jest.fn().mockResolvedValue(undefined), + config: { productId: 'beakerstack' }, + }), + useFeature: jest.fn(() => ({ + enabled: true, + value: 2, + loading: false, + error: null, + })), + usePlan: jest.fn(() => ({ + data: { id: 'beakerstack_free', display_name: 'Free' }, + loading: false, + error: null, + })), + useUsage: jest.fn(() => ({ + used: 0, + limit: 30, + remaining: 30, + resetsAt: '', + exceeded: false, + loading: false, + error: null, + refresh: jest.fn().mockResolvedValue(undefined), + })), + }; +}); + +jest.mock('@beakerstack/billing/native', () => { + const { Text } = require('react-native'); + return { + FeatureGate: ({ children }: { children: React.ReactNode }) => ( + <>{children} + ), + UsageIndicator: () => usage, + }; +}); -// Mock expo-constants jest.mock('expo-constants', () => ({ default: { expoConfig: { @@ -21,96 +63,196 @@ jest.mock('expo-constants', () => ({ }, })); -// Mock supabase -jest.mock('../../src/lib/supabase', () => ({ - supabase: {} as SupabaseClient, -})); - -// Mock AppHeader jest.mock('@beakerstack/shared/components/navigation/AppHeader.native', () => ({ AppHeader: () => null, })); -const mockReplace = jest.fn(); -const mockNavigation = { - replace: mockReplace, -} as any; - -const createMockSupabaseClient = (hasUser = false): SupabaseClient => { - const mockUser = { - id: 'test-user-id', - email: 'test@example.com', - app_metadata: {}, - user_metadata: {}, - aud: 'authenticated', - created_at: new Date().toISOString(), - }; - - const mockSession = { - access_token: 'mock-token', - refresh_token: 'mock-refresh', - expires_in: 3600, - expires_at: Date.now() + 3600000, - token_type: 'bearer', - user: mockUser, +jest.mock('../../src/lib/supabase', () => { + const mockRpc = jest.fn((name: string) => { + if (name === 'billing_record_usage_event') { + return Promise.resolve({ data: null, error: null }); + } + if (name === 'billing_demo_get_collections') { + return Promise.resolve({ data: [], error: null }); + } + if (name === 'ensure_billing_subscription') { + return Promise.resolve({ data: null, error: null }); + } + if (name === 'billing_get_remaining_usage') { + return Promise.resolve({ + data: { + used: 0, + limit: 30, + remaining: 30, + periodEnd: '', + periodStart: '', + }, + error: null, + }); + } + return Promise.resolve({ data: null, error: null }); + }); + const mockChannel = { + on: jest.fn().mockReturnThis(), + subscribe: jest.fn().mockReturnThis(), + unsubscribe: jest.fn().mockResolvedValue(undefined), }; - return { - auth: { - getSession: jest.fn().mockResolvedValue({ - data: { session: hasUser ? mockSession : null }, - error: null, - }), - onAuthStateChange: jest.fn(() => ({ - data: { - subscription: { - unsubscribe: jest.fn(), + supabase: { + auth: { + getSession: jest.fn(), + onAuthStateChange: jest.fn(() => ({ + data: { + subscription: { + unsubscribe: jest.fn(), + }, }, - }, - })), - signOut: jest.fn(), - }, - from: jest.fn(() => ({ - select: jest.fn(() => ({ - eq: jest.fn(() => ({ - single: jest.fn().mockResolvedValue({ data: null, error: null }), + })), + signOut: jest.fn(), + }, + from: jest.fn(() => ({ + select: jest.fn(() => ({ + eq: jest.fn(() => ({ + eq: jest.fn(() => ({ + maybeSingle: jest + .fn() + .mockResolvedValue({ data: null, error: null }), + })), + maybeSingle: jest + .fn() + .mockResolvedValue({ data: null, error: null }), + single: jest.fn().mockResolvedValue({ data: null, error: null }), + })), })), })), - })), - channel: jest.fn(() => ({ - on: jest.fn().mockReturnThis(), - subscribe: jest.fn(() => ({ - unsubscribe: jest.fn(), - })), - })), - } as unknown as SupabaseClient; + rpc: mockRpc, + channel: jest.fn(() => mockChannel), + removeChannel: jest.fn().mockResolvedValue({ status: 'ok', error: null }), + functions: { + invoke: jest.fn().mockResolvedValue({ data: null, error: null }), + }, + }, + }; +}); + +const mockUser = { + id: 'test-user-id', + email: 'test@example.com', + app_metadata: {}, + user_metadata: {}, + aud: 'authenticated', + created_at: new Date().toISOString(), +}; + +const mockSession = { + access_token: 'mock-token', + refresh_token: 'mock-refresh', + expires_in: 3600, + expires_at: Date.now() + 3600000, + token_type: 'bearer', + user: mockUser, }; -const renderWithProviders = ( - component: React.ReactElement, - mockClient?: SupabaseClient -) => { - const client = mockClient || createMockSupabaseClient(); +const mockReplace = jest.fn(); +const mockNavigate = jest.fn(); +const mockNavigation = { + replace: mockReplace, + navigate: mockNavigate, +} as any; + +const renderWithProviders = (component: React.ReactElement) => { return render( - - {component} + + + {component} + ); }; +function restoreDefaultSupabaseRpc(): void { + (supabase.rpc as jest.Mock).mockImplementation((name: string) => { + if (name === 'billing_record_usage_event') { + return Promise.resolve({ data: null, error: null }); + } + if (name === 'billing_demo_get_collections') { + return Promise.resolve({ data: [], error: null }); + } + if (name === 'billing_demo_simulate_upgrade') { + return Promise.resolve({ data: null, error: null }); + } + if (name === 'billing_demo_reset_usage') { + return Promise.resolve({ data: null, error: null }); + } + if (name === 'ensure_billing_subscription') { + return Promise.resolve({ data: null, error: null }); + } + if (name === 'billing_get_remaining_usage') { + return Promise.resolve({ + data: { + used: 0, + limit: 30, + remaining: 30, + periodEnd: '', + periodStart: '', + }, + error: null, + }); + } + return Promise.resolve({ data: null, error: null }); + }); +} + +function restoreBillingFeatureMocks(): void { + const billing = jest.requireMock('@beakerstack/billing') as { + useUsage: jest.Mock; + useFeature: jest.Mock; + }; + billing.useFeature.mockImplementation(() => ({ + enabled: true, + value: 2, + loading: false, + error: null, + })); + billing.useUsage.mockImplementation(() => ({ + used: 0, + limit: 30, + remaining: 30, + resetsAt: '', + exceeded: false, + loading: false, + error: null, + refresh: jest.fn().mockResolvedValue(undefined), + })); +} + describe('DashboardScreen', () => { beforeEach(() => { jest.clearAllMocks(); jest.useFakeTimers(); + restoreDefaultSupabaseRpc(); + (supabase.functions.invoke as jest.Mock).mockReset(); + (supabase.functions.invoke as jest.Mock).mockResolvedValue({ + data: null, + error: null, + }); + (supabase.auth.getSession as jest.Mock).mockResolvedValue({ + data: { session: mockSession }, + error: null, + }); + restoreBillingFeatureMocks(); }); afterEach(() => { jest.useRealTimers(); + if (typeof process !== 'undefined' && process.env) { + process.env.EXPO_PUBLIC_BILLING_DEMO_MODE = ''; + process.env.EXPO_PUBLIC_DEMO_USE_REAL_AI = ''; + } }); it('shows loading state while checking authentication', () => { - const mockClient = createMockSupabaseClient(); - (mockClient.auth.getSession as jest.Mock).mockImplementation( + (supabase.auth.getSession as jest.Mock).mockImplementation( () => new Promise(resolve => { setTimeout( @@ -121,18 +263,20 @@ describe('DashboardScreen', () => { ); const { getByText } = renderWithProviders( - , - mockClient + ); expect(getByText('Loading...')).toBeTruthy(); }); it('redirects to Home when not authenticated', async () => { - const mockClient = createMockSupabaseClient(false); + (supabase.auth.getSession as jest.Mock).mockResolvedValue({ + data: { session: null }, + error: null, + }); + const { getByText } = renderWithProviders( - , - mockClient + ); await waitFor(() => { @@ -147,15 +291,472 @@ describe('DashboardScreen', () => { }); it('renders dashboard content when authenticated', async () => { - const mockClient = createMockSupabaseClient(true); + (supabase.auth.getSession as jest.Mock).mockResolvedValue({ + data: { session: mockSession }, + error: null, + }); + + const { getByText } = renderWithProviders( + + ); + + await waitFor(() => { + expect(getByText(/welcome to beakerstack/i)).toBeTruthy(); + }); + }); + + it('navigates to Billing when polished billing link is pressed', async () => { + const { getByText } = renderWithProviders( + + ); + + await waitFor(() => { + expect(getByText(/welcome to beakerstack/i)).toBeTruthy(); + }); + fireEvent.press(getByText('View polished billing →')); + expect(mockNavigate).toHaveBeenCalledWith('Billing'); + }); + + it('records metered usage and shows fake AI summary when Simulate AI summarize is pressed', async () => { + const { getByText } = renderWithProviders( + + ); + + await waitFor(() => { + expect(getByText('Simulate AI summarize')).toBeTruthy(); + }); + fireEvent.press(getByText('Simulate AI summarize')); + + await waitFor(() => { + expect(getByText(/Lorem ipsum/i)).toBeTruthy(); + }); + expect(supabase.rpc).toHaveBeenCalledWith( + 'billing_record_usage_event', + expect.objectContaining({ + p_product_id: 'beakerstack', + p_quantity: 1, + }) + ); + }); + + it('shows limit reached when meter usage is exceeded', async () => { + const billing = jest.requireMock('@beakerstack/billing') as { + useUsage: jest.Mock; + }; + billing.useUsage.mockImplementation(() => ({ + used: 30, + limit: 30, + remaining: 0, + resetsAt: '', + exceeded: true, + loading: false, + error: null, + refresh: jest.fn().mockResolvedValue(undefined), + })); + + const { getByText } = renderWithProviders( + + ); + + await waitFor(() => { + expect(getByText(/Limit reached — open Billing for plans/i)).toBeTruthy(); + }); + }); + + it('adds a demo collection when Add collection is pressed', async () => { + const demoRows: { id: string; item_count: number }[] = []; + (supabase.rpc as jest.Mock).mockImplementation((name: string) => { + if (name === 'billing_record_usage_event') { + return Promise.resolve({ data: null, error: null }); + } + if (name === 'billing_demo_get_collections') { + return Promise.resolve({ data: [...demoRows], error: null }); + } + if (name === 'billing_demo_add_collection') { + demoRows.push({ id: 'new-col', item_count: 0 }); + return Promise.resolve({ data: null, error: null }); + } + if (name === 'ensure_billing_subscription') { + return Promise.resolve({ data: null, error: null }); + } + if (name === 'billing_get_remaining_usage') { + return Promise.resolve({ + data: { + used: 0, + limit: 30, + remaining: 30, + periodEnd: '', + periodStart: '', + }, + error: null, + }); + } + return Promise.resolve({ data: null, error: null }); + }); + + const { getByText } = renderWithProviders( + + ); + + await waitFor(() => { + expect(getByText('Add collection')).toBeTruthy(); + }); + fireEvent.press(getByText('Add collection')); + + await waitFor(() => { + expect(getByText(/Collections:/)).toBeTruthy(); + expect(getByText(/1 of 2/)).toBeTruthy(); + }); + }); + + it('shows record error when billing_record_usage_event returns an error', async () => { + (supabase.rpc as jest.Mock).mockImplementation((name: string) => { + if (name === 'billing_record_usage_event') { + return Promise.resolve({ + data: null, + error: { message: 'Usage denied' }, + }); + } + if (name === 'billing_demo_get_collections') { + return Promise.resolve({ data: [], error: null }); + } + if (name === 'ensure_billing_subscription') { + return Promise.resolve({ data: null, error: null }); + } + if (name === 'billing_get_remaining_usage') { + return Promise.resolve({ + data: { + used: 0, + limit: 30, + remaining: 30, + periodEnd: '', + periodStart: '', + }, + error: null, + }); + } + return Promise.resolve({ data: null, error: null }); + }); + + const { getByText } = renderWithProviders( + + ); + + await waitFor(() => { + expect(getByText('Simulate AI summarize')).toBeTruthy(); + }); + fireEvent.press(getByText('Simulate AI summarize')); + + await waitFor(() => { + // mapUnknownError mock stringifies non-Error throws + expect(getByText(/\[object Object\]|Usage denied/i)).toBeTruthy(); + }); + }); + + it('uses edge function summary when EXPO_PUBLIC_DEMO_USE_REAL_AI is true', async () => { + process.env.EXPO_PUBLIC_DEMO_USE_REAL_AI = 'true'; + (supabase.functions.invoke as jest.Mock).mockResolvedValue({ + data: { text: ' Edge summary line ' }, + error: null, + }); + + const { getByText } = renderWithProviders( + + ); + + await waitFor(() => { + expect(getByText('Simulate AI summarize')).toBeTruthy(); + }); + fireEvent.press(getByText('Simulate AI summarize')); + + await waitFor(() => { + expect(supabase.functions.invoke).toHaveBeenCalledWith( + 'demo-ai-summarize', + expect.objectContaining({ + body: expect.objectContaining({ + prompt: expect.any(String), + }), + }) + ); + expect(getByText('Edge summary line')).toBeTruthy(); + }); + }); + + it('falls back to fake AI when edge function returns empty text', async () => { + process.env.EXPO_PUBLIC_DEMO_USE_REAL_AI = 'true'; + (supabase.functions.invoke as jest.Mock).mockResolvedValue({ + data: { text: ' ' }, + error: null, + }); + + const { getByText } = renderWithProviders( + + ); + + await waitFor(() => { + expect(getByText('Simulate AI summarize')).toBeTruthy(); + }); + fireEvent.press(getByText('Simulate AI summarize')); + + await waitFor(() => { + expect( + getByText(/Lorem ipsum|Maecenas ligula|Duis semper/i) + ).toBeTruthy(); + }); + }); + + it('renders demo controls and completes plan simulate + reset usage', async () => { + process.env.EXPO_PUBLIC_BILLING_DEMO_MODE = 'true'; + + const { getByText } = renderWithProviders( + + ); + + await waitFor(() => { + expect(getByText('Demo controls')).toBeTruthy(); + }); + + fireEvent.press(getByText('To Pro')); + + await waitFor(() => { + expect(getByText('Done.')).toBeTruthy(); + expect(supabase.rpc).toHaveBeenCalledWith( + 'billing_demo_simulate_upgrade', + expect.objectContaining({ p_plan_id: 'beakerstack_pro' }) + ); + }); + + fireEvent.press(getByText('Reset all usage counters')); + + await waitFor(() => { + expect(supabase.rpc).toHaveBeenCalledWith( + 'billing_demo_reset_usage', + expect.objectContaining({ + p_event_type: expect.anything(), + }) + ); + }); + }); + + it('shows demo control error when simulate upgrade RPC fails', async () => { + process.env.EXPO_PUBLIC_BILLING_DEMO_MODE = 'true'; + (supabase.rpc as jest.Mock).mockImplementation((name: string) => { + if (name === 'billing_demo_simulate_upgrade') { + return Promise.resolve({ + data: null, + error: { message: 'Simulate failed' }, + }); + } + if (name === 'billing_record_usage_event') { + return Promise.resolve({ data: null, error: null }); + } + if (name === 'billing_demo_get_collections') { + return Promise.resolve({ data: [], error: null }); + } + if (name === 'billing_demo_reset_usage') { + return Promise.resolve({ data: null, error: null }); + } + if (name === 'ensure_billing_subscription') { + return Promise.resolve({ data: null, error: null }); + } + if (name === 'billing_get_remaining_usage') { + return Promise.resolve({ + data: { + used: 0, + limit: 30, + remaining: 30, + periodEnd: '', + periodStart: '', + }, + error: null, + }); + } + return Promise.resolve({ data: null, error: null }); + }); + + const { getByText } = renderWithProviders( + + ); + + await waitFor(() => { + expect(getByText('To Max')).toBeTruthy(); + }); + fireEvent.press(getByText('To Max')); + + await waitFor(() => { + // DemoControls `run` catch: non-Error throws become message "Failed" + expect(getByText('Failed')).toBeTruthy(); + }); + }); + + it('shows Failed when addItem throws a non-Error from RPC', async () => { + const billing = jest.requireMock('@beakerstack/billing') as { + useFeature: jest.Mock; + }; + billing.useFeature.mockImplementation((feature: string) => { + if (feature === 'containers_per_account_max') { + return { enabled: true, value: 10, loading: false, error: null }; + } + if (feature === 'items_per_container_max') { + return { enabled: true, value: 10, loading: false, error: null }; + } + return { enabled: true, value: 2, loading: false, error: null }; + }); + + const rows = [{ id: 'rowadd01', item_count: 0 }]; + (supabase.rpc as jest.Mock).mockImplementation((name: string) => { + if (name === 'billing_demo_get_collections') { + return Promise.resolve({ data: [...rows], error: null }); + } + if (name === 'billing_demo_add_item') { + return Promise.resolve({ data: null, error: { message: 'nope' } }); + } + if (name === 'billing_record_usage_event') { + return Promise.resolve({ data: null, error: null }); + } + if (name === 'billing_demo_simulate_upgrade') { + return Promise.resolve({ data: null, error: null }); + } + if (name === 'billing_demo_reset_usage') { + return Promise.resolve({ data: null, error: null }); + } + if (name === 'ensure_billing_subscription') { + return Promise.resolve({ data: null, error: null }); + } + if (name === 'billing_get_remaining_usage') { + return Promise.resolve({ + data: { + used: 0, + limit: 30, + remaining: 30, + periodEnd: '', + periodStart: '', + }, + error: null, + }); + } + return Promise.resolve({ data: null, error: null }); + }); + + const { getByText } = renderWithProviders( + + ); + + await waitFor(() => { + expect(getByText('Add item')).toBeTruthy(); + }); + fireEvent.press(getByText('Add item')); + + await waitFor(() => { + expect(getByText('Failed')).toBeTruthy(); + }); + }); + + it('removes collection after Delete succeeds', async () => { + const rows = [{ id: 'delcol01', item_count: 0 }]; + (supabase.rpc as jest.Mock).mockImplementation((name: string) => { + if (name === 'billing_demo_get_collections') { + return Promise.resolve({ data: [...rows], error: null }); + } + if (name === 'billing_demo_delete_collection') { + rows.pop(); + return Promise.resolve({ data: null, error: null }); + } + if (name === 'billing_record_usage_event') { + return Promise.resolve({ data: null, error: null }); + } + if (name === 'billing_demo_simulate_upgrade') { + return Promise.resolve({ data: null, error: null }); + } + if (name === 'billing_demo_reset_usage') { + return Promise.resolve({ data: null, error: null }); + } + if (name === 'ensure_billing_subscription') { + return Promise.resolve({ data: null, error: null }); + } + if (name === 'billing_get_remaining_usage') { + return Promise.resolve({ + data: { + used: 0, + limit: 30, + remaining: 30, + periodEnd: '', + periodStart: '', + }, + error: null, + }); + } + return Promise.resolve({ data: null, error: null }); + }); + + const { getByText } = renderWithProviders( + + ); + + await waitFor(() => { + expect(getByText('Delete')).toBeTruthy(); + }); + fireEvent.press(getByText('Delete')); + + await waitFor(() => { + expect( + getByText(/No collections yet\. Tap Add collection to start\./) + ).toBeTruthy(); + }); + }); + + it('shows Limit on add item when at item cap', async () => { + const billing = jest.requireMock('@beakerstack/billing') as { + useFeature: jest.Mock; + }; + billing.useFeature.mockImplementation((feature: string) => { + if (feature === 'containers_per_account_max') { + return { enabled: true, value: 10, loading: false, error: null }; + } + if (feature === 'items_per_container_max') { + return { enabled: true, value: 2, loading: false, error: null }; + } + return { enabled: true, value: 2, loading: false, error: null }; + }); + + const rows = [{ id: 'capitems', item_count: 2 }]; + (supabase.rpc as jest.Mock).mockImplementation((name: string) => { + if (name === 'billing_demo_get_collections') { + return Promise.resolve({ data: [...rows], error: null }); + } + if (name === 'billing_record_usage_event') { + return Promise.resolve({ data: null, error: null }); + } + if (name === 'billing_demo_simulate_upgrade') { + return Promise.resolve({ data: null, error: null }); + } + if (name === 'billing_demo_reset_usage') { + return Promise.resolve({ data: null, error: null }); + } + if (name === 'ensure_billing_subscription') { + return Promise.resolve({ data: null, error: null }); + } + if (name === 'billing_get_remaining_usage') { + return Promise.resolve({ + data: { + used: 0, + limit: 30, + remaining: 30, + periodEnd: '', + periodStart: '', + }, + error: null, + }); + } + return Promise.resolve({ data: null, error: null }); + }); + const { getByText } = renderWithProviders( - , - mockClient + ); await waitFor(() => { - expect(getByText(DASHBOARD_TITLE)).toBeTruthy(); - expect(getByText(DASHBOARD_SUBTITLE)).toBeTruthy(); + expect(getByText('Limit')).toBeTruthy(); }); }); }); diff --git a/apps/mobile/__tests__/screens/HomeScreen.test.tsx b/apps/mobile/__tests__/screens/HomeScreen.test.tsx index 885bfa3a..63bb602b 100644 --- a/apps/mobile/__tests__/screens/HomeScreen.test.tsx +++ b/apps/mobile/__tests__/screens/HomeScreen.test.tsx @@ -59,7 +59,7 @@ const mockNavigation = { } as any; import React from 'react'; -import { render, waitFor } from '@testing-library/react-native'; +import { fireEvent, render, waitFor } from '@testing-library/react-native'; import { NavigationContainer } from '@react-navigation/native'; import HomeScreen from '../../src/screens/HomeScreen'; import { AuthProvider } from '@beakerstack/shared/contexts/AuthContext'; @@ -261,6 +261,47 @@ describe('HomeScreen', () => { }); }); + it('navigates to Dashboard when Go To Dashboard is pressed', async () => { + const { getByText } = renderWithAuth( + , + true + ); + + await waitFor(() => { + expect(getByText('Go To Dashboard')).toBeTruthy(); + }); + fireEvent.press(getByText('Go To Dashboard')); + expect(mockNavigate).toHaveBeenCalledWith('Dashboard'); + }); + + it('navigates to Profile when View Profile is pressed', async () => { + const { getByText } = renderWithAuth( + , + true + ); + + await waitFor(() => { + expect(getByText('View Profile')).toBeTruthy(); + }); + fireEvent.press(getByText('View Profile')); + expect(mockNavigate).toHaveBeenCalledWith('Profile'); + }); + + it('navigates to Login and Signup from signed-out buttons', () => { + const { getAllByText } = renderWithAuth( + , + false + ); + + const signIns = getAllByText('Sign In'); + fireEvent.press(signIns[signIns.length - 1]); + expect(mockNavigate).toHaveBeenCalledWith('Login'); + + const signUps = getAllByText('Sign Up'); + fireEvent.press(signUps[signUps.length - 1]); + expect(mockNavigate).toHaveBeenCalledWith('Signup'); + }); + // Note: Debug tools (database test, auth context test) are now in DebugTools component // which is hidden by default and activated via 4 clicks in bottom left corner. // These tests have been removed as the debug components are no longer directly visible. diff --git a/apps/mobile/__tests__/screens/LoginScreen.test.tsx b/apps/mobile/__tests__/screens/LoginScreen.test.tsx index 02d46007..e65f00d5 100644 --- a/apps/mobile/__tests__/screens/LoginScreen.test.tsx +++ b/apps/mobile/__tests__/screens/LoginScreen.test.tsx @@ -5,7 +5,6 @@ import LoginScreen from '../../src/screens/LoginScreen'; import { AuthProvider } from '@beakerstack/shared/contexts/AuthContext'; import { ProfileProvider } from '@beakerstack/shared/contexts/ProfileContext'; import type { SupabaseClient } from '@supabase/supabase-js'; - // Mock expo-constants jest.mock('expo-constants', () => ({ default: { @@ -13,6 +12,9 @@ jest.mock('expo-constants', () => ({ extra: { supabaseUrl: 'http://localhost:54321', supabaseAnonKey: 'test-anon-key', + googleWebClientId: 'test-web-client-id', + googleIosClientId: 'test-ios-client-id', + googleAndroidClientId: 'test-android-client-id', }, }, }, @@ -29,11 +31,17 @@ jest.mock('@beakerstack/shared/components/navigation/AppHeader.native', () => ({ })); // Mock SocialLoginButton -jest.mock('../../src/components/SocialLoginButton', () => ({ - SocialLoginButton: ({ onPress }: { onPress: () => void }) => ( - - ), -})); +jest.mock('../../src/components/SocialLoginButton', () => { + const React = require('react'); + const { Pressable, Text } = require('react-native'); + return { + SocialLoginButton: ({ onPress }: { onPress: () => void }) => ( + + Google Sign In + + ), + }; +}); // Mock featureFlags jest.mock('../../src/config/featureFlags', () => ({ @@ -209,48 +217,24 @@ describe('LoginScreen', () => { }); }); - it.skip('handles Google login', async () => { - // Skip - SocialLoginButton mock needs proper React Native component rendering + it('shows Google Sign In Failed alert when Google sign-in errors', async () => { const mockClient = createMockSupabaseClient(); - (mockClient.auth.signInWithIdToken as jest.Mock).mockResolvedValue({ - data: { user: null, session: null }, - error: null, - }); - const { getByText } = renderWithProviders( , mockClient ); - // Find Google Sign In button (mocked as regular button) - const googleButton = getByText('Google Sign In'); - fireEvent.press(googleButton); + fireEvent.press(getByText('Google Sign In')); await waitFor(() => { - expect(mockClient.auth.signInWithIdToken).toHaveBeenCalled(); - }); - }); - - it.skip('handles Google login error', async () => { - // Skip - SocialLoginButton mock needs proper React Native component rendering - const mockClient = createMockSupabaseClient(); - (mockClient.auth.signInWithIdToken as jest.Mock).mockRejectedValue( - new Error('Google sign in failed') - ); - - const { getByText } = renderWithProviders( - , - mockClient - ); - - const googleButton = getByText('Google Sign In'); - fireEvent.press(googleButton); - - await waitFor(() => { - expect(Alert.alert).toHaveBeenCalledWith( - 'Google Sign In Failed', - 'Google sign in failed' - ); + expect(Alert.alert).toHaveBeenCalled(); + const [title, message] = (Alert.alert as jest.Mock).mock.calls[0] as [ + string, + string, + ]; + expect(title).toBe('Google Sign In Failed'); + expect(typeof message).toBe('string'); + expect(message.length).toBeGreaterThan(0); }); }); diff --git a/apps/mobile/__tests__/screens/ProfileScreen.test.tsx b/apps/mobile/__tests__/screens/ProfileScreen.test.tsx index 673ea8de..bfbdc5f5 100644 --- a/apps/mobile/__tests__/screens/ProfileScreen.test.tsx +++ b/apps/mobile/__tests__/screens/ProfileScreen.test.tsx @@ -121,11 +121,11 @@ jest.mock('@beakerstack/shared/components/profile/ProfileStats.native', () => ({ jest.mock( '@beakerstack/shared/components/profile/ProfileEditor.native', () => ({ - ProfileEditor: ({ user }: any) => { + ProfileEditor: () => { const { View, Text } = require('react-native'); return ( - {user ? 'Editor' : 'No user'} + Editor ); }, @@ -287,9 +287,8 @@ describe('ProfileScreen', () => { }); }); - it.skip('shows profile editor when edit button is pressed', async () => { - // Skip - dynamic import of ProfileEditor is complex to test - const { getByText, getByTestId } = renderWithAuth( + it('shows loading editor state after Edit Profile is pressed', async () => { + const { getByText, queryByText } = renderWithAuth( ); @@ -297,14 +296,12 @@ describe('ProfileScreen', () => { expect(getByText('Edit Profile')).toBeTruthy(); }); - const editButton = getByText('Edit Profile'); - fireEvent.press(editButton); + fireEvent.press(getByText('Edit Profile')); - await waitFor( - () => { - expect(getByTestId('profile-editor')).toBeTruthy(); - }, - { timeout: 3000 } - ); + await waitFor(() => { + const loading = queryByText('Loading editor...'); + const editor = queryByText('Editor'); + expect(loading || editor).toBeTruthy(); + }); }); }); diff --git a/apps/mobile/__tests__/screens/SignupScreen.test.tsx b/apps/mobile/__tests__/screens/SignupScreen.test.tsx index 63802709..2d4a37a6 100644 --- a/apps/mobile/__tests__/screens/SignupScreen.test.tsx +++ b/apps/mobile/__tests__/screens/SignupScreen.test.tsx @@ -5,7 +5,6 @@ import SignupScreen from '../../src/screens/SignupScreen'; import { AuthProvider } from '@beakerstack/shared/contexts/AuthContext'; import { ProfileProvider } from '@beakerstack/shared/contexts/ProfileContext'; import type { SupabaseClient } from '@supabase/supabase-js'; - // Mock expo-constants jest.mock('expo-constants', () => ({ default: { @@ -13,6 +12,9 @@ jest.mock('expo-constants', () => ({ extra: { supabaseUrl: 'http://localhost:54321', supabaseAnonKey: 'test-anon-key', + googleWebClientId: 'test-web-client-id', + googleIosClientId: 'test-ios-client-id', + googleAndroidClientId: 'test-android-client-id', }, }, }, @@ -28,12 +30,17 @@ jest.mock('@beakerstack/shared/components/navigation/AppHeader.native', () => ({ AppHeader: () => null, })); -// Mock SocialLoginButton -jest.mock('../../src/components/SocialLoginButton', () => ({ - SocialLoginButton: ({ onPress }: { onPress: () => void }) => ( - - ), -})); +jest.mock('../../src/components/SocialLoginButton', () => { + const React = require('react'); + const { Pressable, Text } = require('react-native'); + return { + SocialLoginButton: ({ onPress }: { onPress: () => void }) => ( + + Google Sign Up + + ), + }; +}); // Mock featureFlags jest.mock('../../src/config/featureFlags', () => ({ @@ -254,47 +261,24 @@ describe('SignupScreen', () => { }); }); - it.skip('handles Google signup', async () => { - // Skip - SocialLoginButton mock needs proper React Native component rendering + it('shows Google Sign Up Failed alert when Google sign-up errors', async () => { const mockClient = createMockSupabaseClient(); - (mockClient.auth.signInWithIdToken as jest.Mock).mockResolvedValue({ - data: { user: null, session: null }, - error: null, - }); - const { getByText } = renderWithProviders( , mockClient ); - const googleButton = getByText('Google Sign Up'); - fireEvent.press(googleButton); + fireEvent.press(getByText('Google Sign Up')); await waitFor(() => { - expect(mockClient.auth.signInWithIdToken).toHaveBeenCalled(); - }); - }); - - it.skip('handles Google signup error', async () => { - // Skip - SocialLoginButton mock needs proper React Native component rendering - const mockClient = createMockSupabaseClient(); - (mockClient.auth.signInWithIdToken as jest.Mock).mockRejectedValue( - new Error('Google sign up failed') - ); - - const { getByText } = renderWithProviders( - , - mockClient - ); - - const googleButton = getByText('Google Sign Up'); - fireEvent.press(googleButton); - - await waitFor(() => { - expect(Alert.alert).toHaveBeenCalledWith( - 'Google Sign Up Failed', - 'Google sign up failed' - ); + expect(Alert.alert).toHaveBeenCalled(); + const [title, message] = (Alert.alert as jest.Mock).mock.calls[0] as [ + string, + string, + ]; + expect(title).toBe('Google Sign Up Failed'); + expect(typeof message).toBe('string'); + expect(message.length).toBeGreaterThan(0); }); }); diff --git a/apps/mobile/jest/expo-virtual-env-mock.cjs b/apps/mobile/jest/expo-virtual-env-mock.cjs new file mode 100644 index 00000000..d4c951cf --- /dev/null +++ b/apps/mobile/jest/expo-virtual-env-mock.cjs @@ -0,0 +1,14 @@ +/** + * Jest stub for Metro’s `expo/virtual/env` alias (`shims/expo-virtual-env.js`). + * The Expo Babel plugin rewrites EXPO_PUBLIC_* reads; it may use default and/or + * named exports off this module. + */ +'use strict'; + +const base = (typeof process !== 'undefined' && process.env) || {}; +const d = Object.assign({}, base); + +module.exports = Object.assign( + { __esModule: true, default: d }, + d +); diff --git a/apps/mobile/metro.config.js b/apps/mobile/metro.config.js index 83a9b30d..3b393ce5 100644 --- a/apps/mobile/metro.config.js +++ b/apps/mobile/metro.config.js @@ -1,10 +1,12 @@ const { getDefaultConfig } = require('expo/metro-config'); const exclusionList = require('metro-config/src/defaults/exclusionList'); +const fs = require('fs'); const path = require('path'); const projectRoot = __dirname; const workspaceRoot = path.resolve(projectRoot, '../..'); const sharedPkg = path.resolve(projectRoot, '../../packages/shared'); +const billingPkg = path.resolve(projectRoot, '../../packages/billing'); const config = getDefaultConfig(projectRoot); @@ -23,7 +25,7 @@ config.resolver.sourceExts = [ ]; // Only watch what we need -config.watchFolders = [sharedPkg]; +config.watchFolders = [sharedPkg, billingPkg]; // Resolve node_modules (mobile first, then root) config.resolver.nodeModulesPaths = [ @@ -62,6 +64,30 @@ config.resolver.resolveRequest = (context, moduleName, platform) => { }; } + // TypeScript ESM style: `from './foo.js'` in source; Metro looks for a real .js file. + // Map to .ts / .tsx (and platform .native.*) when present (e.g. packages/billing). + if ( + typeof moduleName === 'string' && + moduleName.startsWith('.') && + moduleName.endsWith('.js') && + context.originModulePath + ) { + const originDir = path.dirname(context.originModulePath); + const stem = moduleName.replace(/\.js$/, ''); + const relativeCandidates = [ + `${stem}.ts`, + `${stem}.tsx`, + `${stem}.native.ts`, + `${stem}.native.tsx`, + ]; + for (const rel of relativeCandidates) { + const candidate = path.normalize(path.join(originDir, rel)); + if (fs.existsSync(candidate)) { + return { filePath: candidate, type: 'sourceFile' }; + } + } + } + // Fall back to default Expo resolver for everything else if (defaultResolver) { return defaultResolver(context, moduleName, platform); diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 6e00cac6..9f17a7fb 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -47,6 +47,7 @@ "install:preview:android:local": "sh -c 'LATEST_BUILD=$(ls -t build-*.apk 2>/dev/null | head -1); if [ -z \"$LATEST_BUILD\" ]; then echo \"❌ No local build found. Run: npm run build:preview:android:local\"; exit 1; fi; ./install-local-device-android.sh \"$LATEST_BUILD\"'" }, "dependencies": { + "@beakerstack/billing": "^1.0.0", "@react-native-async-storage/async-storage": "1.21.0", "@react-native-google-signin/google-signin": "13.3.1", "@react-navigation/native": "^6.1.18", @@ -96,7 +97,8 @@ "testEnvironment": "node", "moduleNameMapper": { "^@beakerstack/shared/(.*)$": "/../../packages/shared/src/$1", - "^react-native-svg$": "/__mocks__/react-native-svg.ts" + "^react-native-svg$": "/__mocks__/react-native-svg.ts", + "^expo/virtual/env$": "/jest/expo-virtual-env-mock.cjs" }, "collectCoverageFrom": [ "src/**/*.{ts,tsx}", diff --git a/apps/mobile/src/billing/__tests__/useDemoCollections.test.ts b/apps/mobile/src/billing/__tests__/useDemoCollections.test.ts new file mode 100644 index 00000000..7dc396f9 --- /dev/null +++ b/apps/mobile/src/billing/__tests__/useDemoCollections.test.ts @@ -0,0 +1,235 @@ +import { describe, it, expect, beforeEach, jest } from '@jest/globals'; +import { act, renderHook, waitFor } from '@testing-library/react-native'; +import { useDemoCollections } from '../useDemoCollections'; + +type RpcReturn = { data: unknown; error: unknown }; + +const mockRpc = jest.fn<(...args: unknown[]) => Promise>(); + +jest.mock('../../lib/supabase', () => ({ + supabase: { rpc: (...args: unknown[]) => mockRpc(...args) }, +})); + +describe('useDemoCollections', () => { + beforeEach(() => { + mockRpc.mockReset(); + }); + + it('treats null data as empty collections when no error', async () => { + mockRpc.mockResolvedValue({ data: null, error: null }); + const { result } = renderHook(() => useDemoCollections()); + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + expect(result.current.error).toBeNull(); + expect(result.current.collections).toEqual([]); + }); + + it('maps RPC rows and clears error on success', async () => { + mockRpc.mockResolvedValue({ + data: [ + { id: 'c1', item_count: 2 }, + { id: 99, item_count: null }, + ], + error: null, + }); + const { result } = renderHook(() => useDemoCollections()); + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + expect(result.current.error).toBeNull(); + expect(result.current.collections).toEqual([ + { id: 'c1', item_count: 2 }, + { id: '99', item_count: 0 }, + ]); + }); + + it('sets error and empty collections when RPC returns error', async () => { + mockRpc.mockResolvedValue({ data: null, error: { message: 'rpc failed' } }); + const { result } = renderHook(() => useDemoCollections()); + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + expect(result.current.error).toBe('Could not load demo collections.'); + expect(result.current.collections).toEqual([]); + }); + + it('uses Error message when throw is Error', async () => { + mockRpc.mockRejectedValue(new Error('network down')); + const { result } = renderHook(() => useDemoCollections()); + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + expect(result.current.error).toBe('network down'); + expect(result.current.collections).toEqual([]); + }); + + it('sets generic error when throw is non-Error', async () => { + mockRpc.mockRejectedValue('boom'); + const { result } = renderHook(() => useDemoCollections()); + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + expect(result.current.error).toBe('Could not load demo collections.'); + expect(result.current.collections).toEqual([]); + }); + + it('refresh refetches after initial load', async () => { + mockRpc + .mockResolvedValueOnce({ + data: [{ id: 'a', item_count: 1 }], + error: null, + }) + .mockResolvedValueOnce({ + data: [ + { id: 'a', item_count: 1 }, + { id: 'b', item_count: 5 }, + ], + error: null, + }); + const { result } = renderHook(() => useDemoCollections()); + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + expect(result.current.collections).toHaveLength(1); + await act(async () => { + await result.current.refresh(); + }); + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + expect(result.current.collections).toHaveLength(2); + }); + + it('addCollection calls RPC then refetches', async () => { + mockRpc + .mockResolvedValueOnce({ data: [], error: null }) + .mockResolvedValueOnce({ data: null, error: null }) + .mockResolvedValueOnce({ + data: [{ id: 'new', item_count: 0 }], + error: null, + }); + const { result } = renderHook(() => useDemoCollections()); + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + await act(async () => { + await result.current.addCollection(); + }); + await waitFor(() => { + expect(result.current.collections.some(c => c.id === 'new')).toBe(true); + }); + expect(mockRpc).toHaveBeenCalledWith( + 'billing_demo_add_collection', + expect.objectContaining({ p_product_id: 'beakerstack' }) + ); + }); + + it('deleteCollection calls RPC then refetches', async () => { + mockRpc + .mockResolvedValueOnce({ + data: [{ id: 'x', item_count: 1 }], + error: null, + }) + .mockResolvedValueOnce({ data: null, error: null }) + .mockResolvedValueOnce({ data: [], error: null }); + const { result } = renderHook(() => useDemoCollections()); + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + await act(async () => { + await result.current.deleteCollection('x'); + }); + await waitFor(() => { + expect(result.current.collections).toEqual([]); + }); + expect(mockRpc).toHaveBeenCalledWith( + 'billing_demo_delete_collection', + expect.objectContaining({ + p_product_id: 'beakerstack', + p_collection_id: 'x', + }) + ); + }); + + it('addItem calls RPC then refetches', async () => { + mockRpc + .mockResolvedValueOnce({ + data: [{ id: 'col', item_count: 0 }], + error: null, + }) + .mockResolvedValueOnce({ data: null, error: null }) + .mockResolvedValueOnce({ + data: [{ id: 'col', item_count: 1 }], + error: null, + }); + const { result } = renderHook(() => useDemoCollections()); + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + await act(async () => { + await result.current.addItem('col'); + }); + await waitFor(() => { + expect(result.current.collections[0]?.item_count).toBe(1); + }); + expect(mockRpc).toHaveBeenCalledWith( + 'billing_demo_add_item', + expect.objectContaining({ + p_product_id: 'beakerstack', + p_collection_id: 'col', + }) + ); + }); + + it('propagates RPC error from addCollection', async () => { + mockRpc.mockResolvedValueOnce({ data: [], error: null }); + mockRpc.mockResolvedValueOnce({ + data: null, + error: { message: 'add denied' }, + }); + const { result } = renderHook(() => useDemoCollections()); + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + await expect(result.current.addCollection()).rejects.toEqual({ + message: 'add denied', + }); + }); + + it('propagates RPC error from deleteCollection', async () => { + mockRpc.mockResolvedValueOnce({ + data: [{ id: 'x', item_count: 1 }], + error: null, + }); + mockRpc.mockResolvedValueOnce({ + data: null, + error: { message: 'delete denied' }, + }); + const { result } = renderHook(() => useDemoCollections()); + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + await expect(result.current.deleteCollection('x')).rejects.toEqual({ + message: 'delete denied', + }); + }); + + it('propagates RPC error from addItem', async () => { + mockRpc.mockResolvedValueOnce({ + data: [{ id: 'col', item_count: 0 }], + error: null, + }); + mockRpc.mockResolvedValueOnce({ + data: null, + error: { message: 'item denied' }, + }); + const { result } = renderHook(() => useDemoCollections()); + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + await expect(result.current.addItem('col')).rejects.toEqual({ + message: 'item denied', + }); + }); +}); diff --git a/apps/mobile/src/billing/beakerstackBillingConfig.ts b/apps/mobile/src/billing/beakerstackBillingConfig.ts new file mode 100644 index 00000000..0b24a221 --- /dev/null +++ b/apps/mobile/src/billing/beakerstackBillingConfig.ts @@ -0,0 +1,94 @@ +import { + defineBillingConfig, + type InferFeatureKeys, +} from '@beakerstack/billing'; + +/** + * Template-owned billing config (Free / Pro / Max). IDs match `supabase/seed.sql`. + * Downstream clones rename keys and copy here — do not embed in `@beakerstack/billing`. + * Stripe price IDs live in `public.billing_plans` (synced via `npm run billing:sync-stripe` from the web app config). + */ +export const beakerstackBillingConfig = defineBillingConfig({ + productId: 'beakerstack', + displayName: 'BeakerStack', + description: 'Template demo', + plans: [ + { + id: 'beakerstack_free', + displayName: 'Free', + description: 'Starter', + planCardTagline: 'For getting started', + priceCents: 0, + billingPeriod: 'free', + stripePriceIdMonthly: null, + stripePriceIdAnnual: null, + stripeProductId: null, + features: { + containers_per_account_max: 2, + items_per_container_max: 3, + feature_a: false, + feature_b: false, + }, + usageLimits: { + ai_summarize: 30, + }, + trialPeriodDays: 0, + isPublic: true, + displayOrder: 1, + }, + { + id: 'beakerstack_pro', + displayName: 'Pro', + description: 'More capacity', + planCardTagline: 'For active users', + priceCents: 1900, + billingPeriod: 'monthly', + stripePriceIdMonthly: null, + stripePriceIdAnnual: null, + stripeProductId: null, + features: { + containers_per_account_max: -1, + items_per_container_max: 25, + feature_a: true, + feature_b: false, + }, + usageLimits: { + ai_summarize: 500, + }, + trialPeriodDays: 0, + isPublic: true, + displayOrder: 2, + }, + { + id: 'beakerstack_max', + displayName: 'Max', + description: 'Everything', + planCardTagline: 'For power users', + priceCents: 4900, + billingPeriod: 'monthly', + stripePriceIdMonthly: null, + stripePriceIdAnnual: null, + stripeProductId: null, + features: { + containers_per_account_max: -1, + items_per_container_max: -1, + feature_a: true, + feature_b: true, + }, + usageLimits: { + ai_summarize: -1, + }, + trialPeriodDays: 5, + isPublic: true, + displayOrder: 3, + }, + ], +}); + +export type BeakerstackBillingConfig = typeof beakerstackBillingConfig; + +export const BEAKERSTACK_METER_AI_SUMMARIZE = 'ai_summarize' as const; + +export type BeakerstackFeatureKey = InferFeatureKeys< + typeof beakerstackBillingConfig +>; diff --git a/apps/mobile/src/billing/useDemoCollections.ts b/apps/mobile/src/billing/useDemoCollections.ts new file mode 100644 index 00000000..b4b9438a --- /dev/null +++ b/apps/mobile/src/billing/useDemoCollections.ts @@ -0,0 +1,91 @@ +import { useCallback, useEffect, useState } from 'react'; +import { supabase } from '../lib/supabase'; + +export type DemoCollectionRow = { + id: string; + item_count: number; +}; + +const PRODUCT_ID = 'beakerstack'; + +export function useDemoCollections() { + const [collections, setCollections] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchCollections = useCallback(async () => { + setLoading(true); + setError(null); + try { + const { data, error: rpcErr } = await supabase.rpc( + 'billing_demo_get_collections', + { p_product_id: PRODUCT_ID } + ); + if (rpcErr) throw rpcErr; + const rows = (data as { id: string; item_count: number }[] | null) ?? []; + setCollections( + rows.map(r => ({ + id: String(r.id), + item_count: Number(r.item_count ?? 0), + })) + ); + } catch (e) { + const msg = + e instanceof Error ? e.message : 'Could not load demo collections.'; + setError(msg); + setCollections([]); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void fetchCollections(); + }, [fetchCollections]); + + const addCollection = useCallback(async () => { + const { error: rpcErr } = await supabase.rpc( + 'billing_demo_add_collection', + { p_product_id: PRODUCT_ID } + ); + if (rpcErr) throw rpcErr; + await fetchCollections(); + }, [fetchCollections]); + + const deleteCollection = useCallback( + async (collectionId: string) => { + const { error: rpcErr } = await supabase.rpc( + 'billing_demo_delete_collection', + { + p_product_id: PRODUCT_ID, + p_collection_id: collectionId, + } + ); + if (rpcErr) throw rpcErr; + await fetchCollections(); + }, + [fetchCollections] + ); + + const addItem = useCallback( + async (collectionId: string) => { + const { error: rpcErr } = await supabase.rpc('billing_demo_add_item', { + p_product_id: PRODUCT_ID, + p_collection_id: collectionId, + }); + if (rpcErr) throw rpcErr; + await fetchCollections(); + }, + [fetchCollections] + ); + + return { + collections, + loading, + error, + refresh: fetchCollections, + addCollection, + deleteCollection, + addItem, + }; +} diff --git a/apps/mobile/src/lib/__tests__/fakeAi.test.ts b/apps/mobile/src/lib/__tests__/fakeAi.test.ts new file mode 100644 index 00000000..3f03ab67 --- /dev/null +++ b/apps/mobile/src/lib/__tests__/fakeAi.test.ts @@ -0,0 +1,39 @@ +import { describe, it, expect, beforeEach, jest } from '@jest/globals'; + +describe('nextFakeAiSummary', () => { + beforeEach(() => { + jest.resetModules(); + }); + + it('returns snippets in rotation and wraps after three calls', () => { + const { nextFakeAiSummary } = require('../fakeAi') as { + nextFakeAiSummary: () => string; + }; + + const first = nextFakeAiSummary(); + const second = nextFakeAiSummary(); + const third = nextFakeAiSummary(); + const fourth = nextFakeAiSummary(); + + expect(first.startsWith('Lorem ipsum')).toBe(true); + expect(second.startsWith('Maecenas ligula')).toBe(true); + expect(third.startsWith('Duis semper')).toBe(true); + expect(fourth).toBe(first); + }); + + it('starts from the beginning after module reset', () => { + const { nextFakeAiSummary: a } = require('../fakeAi') as { + nextFakeAiSummary: () => string; + }; + const one = a(); + + jest.resetModules(); + const { nextFakeAiSummary: b } = require('../fakeAi') as { + nextFakeAiSummary: () => string; + }; + const afterReset = b(); + + expect(one.startsWith('Lorem ipsum')).toBe(true); + expect(afterReset.startsWith('Lorem ipsum')).toBe(true); + }); +}); diff --git a/apps/mobile/src/lib/__tests__/supabase.test.ts b/apps/mobile/src/lib/__tests__/supabase.test.ts index cee657ad..2f3e6fed 100644 --- a/apps/mobile/src/lib/__tests__/supabase.test.ts +++ b/apps/mobile/src/lib/__tests__/supabase.test.ts @@ -1,6 +1,15 @@ import { describe, it, expect, beforeEach, jest } from '@jest/globals'; import { Platform } from 'react-native'; +// Mutable Expo extra so tests can vary URL/key after resetModules. +const expoExtraState: { + supabaseUrl?: string; + supabaseAnonKey?: string; +} = { + supabaseUrl: 'http://localhost:54321', + supabaseAnonKey: 'test-anon-key', +}; + // Mock react-native-url-polyfill jest.mock('react-native-url-polyfill/auto', () => ({})); @@ -45,16 +54,14 @@ jest.mock('@beakerstack/shared/utils/logger', () => ({ }, })); -// Mock expo-constants with default values jest.mock('expo-constants', () => { return { __esModule: true, default: { - expoConfig: { - extra: { - supabaseUrl: 'http://localhost:54321', - supabaseAnonKey: 'test-anon-key', - }, + get expoConfig() { + return { + extra: { ...expoExtraState }, + }; }, }, }; @@ -62,17 +69,20 @@ jest.mock('expo-constants', () => { describe('supabase.ts', () => { beforeEach(() => { + jest.resetModules(); jest.clearAllMocks(); + mockCreateClient.mockReturnValue(mockSupabaseClient); mockCreateClient.mockClear(); mockLoggerDebug.mockClear(); mockLoggerInfo.mockClear(); - }); - - it('should create supabase client with correct configuration', () => { + expoExtraState.supabaseUrl = 'http://localhost:54321'; + expoExtraState.supabaseAnonKey = 'test-anon-key'; // eslint-disable-next-line @typescript-eslint/no-explicit-any (global as any).__DEV__ = false; Platform.OS = 'ios'; + }); + it('should create supabase client with correct configuration', () => { const { supabase } = require('../supabase'); expect(mockCreateClient).toHaveBeenCalledWith( @@ -91,9 +101,80 @@ describe('supabase.ts', () => { }); it('should export supabase client', () => { + require('../supabase'); const module = require('../supabase'); expect(module).toHaveProperty('supabase'); expect(module.supabase).toBeDefined(); }); + + it('rewrites 127.0.0.1 to 10.0.2.2 on Android in __DEV__', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (global as any).__DEV__ = true; + Platform.OS = 'android'; + expoExtraState.supabaseUrl = 'http://127.0.0.1:54321'; + expoExtraState.supabaseAnonKey = 'test-anon-key'; + + require('../supabase'); + + expect(mockCreateClient).toHaveBeenCalledWith( + 'http://10.0.2.2:54321', + 'test-anon-key', + expect.objectContaining({ + auth: expect.any(Object), + }) + ); + }); + + it('does not rewrite host when not on Android', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (global as any).__DEV__ = true; + Platform.OS = 'ios'; + expoExtraState.supabaseUrl = 'http://127.0.0.1:54321'; + expoExtraState.supabaseAnonKey = 'test-anon-key'; + + require('../supabase'); + + expect(mockCreateClient).toHaveBeenCalledWith( + 'http://127.0.0.1:54321', + 'test-anon-key', + expect.anything() + ); + }); + + it('throws when supabase URL is missing', () => { + delete expoExtraState.supabaseUrl; + expoExtraState.supabaseAnonKey = 'test-anon-key'; + + expect(() => require('../supabase')).toThrow( + 'Missing EXPO_PUBLIC_SUPABASE_URL environment variable' + ); + expect(mockCreateClient).not.toHaveBeenCalled(); + }); + + it('throws when supabase anon key is missing', () => { + expoExtraState.supabaseUrl = 'http://localhost:54321'; + delete expoExtraState.supabaseAnonKey; + + expect(() => require('../supabase')).toThrow( + 'Missing EXPO_PUBLIC_SUPABASE_ANON_KEY environment variable' + ); + expect(mockCreateClient).not.toHaveBeenCalled(); + }); + + it('logs websocket URL derived from Supabase URL', () => { + expoExtraState.supabaseUrl = 'http://localhost:54321'; + expoExtraState.supabaseAnonKey = 'test-anon-key'; + + require('../supabase'); + + expect(mockLoggerInfo).toHaveBeenCalledWith( + '[mobile.supabase] Using Supabase URL:', + 'http://localhost:54321' + ); + expect(mockLoggerInfo).toHaveBeenCalledWith( + '[mobile.supabase] Realtime websocket URL:', + 'ws://localhost:54321/realtime/v1/websocket' + ); + }); }); diff --git a/apps/mobile/src/lib/fakeAi.ts b/apps/mobile/src/lib/fakeAi.ts new file mode 100644 index 00000000..28deee42 --- /dev/null +++ b/apps/mobile/src/lib/fakeAi.ts @@ -0,0 +1,13 @@ +const SNIPPETS: string[] = [ + `Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed non risus. Suspendisse lectus tortor, dignissim sit amet, adipiscing nec, ultricies sed, dolor.`, + `Maecenas ligula massa, varius a, semper congue, euismod non, mi. Proin porttitor, orci nec nonummy molestie, enim est eleifend mi.`, + `Duis semper. Duis arcu massa, scelerisque vitae, consequat in, pretium a, enim. Pellentesque congue.`, +]; + +let rotateIndex = 0; + +export function nextFakeAiSummary(): string { + const text = SNIPPETS[rotateIndex % SNIPPETS.length]; + rotateIndex += 1; + return text; +} diff --git a/apps/mobile/src/navigation/AppNavigator.tsx b/apps/mobile/src/navigation/AppNavigator.tsx index 89a9a4e6..7986de43 100644 --- a/apps/mobile/src/navigation/AppNavigator.tsx +++ b/apps/mobile/src/navigation/AppNavigator.tsx @@ -9,6 +9,7 @@ import LoginScreen from '../screens/LoginScreen'; import SignupScreen from '../screens/SignupScreen'; import DashboardScreen from '../screens/DashboardScreen'; import ProfileScreen from '../screens/ProfileScreen'; +import BillingScreen from '../screens/BillingScreen'; import { useFeatureFlags } from '../config/featureFlags'; type RootStackParamList = { @@ -17,6 +18,7 @@ type RootStackParamList = { Signup: undefined; Dashboard: undefined; Profile: undefined; + Billing: undefined; }; const Stack = createNativeStackNavigator(); @@ -53,6 +55,11 @@ export const AppNavigator = () => { + ); diff --git a/apps/mobile/src/screens/BillingScreen.tsx b/apps/mobile/src/screens/BillingScreen.tsx new file mode 100644 index 00000000..7cda1a68 --- /dev/null +++ b/apps/mobile/src/screens/BillingScreen.tsx @@ -0,0 +1,203 @@ +import { + BillingProvider, + useRecordUsage, + useUsage, +} from '@beakerstack/billing'; +import { + CustomerPortalLink, + FeatureGate, + PricingTable, + SubscriptionStatus, + UpgradePrompt, + UsageIndicator, +} from '@beakerstack/billing/native'; +import { useNavigation } from '@react-navigation/native'; +import React, { useState, type ReactElement } from 'react'; +import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { + beakerstackBillingConfig, + BEAKERSTACK_METER_AI_SUMMARIZE, +} from '../billing/beakerstackBillingConfig'; +import { supabase } from '../lib/supabase'; + +function readBillingScreenDemoMode(): boolean { + return process.env?.EXPO_PUBLIC_BILLING_DEMO_MODE === 'true'; +} + +/** Set EXPO_PUBLIC_BILLING_DEMO_BASE_URL to your dev machine URL for Stripe return URLs on device. */ +const billingBaseUrl = + (typeof process !== 'undefined' && + process.env?.EXPO_PUBLIC_BILLING_DEMO_BASE_URL) || + 'http://127.0.0.1:8081'; + +function MeteredBlock(): ReactElement { + const { exceeded, refresh } = useUsage< + typeof beakerstackBillingConfig, + typeof BEAKERSTACK_METER_AI_SUMMARIZE + >(BEAKERSTACK_METER_AI_SUMMARIZE); + const { record, pending } = useRecordUsage< + typeof beakerstackBillingConfig, + typeof BEAKERSTACK_METER_AI_SUMMARIZE + >(BEAKERSTACK_METER_AI_SUMMARIZE); + return ( + + AI summarize (metered) + + meter={BEAKERSTACK_METER_AI_SUMMARIZE} + variant='text' + /> + {exceeded ? ( + + targetTier='beakerstack_pro' + reason='Monthly limit reached.' + /> + ) : ( + void record(1)} + > + + {pending ? '…' : 'Use one summarize'} + + + )} + void refresh()}> + Refresh usage + + + ); +} + +function DemoRpcButtons(): ReactElement { + const [msg, setMsg] = useState(null); + if (!readBillingScreenDemoMode()) { + return ( + + Set EXPO_PUBLIC_BILLING_DEMO_MODE=true and DB demo_billing_mode for + simulate controls. + + ); + } + const sim = async (planId: string) => { + const { error } = await supabase.rpc('billing_demo_simulate_upgrade', { + p_product_id: 'beakerstack', + p_plan_id: planId, + }); + setMsg(error ? error.message : `Simulated ${planId}`); + }; + return ( + + Demo mode — not real billing + void sim('beakerstack_pro')} + > + Simulate Pro + + {msg ? {msg} : null} + + ); +} + +export default function BillingScreen(): ReactElement { + const navigation = useNavigation(); + return ( + + + navigation.goBack()} + style={{ marginBottom: 8 }} + > + ← Back + + Billing + + For the full /billing experience (tabs, plans, invoices), use the web + app. This screen reuses the billing package for dev testing. + + + supabase={supabase} + config={beakerstackBillingConfig} + checkoutSuccessUrl={`${billingBaseUrl}/billing`} + checkoutCancelUrl={`${billingBaseUrl}/billing`} + portalReturnUrl={`${billingBaseUrl}/billing`} + > + + + Subscription + /> + + style={{ marginTop: 8 }} + > + Customer portal + + highlightCurrent /> + + + + Feature B (Max) + + feature='feature_b' + fallback={ + + targetTier='beakerstack_max' + reason='Feature B requires Max.' + /> + } + > + Feature B enabled + + + + + + ); +} + +const styles = StyleSheet.create({ + safe: { flex: 1, backgroundColor: '#f9fafb' }, + scroll: { padding: 16, paddingBottom: 32 }, + h1: { fontSize: 22, fontWeight: '700', marginBottom: 8 }, + subtitle: { + fontSize: 13, + color: '#6b7280', + marginBottom: 12, + lineHeight: 18, + }, + h2: { fontSize: 18, fontWeight: '600', marginBottom: 8 }, + section: { + marginBottom: 16, + padding: 12, + backgroundColor: '#fff', + borderRadius: 8, + borderWidth: 1, + borderColor: '#e5e7eb', + }, + btn: { + backgroundColor: '#4f46e5', + padding: 10, + borderRadius: 6, + marginTop: 8, + alignSelf: 'flex-start', + }, + btnText: { color: '#fff' }, + link: { color: '#4f46e5', marginTop: 8 }, + muted: { color: '#6b7280', fontSize: 12, marginTop: 6 }, + ok: { color: '#15803d' }, + demoBox: { + backgroundColor: '#fef3c7', + padding: 8, + borderRadius: 6, + marginBottom: 12, + }, + warn: { fontWeight: '600', marginBottom: 6 }, + smallBtn: { + alignSelf: 'flex-start', + padding: 6, + borderWidth: 1, + borderColor: '#d97706', + borderRadius: 4, + }, +}); diff --git a/apps/mobile/src/screens/DashboardScreen.tsx b/apps/mobile/src/screens/DashboardScreen.tsx index fc87b855..60b196f3 100644 --- a/apps/mobile/src/screens/DashboardScreen.tsx +++ b/apps/mobile/src/screens/DashboardScreen.tsx @@ -1,4 +1,9 @@ -import React, { useEffect } from 'react'; +import React, { + useCallback, + useEffect, + useState, + type ReactElement, +} from 'react'; import { View, Text, @@ -6,15 +11,42 @@ import { SafeAreaView, ActivityIndicator, ScrollView, + Pressable, } from 'react-native'; import { NativeStackNavigationProp } from '@react-navigation/native-stack'; -import { useAuthContext } from '@beakerstack/shared/contexts/AuthContext'; import { - DASHBOARD_TITLE, - DASHBOARD_SUBTITLE, -} from '@beakerstack/shared/utils/strings'; + BillingProvider, + mapUnknownError, + useBillingContext, + useFeature, + usePlan, + useUsage, +} from '@beakerstack/billing'; +import type { BillingError } from '@beakerstack/billing'; +import { FeatureGate } from '@beakerstack/billing/native'; +import { useAuthContext } from '@beakerstack/shared/contexts/AuthContext'; import { AppHeader } from '@beakerstack/shared/components/navigation/AppHeader.native'; import { supabase } from '../lib/supabase'; +import { + beakerstackBillingConfig, + BEAKERSTACK_METER_AI_SUMMARIZE, +} from '../billing/beakerstackBillingConfig'; +import { useDemoCollections } from '../billing/useDemoCollections'; +import { nextFakeAiSummary } from '../lib/fakeAi'; + +// Read at call time (not module init) so tests and dev toggles can change process.env without reload. +function readBillingDashboardDemoMode(): boolean { + return process.env?.['EXPO_PUBLIC_BILLING_DEMO_MODE'] === 'true'; +} + +function readDemoUseRealAi(): boolean { + return process.env?.['EXPO_PUBLIC_DEMO_USE_REAL_AI'] === 'true'; +} + +const billingBaseUrl = + (typeof process !== 'undefined' && + process.env?.['EXPO_PUBLIC_BILLING_DEMO_BASE_URL']) || + 'http://127.0.0.1:8081'; type RootStackParamList = { Home: undefined; @@ -22,6 +54,7 @@ type RootStackParamList = { Signup: undefined; Dashboard: undefined; Profile: undefined; + Billing: undefined; }; type DashboardScreenNavigationProp = NativeStackNavigationProp< @@ -33,21 +66,531 @@ interface Props { navigation: DashboardScreenNavigationProp; } +type SummaryEntry = { id: string; at: number; text: string }; + +function limLabel(v: number | null): string { + if (v === null) return '…'; + if (v === -1) return '∞'; + return String(v); +} + +function SectionCard(props: { + title: string; + demonstrates: string; + description: string; + codeRef: string; + demoMode?: boolean; + children: React.ReactNode; +}): ReactElement { + return ( + + {props.demoMode && ( + + Demo mode only + + )} + {props.title} + + DEMONSTRATES:{' '} + {props.demonstrates} + + {props.description} + {props.children} + // {props.codeRef} + + ); +} + +function MeteredBlock(): ReactElement { + const { config } = useBillingContext(); + const { + used, + limit, + resetsAt, + exceeded, + loading, + error: usageError, + refresh, + } = useUsage< + typeof beakerstackBillingConfig, + typeof BEAKERSTACK_METER_AI_SUMMARIZE + >(BEAKERSTACK_METER_AI_SUMMARIZE); + const [results, setResults] = useState([]); + const [pending, setPending] = useState(false); + const [recordError, setRecordError] = useState(null); + + const resolveSummaryText = useCallback(async (): Promise => { + if (readDemoUseRealAi()) { + try { + const { data, error: fnErr } = await supabase.functions.invoke( + 'demo-ai-summarize', + { + body: { + prompt: 'Write a short lorem-style summary (3-5 lines).', + }, + } + ); + if (!fnErr) { + const t = (data as { text?: string } | null)?.text; + if (typeof t === 'string' && t.trim()) return t.trim(); + } + } catch { + /* optional demo-ai edge unavailable */ + } + } + return nextFakeAiSummary(); + }, []); + + const pushResult = useCallback((text: string) => { + const id = `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; + setResults(prev => { + const next: SummaryEntry[] = [{ id, at: Date.now(), text }, ...prev]; + return next.slice(0, 3); + }); + }, []); + + const onSimulate = useCallback(async () => { + if (exceeded || pending) return; + const summaryText = await resolveSummaryText(); + setPending(true); + setRecordError(null); + try { + const { error: rpcErr } = await supabase.rpc( + 'billing_record_usage_event', + { + p_product_id: config.productId, + p_event_type: BEAKERSTACK_METER_AI_SUMMARIZE, + p_quantity: 1, + p_metadata: {}, + } + ); + if (rpcErr) throw rpcErr; + await refresh(); + pushResult(summaryText); + } catch (e) { + setRecordError(mapUnknownError(e)); + } finally { + setPending(false); + } + }, [ + exceeded, + pending, + config.productId, + refresh, + resolveSummaryText, + pushResult, + ]); + + const lim = limit === null ? '∞' : String(limit); + const capLine = + limit === null + ? `${used} used this period · unlimited` + : `${used} of ${lim} used · resets ${ + resetsAt ? new Date(resetsAt).toLocaleDateString() : '—' + }`; + const pct = + limit != null && limit > 0 ? Math.min(100, (used / limit) * 100) : 0; + const displayError = recordError ?? usageError; + + return ( + + + {limit != null && ( + + + + )} + {loading ? '…' : capLine} + + + {exceeded ? ( + + Limit reached — open Billing for plans. + + ) : ( + void onSimulate()} + > + + {pending ? '…' : 'Simulate AI summarize'} + + + )} + + {displayError ? ( + {displayError.message} + ) : null} + + {results.length === 0 ? ( + + Tap 'Simulate AI summarize' to generate a result. + + ) : ( + results.map(e => ( + + + {new Date(e.at).toLocaleString()} + + {e.text} + + )) + )} + + + ); +} + +function NumericCapsBlock(): ReactElement { + const { + collections, + loading, + error, + addCollection, + deleteCollection, + addItem, + } = useDemoCollections(); + const { value: maxCollectionsRaw, loading: l1 } = useFeature< + typeof beakerstackBillingConfig, + 'containers_per_account_max' + >('containers_per_account_max'); + const { value: maxItemsRaw, loading: l2 } = useFeature< + typeof beakerstackBillingConfig, + 'items_per_container_max' + >('items_per_container_max'); + const maxCollections = + typeof maxCollectionsRaw === 'number' ? maxCollectionsRaw : null; + const maxItemsPer = typeof maxItemsRaw === 'number' ? maxItemsRaw : null; + const count = collections.length; + const atCollectionCap = + maxCollections !== null && maxCollections !== -1 && count >= maxCollections; + const [busy, setBusy] = useState(null); + const [actionErr, setActionErr] = useState(null); + const wrap = useCallback(async (key: string, fn: () => Promise) => { + setActionErr(null); + setBusy(key); + try { + await fn(); + } catch (e) { + setActionErr(e instanceof Error ? e.message : 'Failed'); + } finally { + setBusy(null); + } + }, []); + const featLoading = l1 || l2; + + return ( + + {error ? ( + + {error} (needs demo_billing_mode in DB) + + ) : null} + + + Collections:{' '} + {loading || featLoading + ? '…' + : `${count} of ${limLabel(maxCollections)}`} + + void wrap('add', addCollection)} + > + + {busy === 'add' + ? '…' + : atCollectionCap + ? 'Limit reached' + : 'Add collection'} + + + + {actionErr ? {actionErr} : null} + {!loading && collections.length === 0 ? ( + + No collections yet. Tap Add collection to start. + + ) : ( + collections.map(row => { + const itemCap = + maxItemsPer !== null && + maxItemsPer !== -1 && + row.item_count >= maxItemsPer; + return ( + + {row.id.slice(0, 8)}… + + Items: {row.item_count} of {limLabel(maxItemsPer)} + + + + void wrap(`i-${row.id}`, () => addItem(row.id)) + } + > + + {itemCap + ? 'Limit' + : busy === `i-${row.id}` + ? '…' + : 'Add item'} + + + + void wrap(`d-${row.id}`, () => deleteCollection(row.id)) + } + > + Delete + + + + ); + }) + )} + + ); +} + +function BooleanGatesBlock(): ReactElement { + const { loading: planLoading } = usePlan(); + const a = useFeature( + 'feature_a' + ); + const b = useFeature( + 'feature_b' + ); + return ( + + Feature A (requires Pro) + {planLoading ? ( + + ) : ( + + feature='feature_a' + fallback={ + + Feature A requires Pro. See Billing for plans. + + } + > + + ✓ Feature A is enabled for your plan. + + + )} + + Feature B (requires Max) + + {planLoading ? ( + + ) : ( + + feature='feature_b' + fallback={ + + Feature B requires Max. See Billing for plans. + + } + > + + ✓ Feature B is enabled for your plan. + + + )} + + useFeature("feature_a") →{' '} + {a.loading ? '…' : String(a.enabled)} · feature_b →{' '} + {b.loading ? '…' : String(b.enabled)} + + + ); +} + +const PLANS = [ + { id: 'beakerstack_free' as const, label: 'Free' }, + { id: 'beakerstack_pro' as const, label: 'Pro' }, + { id: 'beakerstack_max' as const, label: 'Max' }, +] as const; + +const METERS = [BEAKERSTACK_METER_AI_SUMMARIZE] as const; + +function DemoControlsBlock(): ReactElement | null { + if (!readBillingDashboardDemoMode()) return null; + const { data: plan, loading: planLoading } = + usePlan(); + const { refreshSubscription } = + useBillingContext(); + const { refresh: refreshUsage } = useUsage< + typeof beakerstackBillingConfig, + typeof BEAKERSTACK_METER_AI_SUMMARIZE + >(BEAKERSTACK_METER_AI_SUMMARIZE); + const [pending, setPending] = useState(null); + const [msg, setMsg] = useState(null); + + const run = useCallback(async (k: string, fn: () => Promise) => { + setMsg(null); + setPending(k); + try { + await fn(); + setMsg('Done.'); + } catch (e) { + setMsg(e instanceof Error ? e.message : 'Failed'); + } finally { + setPending(null); + } + }, []); + + return ( + + + Current plan: {planLoading ? '…' : (plan?.display_name ?? '—')} + + + {PLANS.map(p => ( + + void run(`p-${p.id}`, async () => { + const { error: e } = await supabase.rpc( + 'billing_demo_simulate_upgrade', + { + p_product_id: beakerstackBillingConfig.productId, + p_plan_id: p.id, + } + ); + if (e) throw e; + await refreshSubscription(); + }) + } + > + + {pending === `p-${p.id}` ? '…' : `To ${p.label}`} + + + ))} + + + void run('reset', async () => { + for (const m of METERS) { + const { error: e } = await supabase.rpc( + 'billing_demo_reset_usage', + { + p_product_id: beakerstackBillingConfig.productId, + p_event_type: m, + } + ); + if (e) throw e; + } + await refreshUsage(); + }) + } + > + + {pending === 'reset' ? '…' : 'Reset all usage counters'} + + + {msg ? {msg} : null} + + These actions bypass Stripe. Do not deploy to production. + + + ); +} + +function DashboardBody({ + navigation, +}: { + navigation: DashboardScreenNavigationProp; +}): ReactElement { + return ( + + Welcome to BeakerStack + + This dashboard is a sandbox for @beakerstack/billing. For polished + billing UI, use the web app. + + navigation.navigate('Billing')} + style={styles.navLink} + > + View polished billing → + + + + + + + + + + + + + + + + + + + ); +} + export default function DashboardScreen({ navigation }: Props) { const auth = useAuthContext(); - // Handle route protection - redirect if not authenticated useEffect(() => { if (!auth.loading && !auth.user) { - // Small delay to ensure navigation is ready - const timer = setTimeout(() => { - navigation.replace('Home'); - }, 100); - return () => clearTimeout(timer); + const t = setTimeout(() => navigation.replace('Home'), 100); + return () => clearTimeout(t); } }, [auth.loading, auth.user, navigation]); - // Show loading state while checking authentication if (auth.loading) { return ( @@ -57,7 +600,6 @@ export default function DashboardScreen({ navigation }: Props) { ); } - // Show loading state while redirecting (to avoid blank screen) if (!auth.user) { return ( @@ -67,70 +609,160 @@ export default function DashboardScreen({ navigation }: Props) { ); } - // Render protected content if authenticated - return ; -} - -function DashboardScreenContent({ navigation: _navigation }: Props) { return ( - + supabase={supabase} + config={beakerstackBillingConfig} + checkoutSuccessUrl={`${billingBaseUrl}/billing`} + checkoutCancelUrl={`${billingBaseUrl}/billing/plans?checkout=cancel`} + portalReturnUrl={`${billingBaseUrl}/billing`} > - - {DASHBOARD_TITLE} - {DASHBOARD_SUBTITLE} - - + + ); } const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: '#f9fafb', + container: { flex: 1, backgroundColor: '#f9fafb' }, + scrollView: { flex: 1 }, + content: { padding: 20, paddingBottom: 40 }, + h1: { fontSize: 22, fontWeight: '700', color: '#111827' }, + lede: { marginTop: 8, fontSize: 14, color: '#4b5563', lineHeight: 20 }, + navLink: { marginTop: 12, alignSelf: 'flex-start' }, + navLinkText: { color: '#4f46e5', fontWeight: '600' }, + spacer: { height: 20 }, + card: { + backgroundColor: '#fff', + borderRadius: 12, + borderWidth: 1, + borderColor: '#e5e7eb', + padding: 16, + marginBottom: 20, }, - scrollView: { - flex: 1, + cardDemo: { borderStyle: 'dashed' as const, borderColor: '#d1d5db' }, + badge: { + position: 'absolute' as const, + right: 12, + top: 10, + backgroundColor: '#fef3c7', + paddingHorizontal: 8, + paddingVertical: 2, + borderRadius: 4, + }, + badgeText: { fontSize: 10, fontWeight: '600', color: '#92400e' }, + cardTitle: { + fontSize: 18, + fontWeight: '600', + color: '#111827', + paddingRight: 100, + }, + demonstratesLabel: { + marginTop: 4, + fontSize: 10, + fontWeight: '600', + color: '#6b7280', + textTransform: 'uppercase' as const, + letterSpacing: 0.5, + }, + demonstratesMono: { fontFamily: 'monospace', fontSize: 12, color: '#374151' }, + cardDesc: { marginTop: 6, fontSize: 14, color: '#4b5563' }, + cardBody: { marginTop: 12 }, + codeLine: { + marginTop: 12, + fontSize: 11, + fontFamily: 'monospace', + color: '#6b7280', + }, + usageBarTrack: { + height: 8, + backgroundColor: '#e5e7eb', + borderRadius: 4, + overflow: 'hidden', + }, + usageBarFill: { + height: 8, + backgroundColor: '#4f46e5', + borderRadius: 4, + }, + usageCapLine: { + marginTop: 8, + fontSize: 13, + color: '#4b5563', + }, + row: { + flexDirection: 'row', + flexWrap: 'wrap', + alignItems: 'center', + gap: 8, + marginTop: 8, }, - content: { - padding: 20, - paddingBottom: 40, + rowBetween: { + flexDirection: 'row', + flexWrap: 'wrap', + alignItems: 'center', + justifyContent: 'space-between', + gap: 8, + marginTop: 4, + }, + btnPrimary: { + backgroundColor: '#4f46e5', + paddingHorizontal: 16, + paddingVertical: 10, + borderRadius: 8, }, - dashboardArea: { - borderWidth: 4, - borderStyle: 'dashed', + btnPrimaryText: { color: '#fff', fontWeight: '600' }, + btnDisabled: { opacity: 0.5 }, + btnSecondary: { + borderWidth: 1, + borderColor: '#d1d5db', + paddingHorizontal: 10, + paddingVertical: 6, + borderRadius: 6, + backgroundColor: '#fff', + }, + btnSecondaryText: { color: '#374151', fontSize: 12, fontWeight: '500' }, + btnDanger: { marginLeft: 8, padding: 6 }, + btnDangerText: { color: '#b91c1c', fontSize: 12, fontWeight: '600' }, + btnRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 8, marginTop: 8 }, + muted: { color: '#6b7280', fontSize: 13, marginTop: 4 }, + errText: { color: '#b91c1c', fontSize: 13, marginTop: 4 }, + warnText: { color: '#b45309', fontSize: 12, marginBottom: 6 }, + bodyText: { fontSize: 14, color: '#374151' }, + subH: { fontSize: 14, fontWeight: '600', color: '#111827' }, + okText: { fontSize: 14, color: '#15803d' }, + resultBox: { + marginTop: 12, + borderWidth: 1, borderColor: '#e5e7eb', + backgroundColor: '#f9fafb', borderRadius: 8, - padding: 32, - alignItems: 'center', - justifyContent: 'center', + padding: 10, }, - title: { - fontSize: 24, - fontWeight: 'bold', - color: '#1f2937', - textAlign: 'center', - marginBottom: 16, + resultItem: { + marginBottom: 10, + borderBottomWidth: 1, + borderBottomColor: '#e5e7eb', }, - subtitle: { - fontSize: 16, - color: '#6b7280', - textAlign: 'center', - lineHeight: 24, + resultTs: { fontSize: 11, color: '#6b7280' }, + resultBody: { fontSize: 13, color: '#1f2937', marginTop: 2 }, + collectionCard: { + marginTop: 10, + padding: 10, + backgroundColor: '#f9fafb', + borderRadius: 8, + borderWidth: 1, + borderColor: '#e5e7eb', }, + monoSm: { fontFamily: 'monospace', fontSize: 11, color: '#6b7280' }, + tinyLegal: { fontSize: 10, color: '#6b7280', marginTop: 8 }, loadingContainer: { flex: 1, justifyContent: 'center', alignItems: 'center', backgroundColor: '#F9FAFB', }, - loadingText: { - marginTop: 16, - fontSize: 16, - color: '#6B7280', - }, + loadingText: { marginTop: 16, fontSize: 16, color: '#6B7280' }, }); diff --git a/apps/mobile/src/types/database.ts b/apps/mobile/src/types/database.ts deleted file mode 100644 index cd2465fb..00000000 --- a/apps/mobile/src/types/database.ts +++ /dev/null @@ -1,222 +0,0 @@ -export type Json = - | string - | number - | boolean - | null - | { [key: string]: Json | undefined } - | Json[] - -export type Database = { - // Allows to automatically instantiate createClient with right options - // instead of createClient(URL, KEY) - __InternalSupabase: { - PostgrestVersion: "13.0.5" - } - graphql_public: { - Tables: { - [_ in never]: never - } - Views: { - [_ in never]: never - } - Functions: { - graphql: { - Args: { - extensions?: Json - operationName?: string - query?: string - variables?: Json - } - Returns: Json - } - } - Enums: { - [_ in never]: never - } - CompositeTypes: { - [_ in never]: never - } - } - public: { - Tables: { - user_profiles: { - Row: { - avatar_url: string | null - bio: string | null - created_at: string | null - display_name: string | null - id: string - location: string | null - updated_at: string | null - user_id: string - username: string | null - website: string | null - } - Insert: { - avatar_url?: string | null - bio?: string | null - created_at?: string | null - display_name?: string | null - id?: string - location?: string | null - updated_at?: string | null - user_id: string - username?: string | null - website?: string | null - } - Update: { - avatar_url?: string | null - bio?: string | null - created_at?: string | null - display_name?: string | null - id?: string - location?: string | null - updated_at?: string | null - user_id?: string - username?: string | null - website?: string | null - } - Relationships: [] - } - } - Views: { - [_ in never]: never - } - Functions: { - generate_username: { Args: never; Returns: string } - is_valid_email: { Args: { email: string }; Returns: boolean } - } - Enums: { - [_ in never]: never - } - CompositeTypes: { - [_ in never]: never - } - } -} - -type DatabaseWithoutInternals = Omit - -type DefaultSchema = DatabaseWithoutInternals[Extract] - -export type Tables< - DefaultSchemaTableNameOrOptions extends - | keyof (DefaultSchema["Tables"] & DefaultSchema["Views"]) - | { schema: keyof DatabaseWithoutInternals }, - TableName extends DefaultSchemaTableNameOrOptions extends { - schema: keyof DatabaseWithoutInternals - } - ? keyof (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] & - DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"]) - : never = never, -> = DefaultSchemaTableNameOrOptions extends { - schema: keyof DatabaseWithoutInternals -} - ? (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] & - DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"])[TableName] extends { - Row: infer R - } - ? R - : never - : DefaultSchemaTableNameOrOptions extends keyof (DefaultSchema["Tables"] & - DefaultSchema["Views"]) - ? (DefaultSchema["Tables"] & - DefaultSchema["Views"])[DefaultSchemaTableNameOrOptions] extends { - Row: infer R - } - ? R - : never - : never - -export type TablesInsert< - DefaultSchemaTableNameOrOptions extends - | keyof DefaultSchema["Tables"] - | { schema: keyof DatabaseWithoutInternals }, - TableName extends DefaultSchemaTableNameOrOptions extends { - schema: keyof DatabaseWithoutInternals - } - ? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] - : never = never, -> = DefaultSchemaTableNameOrOptions extends { - schema: keyof DatabaseWithoutInternals -} - ? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends { - Insert: infer I - } - ? I - : never - : DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"] - ? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends { - Insert: infer I - } - ? I - : never - : never - -export type TablesUpdate< - DefaultSchemaTableNameOrOptions extends - | keyof DefaultSchema["Tables"] - | { schema: keyof DatabaseWithoutInternals }, - TableName extends DefaultSchemaTableNameOrOptions extends { - schema: keyof DatabaseWithoutInternals - } - ? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] - : never = never, -> = DefaultSchemaTableNameOrOptions extends { - schema: keyof DatabaseWithoutInternals -} - ? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends { - Update: infer U - } - ? U - : never - : DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"] - ? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends { - Update: infer U - } - ? U - : never - : never - -export type Enums< - DefaultSchemaEnumNameOrOptions extends - | keyof DefaultSchema["Enums"] - | { schema: keyof DatabaseWithoutInternals }, - EnumName extends DefaultSchemaEnumNameOrOptions extends { - schema: keyof DatabaseWithoutInternals - } - ? keyof DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"] - : never = never, -> = DefaultSchemaEnumNameOrOptions extends { - schema: keyof DatabaseWithoutInternals -} - ? DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"][EnumName] - : DefaultSchemaEnumNameOrOptions extends keyof DefaultSchema["Enums"] - ? DefaultSchema["Enums"][DefaultSchemaEnumNameOrOptions] - : never - -export type CompositeTypes< - PublicCompositeTypeNameOrOptions extends - | keyof DefaultSchema["CompositeTypes"] - | { schema: keyof DatabaseWithoutInternals }, - CompositeTypeName extends PublicCompositeTypeNameOrOptions extends { - schema: keyof DatabaseWithoutInternals - } - ? keyof DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"] - : never = never, -> = PublicCompositeTypeNameOrOptions extends { - schema: keyof DatabaseWithoutInternals -} - ? DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"][CompositeTypeName] - : PublicCompositeTypeNameOrOptions extends keyof DefaultSchema["CompositeTypes"] - ? DefaultSchema["CompositeTypes"][PublicCompositeTypeNameOrOptions] - : never - -export const Constants = { - graphql_public: { - Enums: {}, - }, - public: { - Enums: {}, - }, -} as const diff --git a/apps/mobile/supabase/config.toml b/apps/mobile/supabase/config.toml index 0c0db5db..4115c601 100644 --- a/apps/mobile/supabase/config.toml +++ b/apps/mobile/supabase/config.toml @@ -329,6 +329,12 @@ deno_version = 2 # [edge_runtime.secrets] # secret_key = "env(SECRET_VALUE)" +[functions.stripe-webhook] +verify_jwt = false + +[functions.billing-stripe] +verify_jwt = true + [analytics] enabled = true port = 55327 diff --git a/apps/mobile/supabase/migrations/20241020000001_initial_schema.sql b/apps/mobile/supabase/migrations/20241020000001_initial_schema.sql deleted file mode 100644 index d2e6b787..00000000 --- a/apps/mobile/supabase/migrations/20241020000001_initial_schema.sql +++ /dev/null @@ -1,31 +0,0 @@ --- Initial schema setup for Beaker Stack --- This migration sets up the basic database structure - --- Enable necessary extensions -CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; -CREATE EXTENSION IF NOT EXISTS "pgcrypto"; - --- Create a function to update the updated_at timestamp -CREATE OR REPLACE FUNCTION update_updated_at_column() -RETURNS TRIGGER AS $$ -BEGIN - NEW.updated_at = NOW(); - RETURN NEW; -END; -$$ language 'plpgsql'; - --- Create a function to generate a random username -CREATE OR REPLACE FUNCTION generate_username() -RETURNS TEXT AS $$ -BEGIN - RETURN 'user_' || substr(md5(random()::text), 1, 8); -END; -$$ language 'plpgsql'; - --- Create a function to validate email format -CREATE OR REPLACE FUNCTION is_valid_email(email TEXT) -RETURNS BOOLEAN AS $$ -BEGIN - RETURN email ~* '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$'; -END; -$$ language 'plpgsql'; diff --git a/apps/mobile/supabase/migrations/20241020000002_user_profiles.sql b/apps/mobile/supabase/migrations/20241020000002_user_profiles.sql deleted file mode 100644 index ec480086..00000000 --- a/apps/mobile/supabase/migrations/20241020000002_user_profiles.sql +++ /dev/null @@ -1,70 +0,0 @@ --- User profiles table with RLS policies --- This migration creates the user_profiles table with proper constraints and RLS - --- Create user_profiles table -CREATE TABLE IF NOT EXISTS public.user_profiles ( - id UUID DEFAULT uuid_generate_v4() PRIMARY KEY, - user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE UNIQUE NOT NULL, - username TEXT UNIQUE, - display_name TEXT, - bio TEXT, - avatar_url TEXT, - website TEXT, - location TEXT, - created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), - updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), - - -- Constraints - CONSTRAINT valid_username CHECK (username IS NULL OR (length(username) >= 3 AND length(username) <= 30 AND username ~ '^[a-zA-Z0-9_]+$')), - CONSTRAINT valid_display_name CHECK (display_name IS NULL OR length(display_name) <= 100), - CONSTRAINT valid_bio CHECK (bio IS NULL OR length(bio) <= 500), - CONSTRAINT valid_website CHECK (website IS NULL OR website ~ '^https?://[^\s/$.?#].[^\s]*$') -); - --- Create index for faster lookups -CREATE INDEX IF NOT EXISTS idx_user_profiles_user_id ON public.user_profiles(user_id); -CREATE INDEX IF NOT EXISTS idx_user_profiles_username ON public.user_profiles(username); - --- Create trigger to automatically update updated_at -CREATE TRIGGER update_user_profiles_updated_at - BEFORE UPDATE ON public.user_profiles - FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); - --- Enable Row Level Security -ALTER TABLE public.user_profiles ENABLE ROW LEVEL SECURITY; - --- Create RLS policies --- Users can view all profiles (for now - can be restricted later) -CREATE POLICY "Users can view all profiles" ON public.user_profiles - FOR SELECT USING (true); - --- Users can only insert their own profile -CREATE POLICY "Users can insert their own profile" ON public.user_profiles - FOR INSERT WITH CHECK (auth.uid() = user_id); - --- Users can only update their own profile -CREATE POLICY "Users can update their own profile" ON public.user_profiles - FOR UPDATE USING (auth.uid() = user_id); - --- Users can only delete their own profile -CREATE POLICY "Users can delete their own profile" ON public.user_profiles - FOR DELETE USING (auth.uid() = user_id); - --- Create a function to automatically create a profile when a user signs up -CREATE OR REPLACE FUNCTION public.handle_new_user() -RETURNS TRIGGER AS $$ -BEGIN - INSERT INTO public.user_profiles (user_id, username, display_name) - VALUES ( - NEW.id, - generate_username(), - COALESCE(NEW.raw_user_meta_data->>'full_name', NEW.email) - ); - RETURN NEW; -END; -$$ language 'plpgsql' SECURITY DEFINER; - --- Create trigger to automatically create profile on user signup -CREATE TRIGGER on_auth_user_created - AFTER INSERT ON auth.users - FOR EACH ROW EXECUTE FUNCTION public.handle_new_user(); diff --git a/apps/mobile/supabase/migrations/20241020000003_storage_buckets.sql b/apps/mobile/supabase/migrations/20241020000003_storage_buckets.sql deleted file mode 100644 index bd471d33..00000000 --- a/apps/mobile/supabase/migrations/20241020000003_storage_buckets.sql +++ /dev/null @@ -1,50 +0,0 @@ --- Storage bucket configuration for avatars --- This migration creates the avatars storage bucket with RLS policies - --- Create the avatars storage bucket --- Public read access, authenticated write access -INSERT INTO storage.buckets (id, name, public, file_size_limit, allowed_mime_types) -VALUES ( - 'avatars', - 'avatars', - true, -- Public read access - 2097152, -- 2MB file size limit (2 * 1024 * 1024) - ARRAY['image/jpeg', 'image/png', 'image/webp']::text[] -) -ON CONFLICT (id) DO NOTHING; - --- Note: RLS is already enabled on storage.objects by default --- Policy: Users can upload avatars to their own folder --- File path format: {user_id}/avatar.{ext} -CREATE POLICY "Users can upload own avatar" -ON storage.objects FOR INSERT -WITH CHECK ( - bucket_id = 'avatars' - AND split_part(name, '/', 1) = auth.uid()::text -); - --- Policy: Avatar images are publicly accessible for reading -CREATE POLICY "Avatar images are publicly accessible" -ON storage.objects FOR SELECT -USING (bucket_id = 'avatars'); - --- Policy: Users can update their own avatars -CREATE POLICY "Users can update own avatar" -ON storage.objects FOR UPDATE -USING ( - bucket_id = 'avatars' - AND split_part(name, '/', 1) = auth.uid()::text -) -WITH CHECK ( - bucket_id = 'avatars' - AND split_part(name, '/', 1) = auth.uid()::text -); - --- Policy: Users can delete their own avatars -CREATE POLICY "Users can delete own avatar" -ON storage.objects FOR DELETE -USING ( - bucket_id = 'avatars' - AND split_part(name, '/', 1) = auth.uid()::text -); - diff --git a/apps/mobile/supabase/migrations/20241108000001_enable_realtime_user_profiles.sql b/apps/mobile/supabase/migrations/20241108000001_enable_realtime_user_profiles.sql deleted file mode 100644 index 9704d1bd..00000000 --- a/apps/mobile/supabase/migrations/20241108000001_enable_realtime_user_profiles.sql +++ /dev/null @@ -1,9 +0,0 @@ --- Enable Realtime for user_profiles table --- This allows clients to subscribe to changes on the user_profiles table - --- Add the user_profiles table to the realtime publication -ALTER PUBLICATION supabase_realtime ADD TABLE public.user_profiles; - --- Set replica identity to FULL to include all column values in change events --- This ensures UPDATE events contain both old and new values -ALTER TABLE public.user_profiles REPLICA IDENTITY FULL; diff --git a/apps/mobile/supabase/migrations/README.md b/apps/mobile/supabase/migrations/README.md new file mode 100644 index 00000000..228cf591 --- /dev/null +++ b/apps/mobile/supabase/migrations/README.md @@ -0,0 +1,8 @@ +# Schema migrations live at the repository root + +SQL migrations are **not** duplicated under this app. Author and apply them only from the BeakerStack repository root: + +- **Directory:** `supabase/migrations/` (relative to repo root) +- **CLI:** Run `supabase start`, `supabase migration new …`, `supabase db reset`, and linked `supabase db push` from the **repository root**, using the root `supabase/config.toml`. + +This directory intentionally contains no `.sql` migration files so we avoid drift between copies. diff --git a/apps/mobile/supabase/seed.sql b/apps/mobile/supabase/seed.sql index 54f01615..f3db6581 100644 --- a/apps/mobile/supabase/seed.sql +++ b/apps/mobile/supabase/seed.sql @@ -1,6 +1,69 @@ --- Seed data for local development --- This file contains basic test data for the Beaker Stack +-- Seed data for local development (kept in sync with repo root supabase/seed.sql) --- For now, just a comment since we don't have tables yet --- Tables will be created in future migrations -SELECT 'Seed data loaded successfully' as status; +INSERT INTO public.billing_products (id, display_name, description) +VALUES ( + 'beakerstack', + 'BeakerStack', + 'Template billing demo product' +) +ON CONFLICT (id) DO NOTHING; + +INSERT INTO public.billing_plans ( + id, product_id, display_name, description, price_cents, billing_period, + stripe_price_id_monthly, stripe_price_id_annual, stripe_product_id, features, usage_limits, trial_period_days, + is_public, display_order +) +VALUES +( + 'beakerstack_free', + 'beakerstack', + 'Free', + 'Starter', + 0, + 'free', + NULL, + NULL, + NULL, + '{"containers_per_account_max": 2, "items_per_container_max": 3, "feature_a": false, "feature_b": false}'::jsonb, + '{"ai_summarize": 30}'::jsonb, + 0, + true, + 1 +), +( + 'beakerstack_pro', + 'beakerstack', + 'Pro', + 'More capacity', + 1900, + 'monthly', + NULL, + NULL, + NULL, + '{"containers_per_account_max": -1, "items_per_container_max": 25, "feature_a": true, "feature_b": false}'::jsonb, + '{"ai_summarize": 500}'::jsonb, + 0, + true, + 2 +), +( + 'beakerstack_max', + 'beakerstack', + 'Max', + 'Everything', + 4900, + 'monthly', + NULL, + NULL, + NULL, + '{"containers_per_account_max": -1, "items_per_container_max": -1, "feature_a": true, "feature_b": true, "feature_c": true}'::jsonb, + '{"ai_summarize": -1}'::jsonb, + 5, + true, + 3 +) +ON CONFLICT (id) DO NOTHING; + +UPDATE public.billing_system_flags SET value = true WHERE key = 'demo_billing_mode'; + +SELECT 'Seed data loaded successfully' AS status; diff --git a/apps/mobile/tsconfig.json b/apps/mobile/tsconfig.json index 07f0b57e..8ee1956e 100644 --- a/apps/mobile/tsconfig.json +++ b/apps/mobile/tsconfig.json @@ -8,10 +8,22 @@ "baseUrl": ".", "paths": { "@/*": ["./src/*"], - "@beakerstack/shared/*": ["../../packages/shared/src/*"] + "@beakerstack/shared/*": ["../../packages/shared/src/*"], + "@beakerstack/billing": ["../../packages/billing/src/index.ts"], + "@beakerstack/billing/web": ["../../packages/billing/src/web.ts"], + "@beakerstack/billing/native": ["../../packages/billing/src/native.ts"] } }, - "include": ["**/*.ts", "**/*.tsx"], + "include": [ + "**/*.ts", + "**/*.tsx", + "../../packages/billing/src/**/*.ts", + "../../packages/billing/src/**/*.tsx" + ], + "exclude": [ + "../../packages/billing/src/**/*.test.ts", + "../../packages/billing/src/**/*.test.tsx" + ], "references": [ { "path": "../../packages/shared" diff --git a/apps/web/docs/billing-demo.md b/apps/web/docs/billing-demo.md new file mode 100644 index 00000000..9ce8f741 --- /dev/null +++ b/apps/web/docs/billing-demo.md @@ -0,0 +1,22 @@ +# Billing demo (deprecated) + +> **Replaced by** production billing at **`/billing`** (Overview, Usage, Plans, Invoices) and the testing doc **[billing-testing.md](./billing-testing.md)**. + +The former `apps/web/src/billing-demo/` app folder and `/billing-demo` route have been removed. Template config now lives at: + +- `apps/web/src/billing/beakerstackBillingConfig.ts` +- `apps/web/src/billing/billing-sync.json` (for `npm run billing:sync-stripe`) + +## Webhook testing (Stripe CLI) + +Use **[billing-testing.md](./billing-testing.md)** for the authoritative walkthrough: `stripe listen` forwarding to `stripe-webhook`, signing secrets, invoice lifecycle triggers, and **idempotency** (`billing_webhook_events` dedupe). + +**Invoice / subscription race (local QA):** if an `invoice.*` event arrives before checkout has linked `stripe_customer_id` on `billing_subscriptions`, the webhook **skips** invoice upsert with a log line and returns **200** so Stripe does not retry indefinitely; a later event reconciles once the row exists. + +## Mobile + +See `apps/mobile/src/screens/BillingScreen.tsx` and `apps/mobile/src/billing/beakerstackBillingConfig.ts` for the native dev surface that wraps `@beakerstack/billing`. + +## Historical content + +Prior versions of this file documented Stripe CLI triggers, env vars, and sync steps; those are consolidated in **[billing-testing.md](./billing-testing.md)** with updated paths and invoice coverage. diff --git a/apps/web/docs/billing-testing.md b/apps/web/docs/billing-testing.md new file mode 100644 index 00000000..f2eea988 --- /dev/null +++ b/apps/web/docs/billing-testing.md @@ -0,0 +1,94 @@ +# Billing — testing & verification + +Production routes: **`/billing`**, `/billing/usage`, `/billing/plans`, `/billing/invoices`. Config: `apps/web/src/billing/beakerstackBillingConfig.ts`. Stripe price sync: `apps/web/src/billing/billing-sync.json` and `npm run billing:sync-stripe`. + +## Environment + +| Variable | Purpose | +| ---------------------------------------------- | --------------------------------------------------------------------------------------- | +| `VITE_SUPABASE_URL` / `VITE_SUPABASE_ANON_KEY` | Supabase client | +| `VITE_BILLING_DEMO_MODE` | When `true`, web UI may show demo RPC controls (must match server) | +| `STRIPE_*` | Used by Edge Functions (Supabase Dashboard → Edge Function secrets), not in the browser | + +`STRIPE_PUBLISHABLE_KEY` is read in Edge (`stripe-webhook`, `billing-stripe`) for configuration parity with the billing spec; checkout and Elements still consume the publishable key from **client** env (e.g. `VITE_*`). Edge calls use only the secret key. + +**Deploy target (`billing_deploy_target`):** Checkout stamps each session with a label derived from `SUPABASE_URL` / `BILLING_SUPABASE_URL` (Supabase project ref for hosted `*.supabase.co`, otherwise **`local`**). The webhook skips completed checkouts when metadata targets another deployment. You normally need no extra env; set optional **`BILLING_WEBHOOK_TARGET`** on both Edge functions only if you override the default (same value in `supabase/.env.local` for local serve). + +**Invoice race:** if Stripe delivers an invoice event before `billing_subscriptions` links the Stripe customer (rare around checkout), the webhook logs and skips that upsert instead of failing the handler; a later subscription/checkout event reconciles. + +Server: `billing_system_flags` row `demo_billing_mode = true` enables `billing_demo_simulate_upgrade` and `billing_demo_reset_usage` (local `supabase/seed.sql` sets this in dev). + +## `useBillingState` matrix (9 states) + +Derived in `packages/billing/src/hooks/useBillingState.ts` and reflected in `Banner` / badges on the billing pages: + +1. **loading** — no subscription data yet; skeletons/loading UI. +2. **no_subscription** — no current subscription row; free-tier/post-cancellation state before/without a `free` row. +3. **free** — explicit free subscription state. +4. **paid_active** — paid subscription (`active`, and currently also `paused`/`incomplete`/`unpaid` in derive logic). +5. **cancelled_pending** — `cancel_at_period_end` and still in paid period. +6. **payment_failed** — `past_due` / failed payment. +7. **trialing** — in trial. +8. **trial_ending** — trial ends soon (derived from `trial_end` threshold). +9. **downgrade_pending** — `cancel_at_period_end` with `pending_target_plan_id` set. + +Use **Stripe** for realistic transitions where possible; use **demo RPCs** to jump plans without a card when `demo_billing_mode` is on. + +### Demo RPCs (Supabase) + +```sql +-- Simulate upgrade to a plan_id (e.g. beakerstack_pro) +select billing_demo_simulate_upgrade( + p_product_id => 'beakerstack', + p_plan_id => 'beakerstack_pro' +); + +select billing_demo_reset_usage(); +``` + +## Stripe CLI — webhook checks + +Prerequisites: `supabase start`, `supabase functions serve` with the same env as `stripe-webhook` (e.g. `./supabase/.env.local`), Stripe CLI logged in (`stripe login`). + +Forward webhooks: + +```bash +stripe listen --forward-to http://127.0.0.1:54321/functions/v1/stripe-webhook +``` + +Use the **webhook signing secret** from the CLI as `STRIPE_WEBHOOK_SECRET` for that session (or the Dashboard for a fixed endpoint). + +### Useful triggers + +Confirm rows in `billing_webhook_events`, `billing_subscriptions`, and (for invoices) `billing_invoices` in Supabase Studio. + +| Scenario | Command | Notes | +| --------------------------- | ---------------------------------------------------------------------------------- | ------------------------------------------- | +| Invoice payment failed | `stripe trigger invoice.payment_failed` | May set subscription `past_due` when linked | +| Subscription updated | `stripe trigger customer.subscription.updated` | Status/period sync | +| Trial will end (signal) | `stripe trigger customer.subscription.trial_will_end` | Logged; email is app concern | +| Invoice created / lifecycle | `invoice.created`, `invoice.finalized`, `invoice.paid`, `invoice.voided` as needed | Invoices table upserts | +| Idempotency | Re-run same `stripe trigger` or resend in Dashboard | `stripe_event_id` must dedupe | + +## Sync Stripe prices to the database + +```bash +export STRIPE_SECRET_KEY=sk_test_... +export SUPABASE_URL=http://127.0.0.1:54321 +export SUPABASE_SERVICE_ROLE_KEY= +npm run billing:sync-stripe +``` + +Paid plans get `stripe_price_id_monthly` / `stripe_price_id_annual` in `public.billing_plans`. + +## Manual Checkout + +Run the web app, sign in, go to **`/billing/plans`**, use test card `4242 4242 4242 4242` (any future CVC, any ZIP). Return URLs use `/billing` paths. After success, confirm `billing_subscriptions` and webhook rows. + +## Mobile + +`EXPO_PUBLIC_BILLING_DEMO_MODE` and `EXPO_PUBLIC_BILLING_DEMO_BASE_URL` (LAN URL for return URLs on device). Billing package smoke screen: `apps/mobile/src/screens/BillingScreen.tsx`. Full tab parity is web-first; the native app links users to the web for the complete `/billing` UI if needed. + +## Legacy + +The old `/billing-demo` route has been removed. If you have bookmarks, use `/billing` instead. See [billing-demo.md](./billing-demo.md) (deprecated) for any retained historical notes. diff --git a/apps/web/package.json b/apps/web/package.json index feb5e00d..c68f3926 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -16,8 +16,10 @@ "clean": "rm -rf dist node_modules/.vite" }, "dependencies": { + "@beakerstack/billing": "^1.0.0", "@beakerstack/shared": "^1.0.0", "@supabase/supabase-js": "^2.38.0", + "lucide-react": "^0.460.0", "react": "18.2.0", "react-dom": "18.2.0", "react-router-dom": "^6.30.3", diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 83e12616..4f6e669c 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,11 +1,16 @@ -import { Routes, Route } from 'react-router-dom'; +import { Routes, Route, Outlet } from 'react-router-dom'; import { ProtectedRoute } from '@beakerstack/shared/components/auth/ProtectedRoute.web'; +import { BillingProviderLayout } from './billing/BillingProviderLayout'; import HomePage from './pages/HomePage'; import LoginPage from './pages/LoginPage'; import SignupPage from './pages/SignupPage'; import DashboardPage from './pages/DashboardPage'; import ProfilePage from './pages/ProfilePage'; import AuthCallbackPage from './pages/AuthCallbackPage'; +import BillingOverviewPage from './pages/billing/BillingOverviewPage'; +import BillingUsagePage from './pages/billing/BillingUsagePage'; +import BillingPlansPage from './pages/billing/BillingPlansPage'; +import BillingInvoicesPage from './pages/billing/BillingInvoicesPage'; function App() { return ( @@ -15,21 +20,28 @@ function App() { } /> } /> - + } /> - + } - /> + > + }> + } /> + } /> + } /> + } /> + } /> + + } /> diff --git a/apps/web/src/billing/BillingProviderLayout.tsx b/apps/web/src/billing/BillingProviderLayout.tsx new file mode 100644 index 00000000..bc4b62c3 --- /dev/null +++ b/apps/web/src/billing/BillingProviderLayout.tsx @@ -0,0 +1,27 @@ +import { BillingProvider } from '@beakerstack/billing'; +import { Outlet } from 'react-router-dom'; +import { supabase } from '../lib/supabase'; +import { beakerstackBillingConfig } from './beakerstackBillingConfig'; + +function appBasePath(): string { + if (typeof window === 'undefined') return ''; + return `${window.location.origin}${(import.meta.env.BASE_URL || '/').replace(/\/$/, '')}`; +} + +/** + * Wraps `/dashboard` and `/billing/*` with a single {@link BillingProvider} (shared subscription state). + */ +export function BillingProviderLayout() { + const base = appBasePath(); + return ( + + supabase={supabase} + config={beakerstackBillingConfig} + checkoutSuccessUrl={`${base}/billing?checkout=success`} + checkoutCancelUrl={`${base}/billing/plans?checkout=cancel`} + portalReturnUrl={`${base}/billing`} + > + + + ); +} diff --git a/apps/web/src/billing/__tests__/BillingProviderLayout.test.tsx b/apps/web/src/billing/__tests__/BillingProviderLayout.test.tsx new file mode 100644 index 00000000..c7da04fa --- /dev/null +++ b/apps/web/src/billing/__tests__/BillingProviderLayout.test.tsx @@ -0,0 +1,69 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { BillingProviderLayout } from '../BillingProviderLayout'; + +const { BillingProviderSpy } = vi.hoisted(() => { + const BillingProviderSpy = vi.fn( + ({ + children, + checkoutSuccessUrl, + }: { + children: React.ReactNode; + checkoutSuccessUrl: string; + }) => ( +
+ {children} +
+ ) + ); + return { BillingProviderSpy }; +}); + +vi.mock('@beakerstack/billing', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + BillingProvider: BillingProviderSpy, + }; +}); + +vi.mock('../../lib/supabase', () => ({ + supabase: { from: vi.fn() }, +})); + +describe('BillingProviderLayout', () => { + beforeEach(() => { + BillingProviderSpy.mockClear(); + vi.stubGlobal('location', { + ...window.location, + origin: 'https://app.test', + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('wraps routes with BillingProvider and renders the outlet', () => { + render( + + + }> + Billing child} /> + + + + ); + + expect(screen.getByTestId('mock-billing-provider')).toBeInTheDocument(); + expect(screen.getByText('Billing child')).toBeInTheDocument(); + const el = screen.getByTestId('mock-billing-provider'); + expect(el.getAttribute('data-success')).toMatch(/checkout=success/); + expect(BillingProviderSpy).toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/billing/__tests__/beakerstackBillingConfig.test.ts b/apps/web/src/billing/__tests__/beakerstackBillingConfig.test.ts new file mode 100644 index 00000000..253c6821 --- /dev/null +++ b/apps/web/src/billing/__tests__/beakerstackBillingConfig.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest'; +import { + BEAKERSTACK_METER_AI_SUMMARIZE, + beakerstackBillingConfig, +} from '../beakerstackBillingConfig'; + +describe('beakerstackBillingConfig', () => { + it('defines a parsed product with public plans', () => { + expect(beakerstackBillingConfig.productId).toBe('beakerstack'); + expect(beakerstackBillingConfig.plans.length).toBeGreaterThanOrEqual(1); + const ids = beakerstackBillingConfig.plans.map(p => p.id); + expect(ids).toContain('beakerstack_free'); + }); + + it('exposes meter key constant for AI usage', () => { + expect(BEAKERSTACK_METER_AI_SUMMARIZE).toBe('ai_summarize'); + }); +}); diff --git a/apps/web/src/billing/__tests__/billingSyncDisplay.mockedSync.test.ts b/apps/web/src/billing/__tests__/billingSyncDisplay.mockedSync.test.ts new file mode 100644 index 00000000..88eeae88 --- /dev/null +++ b/apps/web/src/billing/__tests__/billingSyncDisplay.mockedSync.test.ts @@ -0,0 +1,235 @@ +import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest'; +import type { Plan } from '@beakerstack/billing'; + +const basePaidPlan = (over: Partial & Pick): Plan => ({ + product_id: 'beakerstack', + display_name: 'P', + description: null, + price_cents: over.price_cents ?? 1000, + billing_period: 'monthly', + stripe_price_id_monthly: null, + stripe_price_id_annual: null, + stripe_product_id: null, + features: {}, + usage_limits: {}, + trial_period_days: 0, + is_public: true, + display_order: 1, + ...over, +}); + +describe('billingSyncDisplay with mocked billing-sync.json', () => { + beforeEach(() => { + vi.resetModules(); + vi.doUnmock('../billing-sync.json'); + }); + + afterEach(() => { + vi.doUnmock('../billing-sync.json'); + vi.resetModules(); + }); + + it('planAnnualSavingsCopy returns percent when annual discount is not whole months', async () => { + vi.doMock('../billing-sync.json', () => ({ + default: { + plans: [ + { + planId: 'frac', + prices: [ + { unitAmount: 1000, interval: 'month' }, + { unitAmount: 10500, interval: 'year' }, + ], + }, + ], + }, + })); + const { planAnnualSavingsCopy } = await import('../billingSyncDisplay'); + expect(planAnnualSavingsCopy('frac', 1000)).toEqual({ + kind: 'percent', + pct: 13, + }); + }); + + it('planAnnualSavingsCopy returns none when rounded percent is zero', async () => { + vi.doMock('../billing-sync.json', () => ({ + default: { + plans: [ + { + planId: 'tiny', + prices: [ + { unitAmount: 10000, interval: 'month' }, + { unitAmount: 119700, interval: 'year' }, + ], + }, + ], + }, + })); + const { planAnnualSavingsCopy } = await import('../billingSyncDisplay'); + expect(planAnnualSavingsCopy('tiny', 10000)).toEqual({ kind: 'none' }); + }); + + it('cadenceAnnualSavingsFromPlans returns months_range when month-free counts differ', async () => { + vi.doMock('../billing-sync.json', () => ({ + default: { + plans: [ + { + planId: 'mo_lo', + prices: [ + { unitAmount: 1000, interval: 'month' }, + { unitAmount: 10000, interval: 'year' }, + ], + }, + { + planId: 'mo_hi', + prices: [ + { unitAmount: 1000, interval: 'month' }, + { unitAmount: 9000, interval: 'year' }, + ], + }, + ], + }, + })); + const { cadenceAnnualSavingsFromPlans } = + await import('../billingSyncDisplay'); + const plans: Plan[] = [ + basePaidPlan({ id: 'mo_lo', display_order: 1 }), + basePaidPlan({ id: 'mo_hi', display_order: 2 }), + ]; + expect(cadenceAnnualSavingsFromPlans(plans)).toEqual({ + kind: 'months_range', + max: 3, + }); + }); + + it('cadenceAnnualSavingsFromPlans returns single percent when all plans agree on percent savings', async () => { + vi.doMock('../billing-sync.json', () => ({ + default: { + plans: [ + { + planId: 'pct_a', + prices: [ + { unitAmount: 1000, interval: 'month' }, + { unitAmount: 10800, interval: 'year' }, + ], + }, + { + planId: 'pct_b', + prices: [ + { unitAmount: 2000, interval: 'month' }, + { unitAmount: 21600, interval: 'year' }, + ], + }, + ], + }, + })); + const { cadenceAnnualSavingsFromPlans } = + await import('../billingSyncDisplay'); + const plans: Plan[] = [ + basePaidPlan({ id: 'pct_a', price_cents: 1000, display_order: 1 }), + basePaidPlan({ id: 'pct_b', price_cents: 2000, display_order: 2 }), + ]; + expect(cadenceAnnualSavingsFromPlans(plans)).toEqual({ + kind: 'percent', + pct: 10, + }); + }); + + it('cadenceAnnualSavingsFromPlans returns percent_range when percent savings spread is wide', async () => { + vi.doMock('../billing-sync.json', () => ({ + default: { + plans: [ + { + planId: 'wide_a', + prices: [ + { unitAmount: 1000, interval: 'month' }, + { unitAmount: 10800, interval: 'year' }, + ], + }, + { + planId: 'wide_b', + prices: [ + { unitAmount: 1000, interval: 'month' }, + { unitAmount: 9600, interval: 'year' }, + ], + }, + ], + }, + })); + const { cadenceAnnualSavingsFromPlans } = + await import('../billingSyncDisplay'); + const plans: Plan[] = [ + basePaidPlan({ id: 'wide_a', price_cents: 1000, display_order: 1 }), + basePaidPlan({ id: 'wide_b', price_cents: 1000, display_order: 2 }), + ]; + expect(cadenceAnnualSavingsFromPlans(plans)).toEqual({ + kind: 'percent_range', + max: 20, + }); + }); + + it('cadenceAnnualSavingsFromPlans averages percent when spread is at most one point', async () => { + vi.doMock('../billing-sync.json', () => ({ + default: { + plans: [ + { + planId: 'tight_a', + prices: [ + { unitAmount: 1000, interval: 'month' }, + { unitAmount: 10800, interval: 'year' }, + ], + }, + { + planId: 'tight_b', + prices: [ + { unitAmount: 1000, interval: 'month' }, + { unitAmount: 10700, interval: 'year' }, + ], + }, + ], + }, + })); + const { cadenceAnnualSavingsFromPlans } = + await import('../billingSyncDisplay'); + const plans: Plan[] = [ + basePaidPlan({ id: 'tight_a', price_cents: 1000, display_order: 1 }), + basePaidPlan({ id: 'tight_b', price_cents: 1000, display_order: 2 }), + ]; + expect(cadenceAnnualSavingsFromPlans(plans)).toEqual({ + kind: 'percent', + pct: 11, + }); + }); + + it('cadenceAnnualSavingsFromPlans falls back to annual percent range when mix of months and percent', async () => { + vi.doMock('../billing-sync.json', () => ({ + default: { + plans: [ + { + planId: 'mix_m', + prices: [ + { unitAmount: 1000, interval: 'month' }, + { unitAmount: 10000, interval: 'year' }, + ], + }, + { + planId: 'mix_p', + prices: [ + { unitAmount: 1000, interval: 'month' }, + { unitAmount: 10800, interval: 'year' }, + ], + }, + ], + }, + })); + const { cadenceAnnualSavingsFromPlans } = + await import('../billingSyncDisplay'); + const plans: Plan[] = [ + basePaidPlan({ id: 'mix_m', price_cents: 1000, display_order: 1 }), + basePaidPlan({ id: 'mix_p', price_cents: 1000, display_order: 2 }), + ]; + expect(cadenceAnnualSavingsFromPlans(plans)).toEqual({ + kind: 'percent_range', + max: 17, + }); + }); +}); diff --git a/apps/web/src/billing/__tests__/billingSyncDisplay.test.ts b/apps/web/src/billing/__tests__/billingSyncDisplay.test.ts new file mode 100644 index 00000000..ca417b55 --- /dev/null +++ b/apps/web/src/billing/__tests__/billingSyncDisplay.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from 'vitest'; +import type { Plan } from '@beakerstack/billing'; +import { + annualListCentsFromSync, + annualSavingsPercentForPlan, + cadenceAnnualSavingsFromPlans, + formatCadenceAnnualButtonLabel, + formatCadenceToggleSavingsBadge, + formatSavingsCalloutFromCopy, + monthlyListCentsFromSync, + planAnnualSavingsCopy, +} from '../billingSyncDisplay'; + +describe('billingSyncDisplay', () => { + it('monthlyListCentsFromSync uses sync JSON when plan exists', () => { + expect(monthlyListCentsFromSync('beakerstack_pro', 99999)).toBe(1900); + expect(monthlyListCentsFromSync('beakerstack_max', 1)).toBe(4900); + }); + + it('monthlyListCentsFromSync falls back when plan is missing', () => { + expect(monthlyListCentsFromSync('unknown_plan', 4200)).toBe(4200); + }); + + it('annualListCentsFromSync prefers yearly row in JSON', () => { + expect(annualListCentsFromSync('beakerstack_pro', 1900)).toBe(19000); + }); + + it('annualListCentsFromSync uses 12× monthly when no yearly row', () => { + expect(annualListCentsFromSync('unknown_plan', 1000)).toBe(12000); + }); + + it('planAnnualSavingsCopy returns months when discount matches whole months', () => { + expect(planAnnualSavingsCopy('beakerstack_pro', 1900)).toEqual({ + kind: 'months', + months: 2, + }); + }); + + it('planAnnualSavingsCopy returns none when annual is not cheaper', () => { + expect(planAnnualSavingsCopy('unknown_plan', 0)).toEqual({ kind: 'none' }); + }); + + it('formatSavingsCalloutFromCopy maps copy kinds', () => { + expect(formatSavingsCalloutFromCopy({ kind: 'none' })).toBeNull(); + expect(formatSavingsCalloutFromCopy({ kind: 'months', months: 1 })).toBe( + '1 Month Free' + ); + expect(formatSavingsCalloutFromCopy({ kind: 'percent', pct: 15 })).toBe( + 'Save 15%' + ); + }); + + it('formatCadenceToggleSavingsBadge covers CadenceSavingsLabel variants', () => { + expect(formatCadenceToggleSavingsBadge({ kind: 'none' })).toBeNull(); + expect(formatCadenceToggleSavingsBadge({ kind: 'months', months: 3 })).toBe( + '3 Months Free' + ); + expect( + formatCadenceToggleSavingsBadge({ kind: 'months_range', max: 4 }) + ).toBe('Up to 4 Months Free'); + expect(formatCadenceToggleSavingsBadge({ kind: 'percent', pct: 12 })).toBe( + 'Save 12%' + ); + expect( + formatCadenceToggleSavingsBadge({ kind: 'percent_range', max: 20 }) + ).toBe('Save up to 20%'); + }); + + it('formatCadenceAnnualButtonLabel prefixes Annually when badge present', () => { + expect(formatCadenceAnnualButtonLabel({ kind: 'none' })).toBe('Annually'); + expect(formatCadenceAnnualButtonLabel({ kind: 'percent', pct: 10 })).toBe( + 'Annually · Save 10%' + ); + }); + + it('annualSavingsPercentForPlan returns percent or null', () => { + const p = annualSavingsPercentForPlan('beakerstack_pro', 1900); + expect(p).not.toBeNull(); + expect(p).toBeGreaterThan(0); + expect(annualSavingsPercentForPlan('unknown_plan', 0)).toBeNull(); + }); + + it('cadenceAnnualSavingsFromPlans aggregates paid plans', () => { + const plans: Plan[] = [ + { + id: 'beakerstack_pro', + product_id: 'beakerstack', + display_name: 'Pro', + description: null, + price_cents: 1900, + billing_period: 'monthly', + stripe_price_id_monthly: null, + stripe_price_id_annual: null, + stripe_product_id: null, + features: {}, + usage_limits: {}, + trial_period_days: 0, + is_public: true, + display_order: 2, + }, + { + id: 'beakerstack_max', + product_id: 'beakerstack', + display_name: 'Max', + description: null, + price_cents: 4900, + billing_period: 'monthly', + stripe_price_id_monthly: null, + stripe_price_id_annual: null, + stripe_product_id: null, + features: {}, + usage_limits: {}, + trial_period_days: 0, + is_public: true, + display_order: 3, + }, + ]; + const agg = cadenceAnnualSavingsFromPlans(plans); + expect(agg.kind).not.toBe('none'); + }); +}); diff --git a/apps/web/src/billing/__tests__/constraintBlockers.test.ts b/apps/web/src/billing/__tests__/constraintBlockers.test.ts new file mode 100644 index 00000000..39f7f7f0 --- /dev/null +++ b/apps/web/src/billing/__tests__/constraintBlockers.test.ts @@ -0,0 +1,313 @@ +import { describe, expect, it } from 'vitest'; +import type { Plan } from '@beakerstack/billing'; +import type { ProductBillingConfig } from '@beakerstack/billing'; +import { computeDowngradeBlockers } from '../constraintBlockers'; + +const plan = (over: Partial): Plan => ({ + id: 'id', + product_id: 'beakerstack', + display_name: 'Name', + description: null, + price_cents: 0, + billing_period: 'monthly', + stripe_price_id_monthly: null, + stripe_price_id_annual: null, + stripe_product_id: null, + features: { + containers_per_account_max: 10, + feature_b: false, + }, + usage_limits: { ai_summarize: 100 }, + trial_period_days: 0, + is_public: true, + display_order: 1, + ...over, +}); + +const billingConfig: ProductBillingConfig = { + productId: 'beakerstack', + displayName: 'BeakerStack', + plans: [ + { + id: 'free', + displayName: 'Free', + priceCents: 0, + billingPeriod: 'free', + features: { + containers_per_account_max: 2, + feature_b: false, + }, + usageLimits: { ai_summarize: 30 }, + }, + { + id: 'max', + displayName: 'Max', + priceCents: 4900, + billingPeriod: 'monthly', + features: { + containers_per_account_max: -1, + feature_b: true, + }, + usageLimits: { ai_summarize: -1 }, + }, + ], + downgradeConstraintCopy: { + collectionsOverCap: + '{current} vs {cap} on {targetPlan}, delete {deleteCount}', + booleanFeatureLoss: + '{featureLabel} only on {exclusivePlanName} to {targetPlanName}', + meterOverCap: '{used} over {limit} for {targetPlan}', + }, +}; + +const defaultOptions = ( + o: Partial[2]> +) => ({ + collectionCount: 0, + maxItemsInAnyCollection: 0, + aiUsedThisPeriod: 0, + ...o, +}); + +describe('computeDowngradeBlockers', () => { + const allPlans = [ + plan({ + id: 'free', + display_name: 'Free', + display_order: 1, + features: { + containers_per_account_max: 2, + items_per_container_max: 3, + feature_b: false, + }, + usage_limits: { ai_summarize: 30 }, + }), + plan({ + id: 'max', + display_name: 'Max', + display_order: 3, + features: { + containers_per_account_max: -1, + feature_b: true, + }, + usage_limits: { ai_summarize: -1 }, + }), + ]; + + it('adds collections over cap message to hard', () => { + const current = plan({ + id: 'pro', + display_name: 'Pro', + display_order: 2, + features: { + containers_per_account_max: -1, + feature_b: false, + }, + usage_limits: { ai_summarize: 500 }, + }); + const target = allPlans[0]; + const blockers = computeDowngradeBlockers( + current, + target, + defaultOptions({ collectionCount: 5, aiUsedThisPeriod: 10 }), + [...allPlans, current], + billingConfig + ); + expect(blockers.hard).toHaveLength(1); + expect(blockers.hard[0]).toContain('5'); + expect(blockers.hard[0]).toContain('2'); + expect(blockers.hard[0]).toContain('Free'); + expect(blockers.hard[0]).toContain('3'); + expect(blockers.soft).toHaveLength(0); + }); + + it('adds boolean feature loss to soft when downgrading from feature_b', () => { + const current = plan({ + id: 'max', + display_name: 'Max', + display_order: 3, + features: { + containers_per_account_max: -1, + feature_b: true, + }, + usage_limits: { ai_summarize: -1 }, + }); + const target = plan({ + id: 'pro', + display_name: 'Pro', + display_order: 2, + features: { + containers_per_account_max: -1, + feature_b: false, + }, + usage_limits: { ai_summarize: 500 }, + }); + const blockers = computeDowngradeBlockers( + current, + target, + defaultOptions({}), + [current, target], + billingConfig + ); + expect(blockers.hard).toHaveLength(0); + expect(blockers.soft.some(b => b.includes('Max'))).toBe(true); + }); + + it('adds items-per-collection overage to hard', () => { + const free = allPlans[0]; + const current = allPlans[1]; + const blockers = computeDowngradeBlockers( + current, + free, + defaultOptions({ collectionCount: 2, maxItemsInAnyCollection: 4 }), + allPlans, + billingConfig + ); + expect( + blockers.hard.some( + m => m.includes('4') && m.includes('3') && m.includes('Free') + ) + ).toBe(true); + }); + + it('skips items check when target has unlimited items per collection', () => { + const target = plan({ + features: { + containers_per_account_max: 2, + items_per_container_max: -1, + feature_b: false, + }, + }); + const current = plan({ + features: { containers_per_account_max: -1, feature_b: true }, + }); + const blockers = computeDowngradeBlockers( + current, + target, + defaultOptions({ maxItemsInAnyCollection: 99 }), + [current, target], + billingConfig + ); + expect( + blockers.hard.filter( + m => m.includes('99') || m.includes('items per collection') + ) + ).toHaveLength(0); + }); + + it('adds meter over cap for ai_summarize to hard', () => { + const current = allPlans[1]; + const target = allPlans[0]; + const blockers = computeDowngradeBlockers( + current, + target, + defaultOptions({ aiUsedThisPeriod: 50 }), + allPlans, + billingConfig + ); + expect(blockers.hard.some(b => b.includes('50') && b.includes('30'))).toBe( + true + ); + }); + + it('returns empty hard and soft when no blockers apply', () => { + const current = allPlans[0]; + const target = allPlans[0]; + const b = computeDowngradeBlockers( + current, + target, + defaultOptions({}), + allPlans, + billingConfig + ); + expect(b.hard).toEqual([]); + expect(b.soft).toEqual([]); + }); + + it('skips collection check when target cap is negative', () => { + const target = plan({ + features: { containers_per_account_max: -1, feature_b: false }, + }); + const current = plan({ + features: { containers_per_account_max: -1, feature_b: false }, + }); + const b = computeDowngradeBlockers( + current, + target, + defaultOptions({ collectionCount: 99 }), + [current, target], + billingConfig + ); + expect(b.hard).toEqual([]); + expect(b.soft).toEqual([]); + }); + + it('adds feature_a to soft when pro downgrades to free (boolean loop)', () => { + const proP = plan({ + id: 'beakerstack_pro', + display_name: 'Pro', + display_order: 2, + features: { + feature_a: true, + feature_b: false, + containers_per_account_max: -1, + items_per_container_max: 25, + }, + usage_limits: { ai_summarize: 500 }, + }); + const freeP = plan({ + id: 'beakerstack_free', + display_name: 'Free', + display_order: 1, + features: { + feature_a: false, + feature_b: false, + containers_per_account_max: 2, + items_per_container_max: 3, + }, + usage_limits: { ai_summarize: 30 }, + }); + const blockers = computeDowngradeBlockers( + proP, + freeP, + defaultOptions({ collectionCount: 0, maxItemsInAnyCollection: 0 }), + [freeP, proP], + billingConfig + ); + expect( + blockers.soft.some( + m => m.includes('Feature A') && m.includes('Pro') && m.includes('Free') + ) + ).toBe(true); + }); + + it('uses fallback exclusive plan name when no plan advertises the boolean feature', () => { + const current = plan({ + id: 'cur', + display_name: 'Current', + features: { + containers_per_account_max: -1, + feature_b: true, + }, + usage_limits: { ai_summarize: 100 }, + }); + const target = plan({ + id: 'tgt', + display_name: 'Target', + features: { + containers_per_account_max: -1, + feature_b: false, + }, + usage_limits: { ai_summarize: 100 }, + }); + const blockers = computeDowngradeBlockers( + current, + target, + defaultOptions({}), + [target], + billingConfig + ); + expect(blockers.hard).toHaveLength(0); + expect(blockers.soft.some(b => b.includes('a higher tier'))).toBe(true); + }); +}); diff --git a/apps/web/src/billing/__tests__/formatters.test.ts b/apps/web/src/billing/__tests__/formatters.test.ts new file mode 100644 index 00000000..0317a34a --- /dev/null +++ b/apps/web/src/billing/__tests__/formatters.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it, vi, afterEach } from 'vitest'; +import { formatDate, formatMoneyCents, formatMonthYear } from '../formatters'; + +describe('formatMoneyCents', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('formats USD cents', () => { + expect(formatMoneyCents(1999, 'usd')).toMatch(/\$19\.99/); + }); + + it('normalizes short currency codes', () => { + expect(formatMoneyCents(100, 'eur')).toMatch(/€|EUR/); + }); + + it('falls back when Intl.NumberFormat throws', () => { + vi.spyOn(Intl, 'NumberFormat').mockImplementation(() => { + throw new Error('unsupported'); + }); + expect(formatMoneyCents(1234, 'usd')).toBe('$12.34'); + }); +}); + +describe('formatDate', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('formats valid ISO strings', () => { + const s = formatDate('2024-06-15T12:00:00.000Z'); + expect(s.length).toBeGreaterThan(4); + }); + + it('handles invalid date strings without throwing', () => { + expect(formatDate('not-a-date')).toBeTruthy(); + }); + + it('returns original string when toLocaleDateString throws', () => { + vi.spyOn(Date.prototype, 'toLocaleDateString').mockImplementation(() => { + throw new Error('locale'); + }); + expect(formatDate('2024-06-15T12:00:00.000Z')).toBe( + '2024-06-15T12:00:00.000Z' + ); + }); +}); + +describe('formatMonthYear', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('formats valid ISO strings', () => { + expect(formatMonthYear('2024-03-01T00:00:00.000Z')).toMatch(/2024/); + }); + + it('handles invalid date strings without throwing', () => { + expect(formatMonthYear('bad')).toBeTruthy(); + }); + + it('returns original string when toLocaleDateString throws', () => { + vi.spyOn(Date.prototype, 'toLocaleDateString').mockImplementation(() => { + throw new Error('locale'); + }); + expect(formatMonthYear('2024-03-01T00:00:00.000Z')).toBe( + '2024-03-01T00:00:00.000Z' + ); + }); +}); diff --git a/apps/web/src/billing/__tests__/planPresentation.test.ts b/apps/web/src/billing/__tests__/planPresentation.test.ts new file mode 100644 index 00000000..ec9620ea --- /dev/null +++ b/apps/web/src/billing/__tests__/planPresentation.test.ts @@ -0,0 +1,275 @@ +import { describe, expect, it } from 'vitest'; +import type { Plan } from '@beakerstack/billing'; +import type { ProductBillingConfig } from '@beakerstack/billing'; +import { + applyTemplate, + booleanFeatureLabel, + DEFAULT_PLAN_FEATURE_ROWS, + exclusiveBooleanFeaturePlanName, + mergeDowngradeConstraintCopy, + mergePlanFeatureRows, + mergeUsageLimitsCopy, + mergeUsageMeterCopy, + planFeatureLine, +} from '../planPresentation'; + +const basePlan = (over: Partial): Plan => ({ + id: 'p1', + product_id: 'prod', + display_name: 'Test', + description: null, + price_cents: 0, + billing_period: 'free', + stripe_price_id_monthly: null, + stripe_price_id_annual: null, + stripe_product_id: null, + features: {}, + usage_limits: {}, + trial_period_days: 0, + is_public: true, + display_order: 1, + ...over, +}); + +const minimalConfig = ( + overrides: Partial = {} +): ProductBillingConfig => ({ + productId: 'demo', + displayName: 'Demo', + plans: [ + { + id: 'free', + displayName: 'Free', + priceCents: 0, + billingPeriod: 'free', + features: { feature_a: false, feature_b: false, x: 0 }, + usageLimits: { ai_summarize: 10 }, + }, + ], + ...overrides, +}); + +describe('applyTemplate', () => { + it('replaces known placeholders', () => { + expect(applyTemplate('a {b} c', { b: 2 })).toBe('a 2 c'); + }); + + it('uses empty string for missing keys', () => { + expect(applyTemplate('{missing}', { other: 1 })).toBe(''); + }); +}); + +describe('mergePlanFeatureRows', () => { + it('returns defaults when planFeatureRows is omitted', () => { + const cfg = minimalConfig(); + expect(mergePlanFeatureRows(cfg)).toEqual(DEFAULT_PLAN_FEATURE_ROWS); + }); + + it('returns defaults when planFeatureRows is empty', () => { + const cfg = minimalConfig({ planFeatureRows: [] }); + expect(mergePlanFeatureRows(cfg)).toEqual(DEFAULT_PLAN_FEATURE_ROWS); + }); + + it('returns custom rows when provided', () => { + const rows = [ + { + id: '1', + featureKey: 'feature_a', + kind: 'boolean' as const, + label: 'Custom A', + }, + ]; + expect( + mergePlanFeatureRows(minimalConfig({ planFeatureRows: rows })) + ).toEqual(rows); + }); +}); + +describe('mergeDowngradeConstraintCopy', () => { + it('fills defaults for missing fields', () => { + const m = mergeDowngradeConstraintCopy(minimalConfig()); + expect(m.collectionsOverCap).toContain('{current}'); + expect(m.booleanFeatureLoss).toContain('{featureLabel}'); + expect(m.booleanFeatureLoss).toContain('{targetPlanName}'); + expect(m.meterOverCap).toContain('{used}'); + expect(m.itemsPerCollectionOverCap).toContain('{maxItems}'); + }); + + it('merges partial overrides', () => { + const m = mergeDowngradeConstraintCopy( + minimalConfig({ + downgradeConstraintCopy: { + meterOverCap: 'Custom {used}', + }, + }) + ); + expect(m.meterOverCap).toBe('Custom {used}'); + expect(m.collectionsOverCap).toContain('collections'); + }); +}); + +describe('planFeatureLine', () => { + const plan = basePlan({ + features: { + feature_a: true, + feature_b: false, + cap: 5, + unlimited: -1, + zero: 0, + bad: 'x' as unknown as number, + }, + }); + + it('handles boolean rows', () => { + expect( + planFeatureLine(plan, { + id: 'a', + featureKey: 'feature_a', + kind: 'boolean', + label: 'A', + }) + ).toEqual({ ok: true, text: 'A' }); + expect( + planFeatureLine(plan, { + id: 'b', + featureKey: 'feature_b', + kind: 'boolean', + label: 'B', + }) + ).toEqual({ ok: false, text: 'B' }); + }); + + it('treats -1 as unlimited for number rows', () => { + expect( + planFeatureLine(plan, { + id: 'u', + featureKey: 'unlimited', + kind: 'number', + unlimitedLabel: 'All', + limitedLabelTemplate: 'Up to {count}', + }) + ).toEqual({ ok: true, text: 'All' }); + }); + + it('formats positive caps', () => { + expect( + planFeatureLine(plan, { + id: 'c', + featureKey: 'cap', + kind: 'number', + unlimitedLabel: 'All', + limitedLabelTemplate: '{count} max', + }) + ).toEqual({ ok: true, text: '5 max' }); + }); + + it('treats zero and non-number as not ok with limited template', () => { + expect( + planFeatureLine(plan, { + id: 'z', + featureKey: 'zero', + kind: 'number', + unlimitedLabel: 'All', + limitedLabelTemplate: '{count} max', + }) + ).toEqual({ ok: false, text: '0 max' }); + + expect( + planFeatureLine(plan, { + id: 'bad', + featureKey: 'bad', + kind: 'number', + unlimitedLabel: 'All', + limitedLabelTemplate: '{count} max', + }) + ).toEqual({ ok: false, text: '0 max' }); + }); +}); + +describe('booleanFeatureLabel', () => { + it('returns row label when present', () => { + expect(booleanFeatureLabel(minimalConfig(), 'feature_a')).toBe('Feature A'); + }); + + it('falls back to feature key when no boolean row matches', () => { + expect(booleanFeatureLabel(minimalConfig(), 'unknown_key')).toBe( + 'unknown_key' + ); + }); +}); + +describe('exclusiveBooleanFeaturePlanName', () => { + const plans: Plan[] = [ + basePlan({ + id: '1', + display_name: 'Low', + display_order: 1, + features: { feature_b: false }, + }), + basePlan({ + id: '2', + display_name: 'High', + display_order: 3, + features: { feature_b: true }, + }), + basePlan({ + id: '3', + display_name: 'Mid', + display_order: 2, + features: { feature_b: 1 }, + }), + ]; + + it('returns null when no plan has the feature', () => { + expect(exclusiveBooleanFeaturePlanName([plans[0]], 'feature_b')).toBeNull(); + }); + + it('picks highest display_order among holders', () => { + expect(exclusiveBooleanFeaturePlanName(plans, 'feature_b')).toBe('High'); + }); + + it('returns null when top holder has no display name', () => { + const holderNoName = basePlan({ + id: 'top', + display_name: null as unknown as string, + display_order: 9, + features: { feature_b: true }, + }); + expect( + exclusiveBooleanFeaturePlanName([holderNoName], 'feature_b') + ).toBeNull(); + }); +}); + +describe('mergeUsageMeterCopy', () => { + it('starts from defaults and merges overrides', () => { + const m = mergeUsageMeterCopy(minimalConfig()); + expect(m.ai_summarize?.label).toBe('AI summarize'); + + const m2 = mergeUsageMeterCopy( + minimalConfig({ + usageMeterCopy: { + ai_summarize: { label: 'Renamed', description: 'D' }, + other: { label: 'Other' }, + }, + }) + ); + expect(m2.ai_summarize).toEqual({ + label: 'Renamed', + description: 'D', + }); + expect(m2.other).toEqual({ label: 'Other' }); + }); +}); + +describe('mergeUsageLimitsCopy', () => { + it('merges over defaults', () => { + const m = mergeUsageLimitsCopy( + minimalConfig({ + usageLimitsCopy: { collectionsRowName: 'Cols' }, + }) + ); + expect(m.collectionsRowName).toBe('Cols'); + expect(m.itemsRowName).toContain('Items'); + }); +}); diff --git a/apps/web/src/billing/__tests__/useDemoCollectionCount.test.tsx b/apps/web/src/billing/__tests__/useDemoCollectionCount.test.tsx new file mode 100644 index 00000000..a4cf07c2 --- /dev/null +++ b/apps/web/src/billing/__tests__/useDemoCollectionCount.test.tsx @@ -0,0 +1,74 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { act, renderHook, waitFor } from '@testing-library/react'; +import { useDemoCollectionCount } from '../useDemoCollectionCount'; + +const { rpc, mockClient } = vi.hoisted(() => { + const rpc = vi.fn(); + const mockClient = { rpc }; + return { rpc, mockClient }; +}); + +vi.mock('../../lib/supabase', () => ({ + supabase: mockClient, + supabaseRpc: mockClient, +})); + +describe('useDemoCollectionCount', () => { + beforeEach(() => { + rpc.mockReset(); + }); + + it('returns count and maxItemsInAnyCollection from RPC rows', async () => { + rpc.mockResolvedValue({ + data: [ + { id: '1', item_count: 3 }, + { id: '2', item_count: 8 }, + ], + error: null, + }); + const { result } = renderHook(() => useDemoCollectionCount()); + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + expect(result.current.count).toBe(2); + expect(result.current.maxItemsInAnyCollection).toBe(8); + }); + + it('returns 0 when RPC errors', async () => { + rpc.mockResolvedValue({ data: null, error: { message: 'fail' } }); + const { result } = renderHook(() => useDemoCollectionCount()); + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + expect(result.current.count).toBe(0); + expect(result.current.maxItemsInAnyCollection).toBe(0); + }); + + it('refresh refetches and updates max items', async () => { + rpc + .mockResolvedValueOnce({ + data: [{ id: 'a', item_count: 2 }], + error: null, + }) + .mockResolvedValueOnce({ + data: [ + { id: 'a', item_count: 2 }, + { id: 'b', item_count: 15 }, + ], + error: null, + }); + const { result } = renderHook(() => useDemoCollectionCount()); + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + expect(result.current.maxItemsInAnyCollection).toBe(2); + await act(async () => { + await result.current.refresh(); + }); + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + expect(result.current.count).toBe(2); + expect(result.current.maxItemsInAnyCollection).toBe(15); + }); +}); diff --git a/apps/web/src/billing/__tests__/useDemoCollections.test.tsx b/apps/web/src/billing/__tests__/useDemoCollections.test.tsx new file mode 100644 index 00000000..f03afba3 --- /dev/null +++ b/apps/web/src/billing/__tests__/useDemoCollections.test.tsx @@ -0,0 +1,183 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { act, renderHook, waitFor } from '@testing-library/react'; +import { useDemoCollections } from '../useDemoCollections'; + +const { rpc, mockClient } = vi.hoisted(() => { + const rpc = vi.fn(); + const mockClient = { rpc }; + return { rpc, mockClient }; +}); + +vi.mock('../../lib/supabase', () => ({ + supabase: mockClient, + supabaseRpc: mockClient, +})); + +describe('useDemoCollections', () => { + beforeEach(() => { + rpc.mockReset(); + }); + + it('maps RPC rows and clears error on success', async () => { + rpc.mockResolvedValue({ + data: [ + { id: 'c1', item_count: 2 }, + { id: 99, item_count: null }, + ], + error: null, + }); + const { result } = renderHook(() => useDemoCollections()); + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + expect(result.current.error).toBeNull(); + expect(result.current.collections).toEqual([ + { id: 'c1', item_count: 2 }, + { id: '99', item_count: 0 }, + ]); + }); + + it('sets error and empty collections when RPC returns error', async () => { + rpc.mockResolvedValue({ data: null, error: { message: 'rpc failed' } }); + const { result } = renderHook(() => useDemoCollections()); + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + // Thrown Postgrest-style objects are not `instanceof Error` + expect(result.current.error).toBe('Could not load demo collections.'); + expect(result.current.collections).toEqual([]); + }); + + it('sets generic error when throw is non-Error', async () => { + rpc.mockRejectedValue('boom'); + const { result } = renderHook(() => useDemoCollections()); + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + expect(result.current.error).toBe('Could not load demo collections.'); + expect(result.current.collections).toEqual([]); + }); + + it('refresh refetches after initial load', async () => { + rpc + .mockResolvedValueOnce({ + data: [{ id: 'a', item_count: 1 }], + error: null, + }) + .mockResolvedValueOnce({ + data: [ + { id: 'a', item_count: 1 }, + { id: 'b', item_count: 5 }, + ], + error: null, + }); + const { result } = renderHook(() => useDemoCollections()); + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + expect(result.current.collections).toHaveLength(1); + await act(async () => { + await result.current.refresh(); + }); + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + expect(result.current.collections).toHaveLength(2); + }); + + it('addCollection calls RPC then refetches', async () => { + rpc + .mockResolvedValueOnce({ data: [], error: null }) + .mockResolvedValueOnce({ data: null, error: null }) + .mockResolvedValueOnce({ + data: [{ id: 'new', item_count: 0 }], + error: null, + }); + const { result } = renderHook(() => useDemoCollections()); + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + await act(async () => { + await result.current.addCollection(); + }); + await waitFor(() => { + expect(result.current.collections.some(c => c.id === 'new')).toBe(true); + }); + expect(rpc).toHaveBeenCalledWith( + 'billing_demo_add_collection', + expect.objectContaining({ p_product_id: 'beakerstack' }) + ); + }); + + it('deleteCollection calls RPC then refetches', async () => { + rpc + .mockResolvedValueOnce({ + data: [{ id: 'x', item_count: 1 }], + error: null, + }) + .mockResolvedValueOnce({ data: null, error: null }) + .mockResolvedValueOnce({ data: [], error: null }); + const { result } = renderHook(() => useDemoCollections()); + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + await act(async () => { + await result.current.deleteCollection('x'); + }); + await waitFor(() => { + expect(result.current.collections).toEqual([]); + }); + expect(rpc).toHaveBeenCalledWith( + 'billing_demo_delete_collection', + expect.objectContaining({ + p_product_id: 'beakerstack', + p_collection_id: 'x', + }) + ); + }); + + it('addItem calls RPC then refetches', async () => { + rpc + .mockResolvedValueOnce({ + data: [{ id: 'col', item_count: 0 }], + error: null, + }) + .mockResolvedValueOnce({ data: null, error: null }) + .mockResolvedValueOnce({ + data: [{ id: 'col', item_count: 1 }], + error: null, + }); + const { result } = renderHook(() => useDemoCollections()); + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + await act(async () => { + await result.current.addItem('col'); + }); + await waitFor(() => { + expect(result.current.collections[0]?.item_count).toBe(1); + }); + expect(rpc).toHaveBeenCalledWith( + 'billing_demo_add_item', + expect.objectContaining({ + p_product_id: 'beakerstack', + p_collection_id: 'col', + }) + ); + }); + + it('propagates RPC error from addCollection', async () => { + rpc.mockResolvedValueOnce({ data: [], error: null }); + rpc.mockResolvedValueOnce({ + data: null, + error: { message: 'add denied' }, + }); + const { result } = renderHook(() => useDemoCollections()); + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + await expect(result.current.addCollection()).rejects.toEqual({ + message: 'add denied', + }); + }); +}); diff --git a/apps/web/src/billing/beakerstackBillingConfig.ts b/apps/web/src/billing/beakerstackBillingConfig.ts new file mode 100644 index 00000000..6c957e98 --- /dev/null +++ b/apps/web/src/billing/beakerstackBillingConfig.ts @@ -0,0 +1,134 @@ +import { + defineBillingConfig, + type InferFeatureKeys, +} from '@beakerstack/billing'; + +/** + * Template-owned billing config (Free / Pro / Max). IDs match `supabase/seed.sql`. + * Downstream clones rename keys and copy here — do not embed in `@beakerstack/billing`. + * Stripe price IDs live in `public.billing_plans` (synced via `npm run billing:sync-stripe`). + */ +export const beakerstackBillingConfig = defineBillingConfig({ + productId: 'beakerstack', + displayName: 'BeakerStack', + description: 'Template demo', + plans: [ + { + id: 'beakerstack_free', + displayName: 'Free', + description: 'Starter', + planCardTagline: 'For getting started', + priceCents: 0, + billingPeriod: 'free', + stripePriceIdMonthly: null, + stripePriceIdAnnual: null, + stripeProductId: null, + features: { + containers_per_account_max: 2, + items_per_container_max: 3, + feature_a: false, + feature_b: false, + }, + usageLimits: { + ai_summarize: 30, + }, + trialPeriodDays: 0, + isPublic: true, + displayOrder: 1, + }, + { + id: 'beakerstack_pro', + displayName: 'Pro', + description: 'More capacity', + planCardTagline: 'For active users', + priceCents: 1900, + billingPeriod: 'monthly', + stripePriceIdMonthly: null, + stripePriceIdAnnual: null, + stripeProductId: null, + features: { + containers_per_account_max: -1, + items_per_container_max: 25, + feature_a: true, + feature_b: false, + }, + usageLimits: { + ai_summarize: 500, + }, + trialPeriodDays: 0, + isPublic: true, + displayOrder: 2, + }, + { + id: 'beakerstack_max', + displayName: 'Max', + description: 'Everything', + planCardTagline: 'For power users', + priceCents: 4900, + billingPeriod: 'monthly', + stripePriceIdMonthly: null, + stripePriceIdAnnual: null, + stripeProductId: null, + features: { + containers_per_account_max: -1, + items_per_container_max: -1, + feature_a: true, + feature_b: true, + }, + usageLimits: { + ai_summarize: -1, + }, + trialPeriodDays: 5, + isPublic: true, + displayOrder: 3, + }, + ], + planFeatureRows: [ + { + id: 'feature_a', + featureKey: 'feature_a', + kind: 'boolean', + label: 'Feature A', + }, + { + id: 'feature_b', + featureKey: 'feature_b', + kind: 'boolean', + label: 'Feature B', + }, + { + id: 'containers', + featureKey: 'containers_per_account_max', + kind: 'number', + unlimitedLabel: 'Unlimited collections', + limitedLabelTemplate: 'Up to {count} collections', + }, + { + id: 'items', + featureKey: 'items_per_container_max', + kind: 'number', + unlimitedLabel: 'Unlimited items per collection', + limitedLabelTemplate: 'Up to {count} items per collection', + }, + ], + usageMeterCopy: { + ai_summarize: { + label: 'AI summarize', + description: 'Summaries generated in this app count toward this meter.', + }, + }, + usageLimitsCopy: { + collectionsRowName: 'Collections', + itemsRowName: 'Items per collection (max in one collection)', + collectionsFootnote: + 'Collection counts use the template demo; wire your product for real item counts.', + }, +}); + +export type BeakerstackBillingConfig = typeof beakerstackBillingConfig; + +export const BEAKERSTACK_METER_AI_SUMMARIZE = 'ai_summarize' as const; + +export type BeakerstackFeatureKey = InferFeatureKeys< + typeof beakerstackBillingConfig +>; diff --git a/apps/web/src/billing/billing-sync.json b/apps/web/src/billing/billing-sync.json new file mode 100644 index 00000000..d2668643 --- /dev/null +++ b/apps/web/src/billing/billing-sync.json @@ -0,0 +1,40 @@ +{ + "productId": "beakerstack", + "stripeProductName": "BeakerStack (template)", + "plans": [ + { + "planId": "beakerstack_pro", + "prices": [ + { + "unitAmount": 1900, + "currency": "usd", + "interval": "month", + "lookupKey": "beakerstack_pro_monthly" + }, + { + "unitAmount": 19000, + "currency": "usd", + "interval": "year", + "lookupKey": "beakerstack_pro_annual" + } + ] + }, + { + "planId": "beakerstack_max", + "prices": [ + { + "unitAmount": 4900, + "currency": "usd", + "interval": "month", + "lookupKey": "beakerstack_max_monthly" + }, + { + "unitAmount": 49000, + "currency": "usd", + "interval": "year", + "lookupKey": "beakerstack_max_annual" + } + ] + } + ] +} diff --git a/apps/web/src/billing/billingSyncDisplay.ts b/apps/web/src/billing/billingSyncDisplay.ts new file mode 100644 index 00000000..1141a6e5 --- /dev/null +++ b/apps/web/src/billing/billingSyncDisplay.ts @@ -0,0 +1,191 @@ +import type { Plan } from '@beakerstack/billing'; +import billingSync from './billing-sync.json'; + +type SyncPrice = { + unitAmount?: number; + interval?: string; +}; + +type SyncPlan = { + planId?: string; + prices?: SyncPrice[]; +}; + +function syncRow(planId: string): SyncPlan | undefined { + const plans = billingSync.plans as SyncPlan[] | undefined; + return plans?.find(p => p.planId === planId); +} + +/** + * Monthly list price in cents from `billing-sync.json` (`interval: "month"`), + * else `fallbackCents` (typically `billing_plans.price_cents`). + */ +export function monthlyListCentsFromSync( + planId: string, + fallbackCents: number +): number { + const month = syncRow(planId)?.prices?.find(p => p.interval === 'month'); + if (month != null && typeof month.unitAmount === 'number') { + return month.unitAmount; + } + return fallbackCents; +} + +/** + * Annual list price in cents from `billing-sync.json` (same source as + * `npm run billing:sync-stripe` → Stripe yearly `unit_amount`). + * If no yearly row exists, falls back to 12× resolved monthly cents. + */ +export function annualListCentsFromSync( + planId: string, + monthlyCentsFallback: number +): number { + const monthly = monthlyListCentsFromSync(planId, monthlyCentsFallback); + const year = syncRow(planId)?.prices?.find(p => p.interval === 'year'); + if (year != null && typeof year.unitAmount === 'number') { + return year.unitAmount; + } + return monthly * 12; +} + +export type PlanSavingsCopy = + | { kind: 'none' } + | { kind: 'months'; months: number } + | { kind: 'percent'; pct: number }; + +/** Savings is “N whole months free” if (12×M − annual) / M is within this of an integer. */ +const MONTH_FREE_TOLERANCE = 0.051; + +/** + * Per paid plan: prefer “N months free” when discount aligns with whole months + * of the monthly list price; otherwise rounded percent vs 12× monthly. + */ +export function planAnnualSavingsCopy( + planId: string, + dbMonthlyCents: number +): PlanSavingsCopy { + const monthly = monthlyListCentsFromSync(planId, dbMonthlyCents); + const annual = annualListCentsFromSync(planId, dbMonthlyCents); + const yearAtMonthlyRates = monthly * 12; + if (yearAtMonthlyRates <= 0 || annual <= 0 || annual >= yearAtMonthlyRates) { + return { kind: 'none' }; + } + const savingsCents = yearAtMonthlyRates - annual; + const monthsFloat = savingsCents / monthly; + const monthsRounded = Math.round(monthsFloat); + if ( + monthsRounded >= 1 && + Math.abs(monthsFloat - monthsRounded) <= MONTH_FREE_TOLERANCE + ) { + return { kind: 'months', months: monthsRounded }; + } + const pct = Math.round((savingsCents / yearAtMonthlyRates) * 100); + if (pct <= 0) return { kind: 'none' }; + return { kind: 'percent', pct }; +} + +/** + * Rounded percent saved vs paying monthly price × 12 (null if not cheaper). + */ +export function annualSavingsPercentForPlan( + planId: string, + dbMonthlyCents: number +): number | null { + const monthly = monthlyListCentsFromSync(planId, dbMonthlyCents); + const annual = annualListCentsFromSync(planId, dbMonthlyCents); + const yearAtMonthlyRates = monthly * 12; + if (yearAtMonthlyRates <= 0 || annual <= 0) return null; + if (annual >= yearAtMonthlyRates) return null; + return Math.round(((yearAtMonthlyRates - annual) / yearAtMonthlyRates) * 100); +} + +export type CadenceSavingsLabel = + | { kind: 'none' } + | { kind: 'months'; months: number } + | { kind: 'months_range'; max: number } + | { kind: 'percent'; pct: number } + | { kind: 'percent_range'; max: number }; + +function formatMonthsFreeTitleCase(n: number): string { + return n === 1 ? '1 Month Free' : `${n} Months Free`; +} + +/** Badge / callout next to a plan name (annual view). */ +export function formatSavingsCalloutFromCopy( + copy: PlanSavingsCopy +): string | null { + if (copy.kind === 'none') return null; + if (copy.kind === 'months') return formatMonthsFreeTitleCase(copy.months); + return `Save ${copy.pct}%`; +} + +/** + * Short savings text for the nested pill inside “Annually” (no “Annually · ” prefix). + */ +export function formatCadenceToggleSavingsBadge( + s: CadenceSavingsLabel +): string | null { + if (s.kind === 'none') return null; + if (s.kind === 'months') return formatMonthsFreeTitleCase(s.months); + if (s.kind === 'months_range') return `Up to ${s.max} Months Free`; + if (s.kind === 'percent') return `Save ${s.pct}%`; + return `Save up to ${s.max}%`; +} + +/** + * Aggregate savings copy for the cadence toggle across paid catalog plans. + */ +export function cadenceAnnualSavingsFromPlans( + plans: Plan[] +): CadenceSavingsLabel { + const paid = plans.filter(p => p.price_cents > 0); + const copies = paid.map(p => planAnnualSavingsCopy(p.id, p.price_cents)); + const valid = copies.filter( + (c): c is Exclude => { + return c.kind !== 'none'; + } + ); + if (valid.length === 0) return { kind: 'none' }; + + const monthCopies = valid.filter( + (c): c is { kind: 'months'; months: number } => c.kind === 'months' + ); + const pctCopies = valid.filter( + (c): c is { kind: 'percent'; pct: number } => c.kind === 'percent' + ); + + if (monthCopies.length === valid.length) { + const ns = monthCopies.map(m => m.months); + const min = Math.min(...ns); + const max = Math.max(...ns); + if (min === max) return { kind: 'months', months: min }; + return { kind: 'months_range', max }; + } + + if (pctCopies.length === valid.length) { + const ps = pctCopies.map(p => p.pct); + const min = Math.min(...ps); + const max = Math.max(...ps); + if (min === max) return { kind: 'percent', pct: min }; + if (max - min <= 1) + return { kind: 'percent', pct: Math.round((min + max) / 2) }; + return { kind: 'percent_range', max }; + } + + const pcts = paid + .map(p => annualSavingsPercentForPlan(p.id, p.price_cents)) + .filter((x): x is number => x != null && x > 0); + if (pcts.length === 0) return { kind: 'none' }; + const min = Math.min(...pcts); + const max = Math.max(...pcts); + if (min === max) return { kind: 'percent', pct: min }; + if (max - min <= 1) + return { kind: 'percent', pct: Math.round((min + max) / 2) }; + return { kind: 'percent_range', max }; +} + +/** Full single-line label (e.g. tooltips); prefer split pill + `formatCadenceToggleSavingsBadge` in UI. */ +export function formatCadenceAnnualButtonLabel(s: CadenceSavingsLabel): string { + const b = formatCadenceToggleSavingsBadge(s); + return b ? `Annually · ${b}` : 'Annually'; +} diff --git a/apps/web/src/billing/constraintBlockers.ts b/apps/web/src/billing/constraintBlockers.ts new file mode 100644 index 00000000..be700446 --- /dev/null +++ b/apps/web/src/billing/constraintBlockers.ts @@ -0,0 +1,106 @@ +import type { Plan } from '@beakerstack/billing'; +import type { ProductBillingConfig } from '@beakerstack/billing'; +import { + applyTemplate, + booleanFeatureLabel, + exclusiveBooleanFeaturePlanName, + mergeDowngradeConstraintCopy, + mergePlanFeatureRows, +} from './planPresentation'; + +export type DowngradeBlockersResult = { + /** Collections over cap, items-per-collection over cap, meter over cap — block the CTA. */ + hard: string[]; + /** Boolean entitlement loss — show warning; CTA remains enabled (see billing UI spec §3.3 soft vs hard). */ + soft: string[]; +}; + +function booleanFeatureEnabled(plan: Plan, key: string): boolean { + const v = plan.features[key]; + return v === true || v === 1; +} + +/** + * Downgrade constraints: **hard** (numeric/meter) vs **soft** (boolean features you would lose). + * Spec §3.3 originally made all constraints hard for v1; we treat boolean entitlement loss as soft so + * users can acknowledge downgrade when nothing must be deleted (only capability loss). + */ +export function computeDowngradeBlockers( + currentPlan: Plan, + targetPlan: Plan, + options: { + collectionCount: number; + maxItemsInAnyCollection: number; + aiUsedThisPeriod: number; + }, + allPlans: Plan[], + billingConfig: ProductBillingConfig +): DowngradeBlockersResult { + const copy = mergeDowngradeConstraintCopy(billingConfig); + const hard: string[] = []; + const soft: string[] = []; + + const tCap = targetPlan.features.containers_per_account_max as + | number + | undefined; + if (tCap != null && tCap >= 0 && options.collectionCount > tCap) { + hard.push( + applyTemplate(copy.collectionsOverCap, { + current: options.collectionCount, + cap: tCap, + targetPlan: targetPlan.display_name, + deleteCount: options.collectionCount - tCap, + }) + ); + } + + const itemsCap = targetPlan.features.items_per_container_max as + | number + | undefined; + if ( + itemsCap != null && + itemsCap >= 0 && + options.maxItemsInAnyCollection > itemsCap + ) { + hard.push( + applyTemplate(copy.itemsPerCollectionOverCap, { + maxItems: options.maxItemsInAnyCollection, + cap: itemsCap, + targetPlan: targetPlan.display_name, + }) + ); + } + + const lim = targetPlan.usage_limits.ai_summarize as number | undefined; + if (lim != null && lim >= 0 && options.aiUsedThisPeriod > lim) { + hard.push( + applyTemplate(copy.meterOverCap, { + used: options.aiUsedThisPeriod, + limit: lim, + targetPlan: targetPlan.display_name, + }) + ); + } + + for (const row of mergePlanFeatureRows(billingConfig)) { + if (row.kind !== 'boolean') continue; + if ( + booleanFeatureEnabled(currentPlan, row.featureKey) && + !booleanFeatureEnabled(targetPlan, row.featureKey) + ) { + const featureLabel = booleanFeatureLabel(billingConfig, row.featureKey); + const exclusivePlanName = + exclusiveBooleanFeaturePlanName(allPlans, row.featureKey) ?? + 'a higher tier'; + soft.push( + applyTemplate(copy.booleanFeatureLoss, { + featureLabel, + exclusivePlanName, + targetPlanName: targetPlan.display_name, + }) + ); + } + } + + return { hard, soft }; +} diff --git a/apps/web/src/billing/formatters.ts b/apps/web/src/billing/formatters.ts new file mode 100644 index 00000000..79a4d8e0 --- /dev/null +++ b/apps/web/src/billing/formatters.ts @@ -0,0 +1,33 @@ +export function formatMoneyCents(cents: number, currency = 'usd') { + try { + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: currency.length === 3 ? currency.toUpperCase() : 'USD', + }).format(cents / 100); + } catch { + return `$${(cents / 100).toFixed(2)}`; + } +} + +export function formatDate(iso: string) { + try { + return new Date(iso).toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + }); + } catch { + return iso; + } +} + +export function formatMonthYear(iso: string) { + try { + return new Date(iso).toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + }); + } catch { + return iso; + } +} diff --git a/apps/web/src/billing/planPresentation.ts b/apps/web/src/billing/planPresentation.ts new file mode 100644 index 00000000..0c40d83e --- /dev/null +++ b/apps/web/src/billing/planPresentation.ts @@ -0,0 +1,156 @@ +import type { + DowngradeConstraintCopy, + PlanFeatureRowConfig, + ProductBillingConfig, +} from '@beakerstack/billing'; +import type { Plan } from '@beakerstack/billing'; + +/** Default “What’s included” rows when `planFeatureRows` is omitted in config. */ +export const DEFAULT_PLAN_FEATURE_ROWS: PlanFeatureRowConfig[] = [ + { + id: 'feature_a', + featureKey: 'feature_a', + kind: 'boolean', + label: 'Feature A', + }, + { + id: 'feature_b', + featureKey: 'feature_b', + kind: 'boolean', + label: 'Feature B', + }, + { + id: 'containers', + featureKey: 'containers_per_account_max', + kind: 'number', + unlimitedLabel: 'Unlimited collections', + limitedLabelTemplate: 'Up to {count} collections', + }, + { + id: 'items', + featureKey: 'items_per_container_max', + kind: 'number', + unlimitedLabel: 'Unlimited items per collection', + limitedLabelTemplate: 'Up to {count} items per collection', + }, +]; + +const DEFAULT_DOWNGRADE: Required = { + collectionsOverCap: + 'You currently have {current} collections. The {targetPlan} plan allows {cap}. Delete {deleteCount} collection(s) before downgrading.', + booleanFeatureLoss: + 'Your current plan includes {featureLabel} (available on {exclusivePlanName}). The {targetPlanName} plan does not — it will be disabled after you downgrade.', + meterOverCap: + "You've used {used} AI summaries this month. The {targetPlan} plan allows {limit} per month.", + itemsPerCollectionOverCap: + 'One of your collections has {maxItems} items. The {targetPlan} plan allows up to {cap} items per collection. Remove items or reorganize collections before downgrading.', +}; + +export function mergePlanFeatureRows( + config: ProductBillingConfig +): PlanFeatureRowConfig[] { + const rows = config.planFeatureRows; + return rows && rows.length > 0 ? rows : DEFAULT_PLAN_FEATURE_ROWS; +} + +export function mergeDowngradeConstraintCopy( + config: ProductBillingConfig +): Required { + const d = config.downgradeConstraintCopy ?? {}; + return { + collectionsOverCap: + d.collectionsOverCap ?? DEFAULT_DOWNGRADE.collectionsOverCap, + booleanFeatureLoss: + d.booleanFeatureLoss ?? DEFAULT_DOWNGRADE.booleanFeatureLoss, + meterOverCap: d.meterOverCap ?? DEFAULT_DOWNGRADE.meterOverCap, + itemsPerCollectionOverCap: + d.itemsPerCollectionOverCap ?? + DEFAULT_DOWNGRADE.itemsPerCollectionOverCap, + }; +} + +export function applyTemplate( + template: string, + vars: Record +): string { + return template.replace(/\{(\w+)\}/g, (_, key: string) => + vars[key] !== undefined && vars[key] !== null ? String(vars[key]) : '' + ); +} + +export function planFeatureLine( + plan: Plan, + row: PlanFeatureRowConfig +): { ok: boolean; text: string } { + const raw = plan.features[row.featureKey]; + if (row.kind === 'boolean') { + return { ok: !!raw, text: row.label }; + } + const n = typeof raw === 'number' ? raw : 0; + if (n === -1) return { ok: true, text: row.unlimitedLabel }; + return { + ok: n > 0, + text: row.limitedLabelTemplate.replace(/\{count\}/g, String(n)), + }; +} + +/** Label for a boolean feature row (e.g. downgrade copy). */ +export function booleanFeatureLabel( + config: ProductBillingConfig, + featureKey: string +): string { + const row = mergePlanFeatureRows(config).find( + (r): r is Extract => + r.kind === 'boolean' && r.featureKey === featureKey + ); + return row?.label ?? featureKey; +} + +/** Plan with highest `display_order` among plans where `features[featureKey]` is truthy. */ +export function exclusiveBooleanFeaturePlanName( + allPlans: Plan[], + featureKey: string +): string | null { + const holders = allPlans.filter(p => { + const v = p.features[featureKey]; + return v === true || v === 1; + }); + if (holders.length === 0) return null; + holders.sort((a, b) => (b.display_order ?? 0) - (a.display_order ?? 0)); + return holders[0]?.display_name ?? null; +} + +const DEFAULT_USAGE_METER_COPY: Record< + string, + { label: string; description?: string } +> = { + ai_summarize: { + label: 'AI summarize', + description: 'Summaries generated in this app count toward this meter.', + }, +}; + +const DEFAULT_USAGE_LIMITS_COPY = { + collectionsRowName: 'Collections', + itemsRowName: 'Items per collection (max in one collection)', + collectionsFootnote: + 'Collection counts use the template demo; wire your product for real item counts.', +}; + +export function mergeUsageMeterCopy( + config: ProductBillingConfig +): Record { + const out: Record = { + ...DEFAULT_USAGE_METER_COPY, + }; + for (const [k, v] of Object.entries(config.usageMeterCopy ?? {})) { + out[k] = { ...out[k], ...v }; + } + return out; +} + +export function mergeUsageLimitsCopy( + config: ProductBillingConfig +): typeof DEFAULT_USAGE_LIMITS_COPY { + return { ...DEFAULT_USAGE_LIMITS_COPY, ...(config.usageLimitsCopy ?? {}) }; +} diff --git a/apps/web/src/billing/useDemoCollectionCount.ts b/apps/web/src/billing/useDemoCollectionCount.ts new file mode 100644 index 00000000..882a1e3c --- /dev/null +++ b/apps/web/src/billing/useDemoCollectionCount.ts @@ -0,0 +1,50 @@ +import { useCallback, useEffect, useState } from 'react'; +import { supabaseRpc } from '../lib/supabase'; + +const PRODUCT_ID = 'beakerstack'; + +/** + * Template demo: collections count and max item_count in any collection (from `billing_demo_get_collections`). + * Used on billing usage/plans for “Collections” and “Items per collection” rows. + */ +export function useDemoCollectionCount() { + const [count, setCount] = useState(0); + const [maxItemsInAnyCollection, setMaxItemsInAnyCollection] = useState(0); + const [loading, setLoading] = useState(true); + + const refresh = useCallback(async () => { + setLoading(true); + try { + const { data, error } = await supabaseRpc.rpc( + 'billing_demo_get_collections', + { + p_product_id: PRODUCT_ID, + } + ); + if (error) throw error; + const rows = (data as { id: string; item_count: number }[] | null) ?? []; + setCount(rows.length); + setMaxItemsInAnyCollection( + rows.length > 0 + ? Math.max(...rows.map(r => Number(r.item_count ?? 0))) + : 0 + ); + } catch { + setCount(0); + setMaxItemsInAnyCollection(0); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void refresh(); + }, [refresh]); + + return { + count, + maxItemsInAnyCollection, + loading, + refresh, + }; +} diff --git a/apps/web/src/billing/useDemoCollections.ts b/apps/web/src/billing/useDemoCollections.ts new file mode 100644 index 00000000..75b2af0d --- /dev/null +++ b/apps/web/src/billing/useDemoCollections.ts @@ -0,0 +1,91 @@ +import { useCallback, useEffect, useState } from 'react'; +import { supabaseRpc } from '../lib/supabase'; + +export type DemoCollectionRow = { + id: string; + item_count: number; +}; + +const PRODUCT_ID = 'beakerstack'; + +export function useDemoCollections() { + const [collections, setCollections] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchCollections = useCallback(async () => { + setLoading(true); + setError(null); + try { + const { data, error: rpcErr } = await supabaseRpc.rpc( + 'billing_demo_get_collections', + { p_product_id: PRODUCT_ID } + ); + if (rpcErr) throw rpcErr; + const rows = (data as { id: string; item_count: number }[] | null) ?? []; + setCollections( + rows.map(r => ({ + id: String(r.id), + item_count: Number(r.item_count ?? 0), + })) + ); + } catch (e) { + const msg = + e instanceof Error ? e.message : 'Could not load demo collections.'; + setError(msg); + setCollections([]); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void fetchCollections(); + }, [fetchCollections]); + + const addCollection = useCallback(async () => { + const { error: rpcErr } = await supabaseRpc.rpc( + 'billing_demo_add_collection', + { p_product_id: PRODUCT_ID } + ); + if (rpcErr) throw rpcErr; + await fetchCollections(); + }, [fetchCollections]); + + const deleteCollection = useCallback( + async (collectionId: string) => { + const { error: rpcErr } = await supabaseRpc.rpc( + 'billing_demo_delete_collection', + { + p_product_id: PRODUCT_ID, + p_collection_id: collectionId, + } + ); + if (rpcErr) throw rpcErr; + await fetchCollections(); + }, + [fetchCollections] + ); + + const addItem = useCallback( + async (collectionId: string) => { + const { error: rpcErr } = await supabaseRpc.rpc('billing_demo_add_item', { + p_product_id: PRODUCT_ID, + p_collection_id: collectionId, + }); + if (rpcErr) throw rpcErr; + await fetchCollections(); + }, + [fetchCollections] + ); + + return { + collections, + loading, + error, + refresh: fetchCollections, + addCollection, + deleteCollection, + addItem, + }; +} diff --git a/apps/web/src/components/billing/Banner.web.tsx b/apps/web/src/components/billing/Banner.web.tsx new file mode 100644 index 00000000..bf153e40 --- /dev/null +++ b/apps/web/src/components/billing/Banner.web.tsx @@ -0,0 +1,35 @@ +import type { ReactNode } from 'react'; + +export type BannerVariant = 'info' | 'success' | 'warning' | 'error'; + +const variantClass: Record = { + info: 'bg-blue-50 border-blue-200 text-blue-900', + success: 'bg-green-50 border-green-200 text-green-900', + warning: 'bg-amber-50 border-amber-200 text-amber-900', + error: 'bg-red-50 border-red-200 text-red-900', +}; + +export function Banner({ + variant = 'info', + title, + children, + action, + className = '', +}: { + variant?: BannerVariant; + title?: string; + children: ReactNode; + action?: ReactNode; + className?: string; +}): JSX.Element { + return ( +
+ {title &&

{title}

} +

{children}

+ {action &&
{action}
} +
+ ); +} diff --git a/apps/web/src/components/billing/BillingPageShell.web.tsx b/apps/web/src/components/billing/BillingPageShell.web.tsx new file mode 100644 index 00000000..b55c449d --- /dev/null +++ b/apps/web/src/components/billing/BillingPageShell.web.tsx @@ -0,0 +1,20 @@ +import type { ReactNode } from 'react'; +import { AppHeader } from '@beakerstack/shared/components/navigation/AppHeader.web'; +import { supabase } from '../../lib/supabase'; + +export function BillingPageShell({ + children, + maxWidthClass = 'max-w-[1024px]', +}: { + children: ReactNode; + maxWidthClass?: string; +}): JSX.Element { + return ( +
+ +
+
{children}
+
+
+ ); +} diff --git a/apps/web/src/components/billing/BillingTabs.web.tsx b/apps/web/src/components/billing/BillingTabs.web.tsx new file mode 100644 index 00000000..46e0a03b --- /dev/null +++ b/apps/web/src/components/billing/BillingTabs.web.tsx @@ -0,0 +1,38 @@ +import { NavLink } from 'react-router-dom'; + +const tabs: { to: string; end?: boolean; label: string }[] = [ + { to: '/billing', end: true, label: 'Overview' }, + { to: '/billing/usage', label: 'Usage' }, + { to: '/billing/plans', label: 'Plans' }, + { to: '/billing/invoices', label: 'Invoices' }, +]; + +/** + * Secondary nav for `/billing/*` (URL-driven active state). + */ +export function BillingTabs() { + return ( + + ); +} diff --git a/apps/web/src/components/billing/CadenceToggle.web.tsx b/apps/web/src/components/billing/CadenceToggle.web.tsx new file mode 100644 index 00000000..f373b5a5 --- /dev/null +++ b/apps/web/src/components/billing/CadenceToggle.web.tsx @@ -0,0 +1,78 @@ +import { usePlanCatalog } from '@beakerstack/billing'; +import { useCallback, useMemo } from 'react'; +import { useSearchParams } from 'react-router-dom'; +import { beakerstackBillingConfig } from '../../billing/beakerstackBillingConfig'; +import { + cadenceAnnualSavingsFromPlans, + formatCadenceToggleSavingsBadge, +} from '../../billing/billingSyncDisplay'; + +/** + * URL sync: `?cadence=annual` | `?cadence=monthly` (default monthly = omit param or monthly). + */ +export function CadenceToggle() { + const [search, setSearch] = useSearchParams(); + const { plans } = usePlanCatalog(); + const savings = useMemo(() => cadenceAnnualSavingsFromPlans(plans), [plans]); + const annualBadgeText = useMemo( + () => formatCadenceToggleSavingsBadge(savings), + [savings] + ); + + const cadence = search.get('cadence') === 'annual' ? 'annual' : 'monthly'; + const set = useCallback( + (c: 'monthly' | 'annual') => { + const n = new URLSearchParams(search); + if (c === 'monthly') n.delete('cadence'); + else n.set('cadence', 'annual'); + setSearch(n, { replace: true }); + }, + [search, setSearch] + ); + return ( +
+
+ + +
+
+ ); +} + +export function getCadenceFromSearch(search: URLSearchParams) { + return search.get('cadence') === 'annual' ? 'annual' : 'monthly'; +} diff --git a/apps/web/src/components/billing/ConfirmDowngradeModal.web.tsx b/apps/web/src/components/billing/ConfirmDowngradeModal.web.tsx new file mode 100644 index 00000000..91ed7dd6 --- /dev/null +++ b/apps/web/src/components/billing/ConfirmDowngradeModal.web.tsx @@ -0,0 +1,55 @@ +import { Button } from '@beakerstack/shared/components/primitives/Button.web'; +import { Modal } from '@beakerstack/shared/components/primitives/Modal.web'; + +export function ConfirmDowngradeModal({ + open, + onClose, + onConfirm, + planName, + bodyText, + pending = false, +}: { + open: boolean; + onClose: () => void; + onConfirm: () => void | Promise; + planName: string; + bodyText: string; + pending?: boolean; +}) { + return ( + +

{bodyText}

+

+ Changes that reduce your entitlements take effect at the end of the + current billing period, unless your payment provider processes them + sooner. Proration is handled by Stripe. +

+
+ + +
+
+ ); +} diff --git a/apps/web/src/components/billing/ConstraintWarning.web.tsx b/apps/web/src/components/billing/ConstraintWarning.web.tsx new file mode 100644 index 00000000..73add2f8 --- /dev/null +++ b/apps/web/src/components/billing/ConstraintWarning.web.tsx @@ -0,0 +1,10 @@ +export function ConstraintWarning({ message }: { message: string }) { + return ( +
+ {message} +
+ ); +} diff --git a/apps/web/src/components/billing/CurrentPlanCard.web.tsx b/apps/web/src/components/billing/CurrentPlanCard.web.tsx new file mode 100644 index 00000000..3d894569 --- /dev/null +++ b/apps/web/src/components/billing/CurrentPlanCard.web.tsx @@ -0,0 +1,99 @@ +import { resolveCadence } from '@beakerstack/billing'; +import type { Plan, SubscriptionRow } from '@beakerstack/billing'; +import { SubscriptionStatusBadge } from '@beakerstack/billing/web'; +import { Link } from 'react-router-dom'; +import { Button } from '@beakerstack/shared/components/primitives/Button.web'; +import { annualListCentsFromSync } from '../../billing/billingSyncDisplay'; +import { formatDate, formatMoneyCents } from '../../billing/formatters'; + +export function CurrentPlanCard({ + plan, + subscription, + isFree, + onManagePayment, + managePaymentPending = false, + /** When set, replaces default renewal / trial / end copy for paid rows (e.g. scheduled downgrade). */ + periodSubcopy, +}: { + plan: Plan | null; + subscription: SubscriptionRow | null; + isFree: boolean; + onManagePayment: () => void; + managePaymentPending?: boolean; + periodSubcopy?: string | null; +}): JSX.Element | null { + if (!plan) { + return null; + } + const cadence = resolveCadence(plan, subscription); + const priceLine = + isFree || plan.price_cents === 0 + ? null + : formatMoneyCents( + cadence === 'annual' + ? annualListCentsFromSync(plan.id, plan.price_cents) + : plan.price_cents + ) + (cadence === 'annual' ? '/year' : '/month'); + + const periodEnd = subscription?.current_period_end + ? formatDate(subscription.current_period_end) + : '—'; + + return ( +
+ {isFree ? ( + <> +

+ You're on the Free plan +

+

+ Upgrade to unlock more capacity and features. +

+ + ) : ( + <> +
+

+ {plan.display_name} +

+ {subscription && ( + + )} +
+ {priceLine &&

{priceLine}

} + + )} + {!isFree && subscription && ( +

+ {periodSubcopy + ? periodSubcopy + : subscription.cancel_at_period_end + ? `Ends on ${periodEnd}` + : subscription.trial_end && + (subscription.status === 'trialing' || + new Date(subscription.trial_end) > new Date()) + ? `Trial — ends ${formatDate(subscription.trial_end)}` + : `Renews on ${periodEnd}`} +

+ )} +
+ + {isFree ? 'Upgrade to Pro' : 'Change plan'} + + {!isFree && subscription?.stripe_customer_id ? ( + + ) : null} +
+
+ ); +} diff --git a/apps/web/src/components/billing/FeatureLimitRow.web.tsx b/apps/web/src/components/billing/FeatureLimitRow.web.tsx new file mode 100644 index 00000000..8e6f877b --- /dev/null +++ b/apps/web/src/components/billing/FeatureLimitRow.web.tsx @@ -0,0 +1,36 @@ +export function FeatureLimitRow({ + name, + used, + cap, + capIsUnlimited, +}: { + name: string; + used: number | null; + cap: number; + capIsUnlimited: boolean; +}) { + const u = used ?? 0; + if (capIsUnlimited) { + return ( +
+ {name} + {u} of unlimited +
+ ); + } + const ratio = cap > 0 ? u / cap : 0; + const heavy = ratio >= 0.8 && u < cap; + const at = u >= cap; + return ( +
+ {name} + + {u} of {cap} + +
+ ); +} diff --git a/apps/web/src/components/billing/InvoiceList.web.tsx b/apps/web/src/components/billing/InvoiceList.web.tsx new file mode 100644 index 00000000..dadd5b4b --- /dev/null +++ b/apps/web/src/components/billing/InvoiceList.web.tsx @@ -0,0 +1,58 @@ +import type { BillingInvoiceRow } from '@beakerstack/billing'; +import { Link } from 'react-router-dom'; +import { formatDate, formatMoneyCents } from '../../billing/formatters'; +import { StatusBadge } from './StatusBadge.web'; + +export function InvoiceList({ + items, + limit = 3, + showViewAll = true, +}: { + items: BillingInvoiceRow[]; + limit?: number; + showViewAll?: boolean; +}): JSX.Element | null { + if (items.length === 0) { + return null; + } + const slice = items.slice(0, limit); + return ( +
+

Recent activity

+
    + {slice.map(inv => ( +
  • +
    +

    {formatDate(inv.created_at)}

    +

    + {inv.description ?? 'Subscription'} +

    +
    +
    + + + {formatMoneyCents( + inv.amount_paid || inv.amount_due, + inv.currency + )} + +
    +
  • + ))} +
+ {showViewAll && items.length > 0 && ( +
+ + View all invoices → + +
+ )} +
+ ); +} diff --git a/apps/web/src/components/billing/InvoiceTable.web.tsx b/apps/web/src/components/billing/InvoiceTable.web.tsx new file mode 100644 index 00000000..dc74ed88 --- /dev/null +++ b/apps/web/src/components/billing/InvoiceTable.web.tsx @@ -0,0 +1,122 @@ +import type { BillingInvoiceRow } from '@beakerstack/billing'; +import { Link } from 'react-router-dom'; +import { Button } from '@beakerstack/shared/components/primitives/Button.web'; +import { Skeleton } from '@beakerstack/shared/components/primitives/Skeleton.web'; +import { formatDate, formatMoneyCents } from '../../billing/formatters'; +import { StatusBadge } from './StatusBadge.web'; + +export function InvoiceTable({ + items, + loading, + hasMore, + onLoadMore, + loadMorePending = false, +}: { + items: BillingInvoiceRow[]; + loading: boolean; + hasMore: boolean; + onLoadMore: () => void; + loadMorePending?: boolean; +}): JSX.Element { + if (loading && items.length === 0) { + return ; + } + if (!loading && items.length === 0) { + return ( +
+

+ No invoices yet. Your invoices will appear here after your first + payment. +

+ + View plans + +
+ ); + } + return ( +
+
+ + + + + + + + + + + + {items.map(inv => ( + + + + + + + + ))} + +
+ Date + + Description + + Amount + + Status + + Actions +
+ {formatDate(inv.created_at)} + + {inv.description ?? '—'} + + {formatMoneyCents( + inv.amount_paid || inv.amount_due, + inv.currency + )} + + + + {inv.hosted_invoice_url && ( + + View + + )} + {inv.invoice_pdf_url && ( + + PDF + + )} +
+
+ {hasMore && ( +
+ +
+ )} +
+ ); +} diff --git a/apps/web/src/components/billing/PlanCard.web.tsx b/apps/web/src/components/billing/PlanCard.web.tsx new file mode 100644 index 00000000..622f7623 --- /dev/null +++ b/apps/web/src/components/billing/PlanCard.web.tsx @@ -0,0 +1,136 @@ +import type { BillingPlanConfig, Plan } from '@beakerstack/billing'; +import { useBillingConfig } from '@beakerstack/billing'; +import { Button } from '@beakerstack/shared/components/primitives/Button.web'; +import { beakerstackBillingConfig } from '../../billing/beakerstackBillingConfig'; +import type { DowngradeBlockersResult } from '../../billing/constraintBlockers'; +import { annualListCentsFromSync } from '../../billing/billingSyncDisplay'; +import { ConstraintWarning } from './ConstraintWarning.web'; +import { PlanFeatureList } from './PlanFeatureList.web'; + +/** + * v1: `public` mode reserved for a future /pricing page (CTA to signup, not checkout). + */ +export function PlanCard({ + plan, + priceHeadline, + priceSubline, + subTag, + savingsCallout, + billingCadence = 'monthly', + blockers = { hard: [], soft: [] }, + primary, + mode = 'authenticated', + /** e.g. "Scheduled" on the target plan when a downgrade is pending (billing UI v1 matrix). */ + supplementalBadge, +}: { + plan: Plan; + priceHeadline: string; + priceSubline?: string; + subTag?: string; + /** Shown next to plan title in annual cadence (e.g. “2 Months Free”). */ + savingsCallout?: string | null; + /** Used for trial copy (“then billed …”). */ + billingCadence?: 'monthly' | 'annual'; + /** Hard blockers disable the CTA; soft blockers are shown as warnings only (boolean entitlement loss). */ + blockers?: DowngradeBlockersResult; + primary: { + label: string; + onClick: () => void; + disabled?: boolean; + loading?: boolean; + variant?: 'primary' | 'secondary'; + }; + mode?: 'authenticated' | 'public'; + supplementalBadge?: string; +}): JSX.Element { + void mode; + const billingConfig = useBillingConfig(); + const cfgPlan = billingConfig.plans.find(p => p.id === plan.id) as + | BillingPlanConfig + | undefined; + const { hard, soft } = blockers; + const hasWarnings = hard.length > 0 || soft.length > 0; + const hardBlocked = hard.length > 0; + const tag = + subTag ?? + cfgPlan?.planCardTagline ?? + cfgPlan?.description ?? + plan.description ?? + undefined; + return ( +
+
+

+ {plan.display_name} +

+ {supplementalBadge ? ( + + {supplementalBadge} + + ) : null} + {savingsCallout ? ( + + {savingsCallout} + + ) : null} +
+ {tag ?

{tag}

: null} +
+

{priceHeadline}

+ {priceSubline && ( +

{priceSubline}

+ )} + {plan.price_cents > 0 && plan.trial_period_days > 0 ? ( +

+ {plan.trial_period_days}-day trial, then billed{' '} + {billingCadence === 'annual' ? 'annually' : 'monthly'} at this rate. +

+ ) : null} +
+ {hasWarnings && ( +
+ {hard.map((m, i) => ( + + ))} + {soft.map((m, i) => ( + + ))} +
+ )} +
+ +
+
+ +
+
+ ); +} + +/** Display price: monthly uses DB cents; annual uses yearly amount from billing-sync (Stripe). */ +export function listPriceForPlan(plan: Plan, cadence: 'monthly' | 'annual') { + if (plan.price_cents === 0) return 'US$0'; + if (cadence === 'monthly') { + return ( + new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + }).format(plan.price_cents / 100) + '/month' + ); + } + const annualCents = annualListCentsFromSync(plan.id, plan.price_cents); + return ( + new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + }).format(annualCents / 100) + '/year, billed annually' + ); +} diff --git a/apps/web/src/components/billing/PlanFeatureList.web.tsx b/apps/web/src/components/billing/PlanFeatureList.web.tsx new file mode 100644 index 00000000..17063b1d --- /dev/null +++ b/apps/web/src/components/billing/PlanFeatureList.web.tsx @@ -0,0 +1,54 @@ +import { Check, X } from 'lucide-react'; +import type { Plan } from '@beakerstack/billing'; +import { useBillingConfig } from '@beakerstack/billing'; +import { beakerstackBillingConfig } from '../../billing/beakerstackBillingConfig'; +import { + mergePlanFeatureRows, + planFeatureLine, +} from '../../billing/planPresentation'; + +/** + * “What’s included” list for a plan card (or future public pricing). + * Rows and labels come from `beakerstackBillingConfig.planFeatureRows` with + * defaults in `apps/web/src/billing/planPresentation.ts`. + */ +export function PlanFeatureList({ + plan, + mode = 'authenticated', +}: { + plan: Plan; + mode?: 'authenticated' | 'public'; +}): JSX.Element { + void mode; + const billingConfig = useBillingConfig(); + const rows = mergePlanFeatureRows(billingConfig); + + return ( +
+

+ What's included +

+
    + {rows.map(row => { + const { ok, text } = planFeatureLine(plan, row); + return ( +
  • + {ok ? ( + + ) : ( + + )} + {text} +
  • + ); + })} +
+
+ ); +} diff --git a/apps/web/src/components/billing/PlanFeatureRow.web.tsx b/apps/web/src/components/billing/PlanFeatureRow.web.tsx new file mode 100644 index 00000000..7261c78a --- /dev/null +++ b/apps/web/src/components/billing/PlanFeatureRow.web.tsx @@ -0,0 +1,46 @@ +import { Check, X } from 'lucide-react'; +import { Link } from 'react-router-dom'; + +export function PlanFeatureRow({ + name, + available, + showUpgradeLink = false, +}: { + name: string; + available: boolean; + showUpgradeLink?: boolean; +}): JSX.Element { + return ( +
+
+ {available ? ( + + ) : ( + + )} + {name} +
+
+ {available ? ( + 'Available' + ) : ( + + Not available + {showUpgradeLink && ( + <> + {' '} + ·{' '} + + Upgrade to unlock + + + )} + + )} +
+
+ ); +} diff --git a/apps/web/src/components/billing/StatCard.web.tsx b/apps/web/src/components/billing/StatCard.web.tsx new file mode 100644 index 00000000..b9d650c8 --- /dev/null +++ b/apps/web/src/components/billing/StatCard.web.tsx @@ -0,0 +1,14 @@ +export function StatCard({ + label, + value, +}: { + label: string; + value: string; +}): JSX.Element { + return ( +
+

{label}

+

{value}

+
+ ); +} diff --git a/apps/web/src/components/billing/StatusBadge.web.tsx b/apps/web/src/components/billing/StatusBadge.web.tsx new file mode 100644 index 00000000..6c643e6b --- /dev/null +++ b/apps/web/src/components/billing/StatusBadge.web.tsx @@ -0,0 +1,27 @@ +/** Invoice / generic status (not subscription — see SubscriptionStatusBadge in @beakerstack/billing/web). */ + +const base = + 'inline-flex rounded-full px-2.5 py-0.5 text-xs font-medium capitalize'; + +const styles: Record = { + paid: { label: 'Paid', cls: 'bg-green-100 text-green-800' }, + open: { label: 'Open', cls: 'bg-blue-100 text-blue-800' }, + uncollectible: { label: 'Failed', cls: 'bg-red-100 text-red-800' }, + void: { label: 'Void', cls: 'bg-gray-100 text-gray-800' }, + draft: { label: 'Draft', cls: 'bg-gray-100 text-gray-800' }, + refunded: { label: 'Refunded', cls: 'bg-gray-100 text-gray-800' }, +}; + +export function StatusBadge({ + status, + className = '', +}: { + status: string; + className?: string; +}): JSX.Element { + const s = (status || '').toLowerCase(); + const m = styles[s] ?? { label: status, cls: 'bg-gray-100 text-gray-800' }; + return ( + {m.label} + ); +} diff --git a/apps/web/src/components/billing/__tests__/Banner.test.tsx b/apps/web/src/components/billing/__tests__/Banner.test.tsx new file mode 100644 index 00000000..1a0fea37 --- /dev/null +++ b/apps/web/src/components/billing/__tests__/Banner.test.tsx @@ -0,0 +1,25 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { Banner } from '../Banner.web'; + +describe('Banner', () => { + it('renders children and title', () => { + render( + + Something changed. + + ); + expect(screen.getByText('Heads up')).toBeInTheDocument(); + expect(screen.getByText('Something changed.')).toBeInTheDocument(); + }); + + it('renders action slot', () => { + render( + Fix}> + Payment failed. + + ); + expect(screen.getByRole('button', { name: 'Fix' })).toBeInTheDocument(); + expect(screen.getByText('Payment failed.')).toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/components/billing/__tests__/BillingPageShell.web.test.tsx b/apps/web/src/components/billing/__tests__/BillingPageShell.web.test.tsx new file mode 100644 index 00000000..8dd47d06 --- /dev/null +++ b/apps/web/src/components/billing/__tests__/BillingPageShell.web.test.tsx @@ -0,0 +1,23 @@ +import { describe, expect, it, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { BillingPageShell } from '../BillingPageShell.web'; + +vi.mock('@beakerstack/shared/components/navigation/AppHeader.web', () => ({ + AppHeader: () =>
, +})); + +vi.mock('../../../lib/supabase', () => ({ + supabase: {}, +})); + +describe('BillingPageShell', () => { + it('renders header and children', () => { + render( + +

Inner

+
+ ); + expect(screen.getByTestId('app-header')).toBeInTheDocument(); + expect(screen.getByText('Inner')).toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/components/billing/__tests__/BillingTabs.web.test.tsx b/apps/web/src/components/billing/__tests__/BillingTabs.web.test.tsx new file mode 100644 index 00000000..52fe4ab7 --- /dev/null +++ b/apps/web/src/components/billing/__tests__/BillingTabs.web.test.tsx @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; +import { render, screen, within } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { BillingTabs } from '../BillingTabs.web'; + +describe('BillingTabs', () => { + it('links to billing sections', () => { + render( + + + + ); + const nav = screen.getByRole('navigation', { name: 'Billing sections' }); + expect(within(nav).getByRole('link', { name: 'Overview' })).toHaveAttribute( + 'href', + '/billing' + ); + expect(within(nav).getByRole('link', { name: 'Plans' })).toHaveAttribute( + 'href', + '/billing/plans' + ); + }); +}); diff --git a/apps/web/src/components/billing/__tests__/CadenceToggle.web.test.tsx b/apps/web/src/components/billing/__tests__/CadenceToggle.web.test.tsx new file mode 100644 index 00000000..bf4a3b8d --- /dev/null +++ b/apps/web/src/components/billing/__tests__/CadenceToggle.web.test.tsx @@ -0,0 +1,103 @@ +import { describe, expect, it, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter } from 'react-router-dom'; +import type { Plan } from '@beakerstack/billing'; +import { CadenceToggle, getCadenceFromSearch } from '../CadenceToggle.web'; + +const proPlan: Plan = { + id: 'beakerstack_pro', + product_id: 'beakerstack', + display_name: 'Pro', + description: null, + price_cents: 1900, + billing_period: 'monthly', + stripe_price_id_monthly: null, + stripe_price_id_annual: null, + stripe_product_id: null, + features: {}, + usage_limits: {}, + trial_period_days: 0, + is_public: true, + display_order: 2, +}; + +const catalogPlans = vi.fn(() => ({ + plans: [proPlan] as Plan[], + loading: false, + error: null, + refresh: vi.fn(), +})); + +vi.mock('@beakerstack/billing', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + usePlanCatalog: () => catalogPlans(), + }; +}); + +describe('getCadenceFromSearch', () => { + it('defaults to monthly when absent', () => { + expect(getCadenceFromSearch(new URLSearchParams())).toBe('monthly'); + expect(getCadenceFromSearch(new URLSearchParams('cadence=monthly'))).toBe( + 'monthly' + ); + }); + + it('detects annual', () => { + expect(getCadenceFromSearch(new URLSearchParams('cadence=annual'))).toBe( + 'annual' + ); + }); +}); + +describe('CadenceToggle', () => { + it('renders monthly / annually controls', () => { + render( + + + + ); + expect(screen.getByRole('button', { name: 'Monthly' })).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: /Annually/i }) + ).toBeInTheDocument(); + }); + + it('syncs cadence to URL and shows savings pill on annual selection', async () => { + const user = userEvent.setup(); + const { container } = render( + + + + ); + const annualBtn = screen.getByRole('button', { name: /Annually/i }); + expect(annualBtn.className).toMatch(/indigo-600/); + const pill = container.querySelector('.border-amber-400'); + expect(pill).toBeTruthy(); + + await user.click(screen.getByRole('button', { name: 'Monthly' })); + expect(screen.getByRole('button', { name: 'Monthly' }).className).toMatch( + /indigo-600/ + ); + + await user.click(screen.getByRole('button', { name: /Annually/i })); + expect(screen.getByRole('button', { name: /Annually/i }).className).toMatch( + /indigo-600/ + ); + }); + + it('selects annual from default monthly URL', async () => { + const user = userEvent.setup(); + render( + + + + ); + await user.click(screen.getByRole('button', { name: /Annually/i })); + expect(screen.getByRole('button', { name: /Annually/i }).className).toMatch( + /indigo-600/ + ); + }); +}); diff --git a/apps/web/src/components/billing/__tests__/CurrentPlanCard.web.test.tsx b/apps/web/src/components/billing/__tests__/CurrentPlanCard.web.test.tsx new file mode 100644 index 00000000..96baed53 --- /dev/null +++ b/apps/web/src/components/billing/__tests__/CurrentPlanCard.web.test.tsx @@ -0,0 +1,110 @@ +import { describe, expect, it, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import type { Plan, SubscriptionRow } from '@beakerstack/billing'; +import { CurrentPlanCard } from '../CurrentPlanCard.web'; + +vi.mock('@beakerstack/billing/web', () => ({ + SubscriptionStatusBadge: () => badge, +})); + +const plan: Plan = { + id: 'beakerstack_pro', + product_id: 'beakerstack', + display_name: 'Pro', + description: null, + price_cents: 1900, + billing_period: 'monthly', + stripe_price_id_monthly: null, + stripe_price_id_annual: null, + stripe_product_id: null, + features: {}, + usage_limits: {}, + trial_period_days: 0, + is_public: true, + display_order: 2, +}; + +const subscription: SubscriptionRow = { + id: 's1', + user_id: 'u1', + product_id: 'beakerstack', + plan_id: 'beakerstack_pro', + stripe_customer_id: 'cus', + stripe_subscription_id: 'sub', + stripe_price_id: 'price', + status: 'active', + current_period_start: null, + current_period_end: new Date(Date.now() + 864e5 * 30).toISOString(), + cancel_at_period_end: false, + pending_target_plan_id: null, + canceled_at: null, + trial_start: null, + trial_end: null, +}; + +describe('CurrentPlanCard', () => { + it('returns null when plan is missing', () => { + const { container } = render( + + + + ); + expect(container.firstChild).toBeNull(); + }); + + it('renders free messaging when isFree', () => { + render( + + + + ); + expect( + screen.getByRole('heading', { name: /Free plan/i }) + ).toBeInTheDocument(); + }); + + it('renders paid plan with manage action', () => { + const onManage = vi.fn(); + render( + + + + ); + expect(screen.getByText('Pro')).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: /Manage payment/i }) + ).toBeInTheDocument(); + }); + + it('hides manage payment when stripe customer id is missing', () => { + render( + + + + ); + expect( + screen.queryByRole('button', { name: /Manage payment/i }) + ).not.toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/components/billing/__tests__/PlanCard.web.test.tsx b/apps/web/src/components/billing/__tests__/PlanCard.web.test.tsx new file mode 100644 index 00000000..dbf345e3 --- /dev/null +++ b/apps/web/src/components/billing/__tests__/PlanCard.web.test.tsx @@ -0,0 +1,142 @@ +import { describe, expect, it, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import type { Plan } from '@beakerstack/billing'; +import { beakerstackBillingConfig } from '@/billing/beakerstackBillingConfig'; +import { PlanCard, listPriceForPlan } from '../PlanCard.web'; + +vi.mock('@beakerstack/billing', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + useBillingConfig: () => beakerstackBillingConfig, + }; +}); + +const proPlan: Plan = { + id: 'beakerstack_pro', + product_id: 'beakerstack', + display_name: 'Pro', + description: null, + price_cents: 1900, + billing_period: 'monthly', + stripe_price_id_monthly: null, + stripe_price_id_annual: null, + stripe_product_id: null, + features: { + feature_a: true, + feature_b: false, + containers_per_account_max: -1, + items_per_container_max: 25, + }, + usage_limits: { ai_summarize: 500 }, + trial_period_days: 0, + is_public: true, + display_order: 2, +}; + +describe('listPriceForPlan', () => { + it('formats free and paid cadence copy', () => { + const free: Plan = { ...proPlan, id: 'beakerstack_free', price_cents: 0 }; + expect(listPriceForPlan(free, 'monthly')).toBe('US$0'); + expect(listPriceForPlan(proPlan, 'monthly')).toMatch(/19/); + expect(listPriceForPlan(proPlan, 'annual')).toMatch(/year/); + }); +}); + +describe('PlanCard', () => { + it('renders plan title and primary CTA', () => { + const onClick = vi.fn(); + render( + + ); + expect(screen.getByRole('heading', { name: 'Pro' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Go Pro' })).toBeInTheDocument(); + }); + + it('disables CTA when hard blockers only', () => { + render( + + ); + expect(screen.getByText('Too many items')).toBeInTheDocument(); + const btn = screen.getByRole('button', { + name: 'Resolve issues to downgrade', + }); + expect(btn).toBeDisabled(); + }); + + it('keeps CTA enabled when only soft blockers', () => { + render( + + ); + expect(screen.getByText('You will lose a feature')).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'Downgrade to Pro' }) + ).not.toBeDisabled(); + }); + + it('shows trial line when plan has trial_period_days', () => { + const maxLike: Plan = { + ...proPlan, + id: 'beakerstack_max', + display_name: 'Max', + price_cents: 4900, + trial_period_days: 5, + usage_limits: { ai_summarize: -1 }, + }; + render( + + ); + expect( + screen.getByText(/5-day trial, then billed monthly/i) + ).toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/components/billing/__tests__/PlanFeatureList.test.tsx b/apps/web/src/components/billing/__tests__/PlanFeatureList.test.tsx new file mode 100644 index 00000000..884c4da7 --- /dev/null +++ b/apps/web/src/components/billing/__tests__/PlanFeatureList.test.tsx @@ -0,0 +1,44 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import type { Plan } from '@beakerstack/billing'; +import { beakerstackBillingConfig } from '@/billing/beakerstackBillingConfig'; +import { PlanFeatureList } from '../PlanFeatureList.web'; + +vi.mock('@beakerstack/billing', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + useBillingConfig: () => beakerstackBillingConfig, + }; +}); + +const freePlan: Plan = { + id: 'beakerstack_free', + product_id: 'beakerstack', + display_name: 'Free', + description: null, + price_cents: 0, + billing_period: 'free', + stripe_price_id_monthly: null, + stripe_price_id_annual: null, + stripe_product_id: null, + features: { + feature_a: false, + feature_b: false, + containers_per_account_max: 2, + items_per_container_max: 3, + }, + usage_limits: { ai_summarize: 30 }, + trial_period_days: 0, + is_public: true, + display_order: 1, +}; + +describe('PlanFeatureList', () => { + it('renders section title and feature lines', () => { + render(); + expect(screen.getByText("What's included")).toBeInTheDocument(); + expect(screen.getByText('Feature A')).toBeInTheDocument(); + expect(screen.getByText('Up to 2 collections')).toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/components/billing/__tests__/presentational.billing.test.tsx b/apps/web/src/components/billing/__tests__/presentational.billing.test.tsx new file mode 100644 index 00000000..b194778c --- /dev/null +++ b/apps/web/src/components/billing/__tests__/presentational.billing.test.tsx @@ -0,0 +1,160 @@ +import { describe, expect, it, vi } from 'vitest'; +import { render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter } from 'react-router-dom'; +import type { BillingInvoiceRow } from '@beakerstack/billing'; +import { ConstraintWarning } from '../ConstraintWarning.web'; +import { StatCard } from '../StatCard.web'; +import { StatusBadge } from '../StatusBadge.web'; +import { FeatureLimitRow } from '../FeatureLimitRow.web'; +import { PlanFeatureRow } from '../PlanFeatureRow.web'; +import { InvoiceList } from '../InvoiceList.web'; +import { InvoiceTable } from '../InvoiceTable.web'; +import { ConfirmDowngradeModal } from '../ConfirmDowngradeModal.web'; + +vi.mock('@beakerstack/shared/components/primitives/Modal.web', () => ({ + Modal: ({ + open, + children, + title, + }: { + open: boolean; + children: React.ReactNode; + title: string; + }) => + open ? ( +
+ {children} +
+ ) : null, +})); + +vi.mock('@beakerstack/shared/components/primitives/Button.web', () => ({ + Button: (p: { children: React.ReactNode; onPress?: () => void }) => ( + + ), +})); + +vi.mock('@beakerstack/shared/components/primitives/Skeleton.web', () => ({ + Skeleton: () =>
, +})); + +const sampleInvoice = (): BillingInvoiceRow => ({ + id: 'inv_1', + user_id: 'u1', + stripe_invoice_id: 'in_1', + stripe_customer_id: 'cus_1', + stripe_subscription_id: 'sub_1', + amount_due: 1000, + amount_paid: 1000, + currency: 'usd', + status: 'paid', + description: 'Subscription', + hosted_invoice_url: null, + invoice_pdf_url: null, + period_start: null, + period_end: null, + created_at: new Date().toISOString(), + finalized_at: null, + paid_at: new Date().toISOString(), +}); + +describe('billing presentational components', () => { + it('StatCard renders label and value', () => { + render(); + expect(screen.getByText('Test')).toBeInTheDocument(); + expect(screen.getByText('42')).toBeInTheDocument(); + }); + + it('StatusBadge maps known statuses', () => { + render(); + expect(screen.getByText('Paid')).toBeInTheDocument(); + }); + + it('ConstraintWarning renders message', () => { + render(); + expect(screen.getByRole('status')).toHaveTextContent('Blocked'); + }); + + it('FeatureLimitRow shows unlimited and capped states', () => { + const { rerender } = render( + + ); + expect(screen.getByText('2 of 10')).toBeInTheDocument(); + rerender(); + expect(screen.getByText(/1 of unlimited/)).toBeInTheDocument(); + }); + + it('PlanFeatureRow shows upgrade link when locked', () => { + render( + + + + ); + expect(screen.getByText('Upgrade to unlock')).toBeInTheDocument(); + }); + + it('InvoiceList renders items and view-all link', () => { + const inv = sampleInvoice(); + render( + + + + ); + expect(screen.getByText('Recent activity')).toBeInTheDocument(); + expect(screen.getByText('View all invoices →')).toBeInTheDocument(); + }); + + it('InvoiceTable shows empty, loading, and row states', () => { + const { rerender } = render( + + + + ); + expect(screen.getByTestId('skeleton')).toBeInTheDocument(); + rerender( + + + + ); + expect(screen.getByText(/No invoices yet/i)).toBeInTheDocument(); + const inv = sampleInvoice(); + rerender( + + + + ); + expect(screen.getByText('Subscription')).toBeInTheDocument(); + }); + + it('ConfirmDowngradeModal shows actions when open', async () => { + const user = userEvent.setup(); + const onClose = vi.fn(); + const onConfirm = vi.fn(); + render( + + ); + const dialog = screen.getByRole('dialog'); + expect(within(dialog).getByText('Body')).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Cancel' })); + expect(onClose).toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/components/dashboard/AISummarizeResult.tsx b/apps/web/src/components/dashboard/AISummarizeResult.tsx new file mode 100644 index 00000000..a35e7f76 --- /dev/null +++ b/apps/web/src/components/dashboard/AISummarizeResult.tsx @@ -0,0 +1,37 @@ +export type SummaryEntry = { + id: string; + at: number; + text: string; +}; + +type Props = { + entries: SummaryEntry[]; +}; + +export function AISummarizeResult({ entries }: Props) { + return ( +
+ {entries.length === 0 ? ( +

+ Click 'Simulate AI summarize' to generate a result. +

+ ) : ( +
    + {entries.map(e => ( +
  • +

    + {new Date(e.at).toLocaleString()} +

    +

    + {e.text} +

    +
  • + ))} +
+ )} +
+ ); +} diff --git a/apps/web/src/components/dashboard/BooleanGatesDemo.tsx b/apps/web/src/components/dashboard/BooleanGatesDemo.tsx new file mode 100644 index 00000000..e653a0e7 --- /dev/null +++ b/apps/web/src/components/dashboard/BooleanGatesDemo.tsx @@ -0,0 +1,92 @@ +import { Link } from 'react-router-dom'; +import { Check } from 'lucide-react'; +import { useFeature, usePlan } from '@beakerstack/billing'; +import { FeatureGate } from '@beakerstack/billing/web'; +import { beakerstackBillingConfig } from '../../billing/beakerstackBillingConfig'; + +function UpgradeLine({ name }: { name: string }) { + return ( +

+ {name} requires a higher plan.{' '} + + Upgrade → + +

+ ); +} + +export function BooleanGatesDemo() { + const { loading: planLoading } = usePlan(); + const imperativeA = useFeature( + 'feature_a' + ); + const imperativeB = useFeature( + 'feature_b' + ); + + return ( +
+
+

+ Feature A (requires Pro) +

+
+ {planLoading ? ( + + ) : ( + + feature='feature_a' + fallback={} + > +

+ + Feature A is enabled for your plan. +

+ + )} +
+
+
+

+ Feature B (requires Max) +

+
+ {planLoading ? ( + + ) : ( + + feature='feature_b' + fallback={} + > +

+ + Feature B is enabled for your plan. +

+ + )} +
+
+

+ Imperative equivalent:{' '} + + const {'{'} enabled {'}'} = useFeature("feature_a") + {' '} + → currently{' '} + + {imperativeA.loading ? '…' : String(imperativeA.enabled)} + + {' · '} + + useFeature("feature_b") + {' '} + →{' '} + + {imperativeB.loading ? '…' : String(imperativeB.enabled)} + +

+
+ ); +} diff --git a/apps/web/src/components/dashboard/DashboardDemoSection.tsx b/apps/web/src/components/dashboard/DashboardDemoSection.tsx new file mode 100644 index 00000000..63bbf6f2 --- /dev/null +++ b/apps/web/src/components/dashboard/DashboardDemoSection.tsx @@ -0,0 +1,45 @@ +import type { ReactNode } from 'react'; + +export type DashboardDemoSectionProps = { + title: string; + demonstrates: string; + description: string; + codeReference: string; + children: ReactNode; + variant?: 'default' | 'demo-mode'; +}; + +export function DashboardDemoSection({ + title, + demonstrates, + description, + codeReference, + children, + variant = 'default', +}: DashboardDemoSectionProps) { + const borderClass = + variant === 'demo-mode' + ? 'border-dashed border-gray-300' + : 'border-gray-200'; + return ( +
+ {variant === 'demo-mode' && ( + + Demo mode only + + )} +

{title}

+

+ Demonstrates:{' '} + + {demonstrates} + +

+

{description}

+
{children}
+

// {codeReference}

+
+ ); +} diff --git a/apps/web/src/components/dashboard/DemoControlsPanel.tsx b/apps/web/src/components/dashboard/DemoControlsPanel.tsx new file mode 100644 index 00000000..6ea34161 --- /dev/null +++ b/apps/web/src/components/dashboard/DemoControlsPanel.tsx @@ -0,0 +1,132 @@ +import { useCallback, useState } from 'react'; +import { useBillingContext, usePlan, useUsage } from '@beakerstack/billing'; +import { supabaseRpc } from '@/lib/supabase'; +import { + beakerstackBillingConfig, + BEAKERSTACK_METER_AI_SUMMARIZE, +} from '../../billing/beakerstackBillingConfig'; +import { DashboardDemoSection } from './DashboardDemoSection'; + +const PLANS = [ + { id: 'beakerstack_free' as const, label: 'Free' }, + { id: 'beakerstack_pro' as const, label: 'Pro' }, + { id: 'beakerstack_max' as const, label: 'Max' }, +]; + +const METER_KEYS = [BEAKERSTACK_METER_AI_SUMMARIZE] as const; + +export function DemoControlsPanel() { + if (import.meta.env.VITE_BILLING_DEMO_MODE !== 'true') { + return null; + } + + const { data: plan, loading: planLoading } = + usePlan(); + const { refreshSubscription } = + useBillingContext(); + const { refresh: refreshUsage } = useUsage< + typeof beakerstackBillingConfig, + typeof BEAKERSTACK_METER_AI_SUMMARIZE + >(BEAKERSTACK_METER_AI_SUMMARIZE); + + const [pending, setPending] = useState(null); + const [message, setMessage] = useState(null); + + const run = useCallback(async (key: string, fn: () => Promise) => { + setMessage(null); + setPending(key); + try { + await fn(); + setMessage('Done.'); + } catch (e) { + setMessage( + e instanceof Error ? e.message : 'Request failed. Is demo mode on?' + ); + } finally { + setPending(null); + } + }, []); + + const onSimulatePlan = useCallback( + (planId: (typeof PLANS)[number]['id']) => { + void run(`plan:${planId}`, async () => { + const { error } = await supabaseRpc.rpc( + 'billing_demo_simulate_upgrade', + { + p_product_id: beakerstackBillingConfig.productId, + p_plan_id: planId, + } + ); + if (error) throw error; + await refreshSubscription(); + }); + }, + [refreshSubscription, run] + ); + + const onResetUsage = useCallback(() => { + void run('reset', async () => { + for (const m of METER_KEYS) { + const { error } = await supabaseRpc.rpc('billing_demo_reset_usage', { + p_product_id: beakerstackBillingConfig.productId, + p_event_type: m, + }); + if (error) throw error; + } + await refreshUsage(); + }); + }, [refreshUsage, run]); + + return ( + +

+ Current plan:{' '} + + {planLoading ? '…' : (plan?.display_name ?? '—')} + +

+ +
+ {PLANS.map(p => ( + + ))} +
+ +
+ +
+ + {message && ( +

+ {message} +

+ )} + +

+ These actions take effect immediately and bypass Stripe. Do not deploy + to production. +

+
+ ); +} diff --git a/apps/web/src/components/dashboard/MeteredUsageDemo.tsx b/apps/web/src/components/dashboard/MeteredUsageDemo.tsx new file mode 100644 index 00000000..1a6b1abd --- /dev/null +++ b/apps/web/src/components/dashboard/MeteredUsageDemo.tsx @@ -0,0 +1,169 @@ +import { useCallback, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { + mapUnknownError, + useBillingContext, + useUsage, +} from '@beakerstack/billing'; +import type { BillingError } from '@beakerstack/billing'; +import { supabase, supabaseRpc } from '@/lib/supabase'; +import { + beakerstackBillingConfig, + BEAKERSTACK_METER_AI_SUMMARIZE, +} from '../../billing/beakerstackBillingConfig'; +import { nextFakeAiSummary } from '../../lib/fakeAi'; +import { AISummarizeResult, type SummaryEntry } from './AISummarizeResult'; + +const useRealAi = import.meta.env.VITE_DEMO_USE_REAL_AI === 'true'; + +export function MeteredUsageDemo() { + const { config } = useBillingContext(); + const { + used, + limit, + resetsAt, + exceeded, + loading, + error: usageError, + refresh, + } = useUsage< + typeof beakerstackBillingConfig, + typeof BEAKERSTACK_METER_AI_SUMMARIZE + >(BEAKERSTACK_METER_AI_SUMMARIZE); + + const [results, setResults] = useState([]); + const [pending, setPending] = useState(false); + const [recordError, setRecordError] = useState(null); + + const resolveSummaryText = useCallback(async (): Promise => { + if (useRealAi) { + try { + const { data, error: fnErr } = await supabase.functions.invoke( + 'demo-ai-summarize', + { + body: { + prompt: 'Write a short lorem-style summary (3-5 lines).', + }, + } + ); + if (!fnErr) { + const t = (data as { text?: string } | null)?.text; + if (typeof t === 'string' && t.trim()) { + return t.trim(); + } + } + } catch { + /* optional demo-ai edge function unavailable — fallback below */ + } + } + return nextFakeAiSummary(); + }, []); + + const pushResult = useCallback((text: string) => { + const id = `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; + setResults(prev => { + const next: SummaryEntry[] = [{ id, at: Date.now(), text }, ...prev]; + return next.slice(0, 3); + }); + }, []); + + const onSimulate = useCallback(async () => { + if (exceeded || pending) return; + const summaryText = await resolveSummaryText(); + setPending(true); + setRecordError(null); + try { + const { error: rpcErr } = await supabaseRpc.rpc( + 'billing_record_usage_event', + { + p_product_id: config.productId, + p_event_type: BEAKERSTACK_METER_AI_SUMMARIZE, + p_quantity: 1, + p_metadata: {}, + } + ); + if (rpcErr) throw rpcErr; + await refresh(); + pushResult(summaryText); + } catch (e) { + setRecordError(mapUnknownError(e)); + } finally { + setPending(false); + } + }, [ + exceeded, + pending, + config.productId, + refresh, + resolveSummaryText, + pushResult, + ]); + + const lim = limit === null ? '∞' : String(limit); + const capLine = + limit === null + ? `${used} used this period · unlimited` + : `${used} of ${lim} used · resets ${resetsAt ? new Date(resetsAt).toLocaleDateString() : '—'}`; + const pct = + limit != null && limit > 0 ? Math.min(100, (used / limit) * 100) : 0; + + const displayError = recordError ?? usageError; + + return ( +
+
+ {limit != null && ( +
+
+
+ )} +
+ {loading ? '…' : capLine} +
+
+
+ {exceeded ? ( + <> + + Limit reached + + + Upgrade + + + ) : ( + + )} +
+ {displayError && ( +

+ {displayError.message} +

+ )} + +
+ ); +} diff --git a/apps/web/src/components/dashboard/NumericCapsDemo.tsx b/apps/web/src/components/dashboard/NumericCapsDemo.tsx new file mode 100644 index 00000000..2462e932 --- /dev/null +++ b/apps/web/src/components/dashboard/NumericCapsDemo.tsx @@ -0,0 +1,181 @@ +import { useCallback, useState } from 'react'; +import { Trash2 } from 'lucide-react'; +import { useFeature } from '@beakerstack/billing'; +import { beakerstackBillingConfig } from '../../billing/beakerstackBillingConfig'; +import { useDemoCollections } from '../../billing/useDemoCollections'; + +function limLabel(v: number | null): string { + if (v === null) return '…'; + if (v === -1) return '∞'; + return String(v); +} + +export function NumericCapsDemo() { + const { + collections, + loading, + error, + addCollection, + deleteCollection, + addItem, + } = useDemoCollections(); + const { value: maxCollectionsRaw, loading: featColLoading } = useFeature< + typeof beakerstackBillingConfig, + 'containers_per_account_max' + >('containers_per_account_max'); + const { value: maxItemsRaw, loading: featItemLoading } = useFeature< + typeof beakerstackBillingConfig, + 'items_per_container_max' + >('items_per_container_max'); + + const maxCollections = + typeof maxCollectionsRaw === 'number' ? maxCollectionsRaw : null; + const maxItemsPer = typeof maxItemsRaw === 'number' ? maxItemsRaw : null; + + const count = collections.length; + const atCollectionCap = + maxCollections !== null && maxCollections !== -1 && count >= maxCollections; + + const [busy, setBusy] = useState<'add-col' | string | null>(null); + const [actionErr, setActionErr] = useState(null); + + const wrap = useCallback( + async (key: typeof busy, fn: () => Promise) => { + setActionErr(null); + setBusy(key); + try { + await fn(); + } catch (e) { + setActionErr(e instanceof Error ? e.message : 'Action failed.'); + } finally { + setBusy(null); + } + }, + [] + ); + + const featLoading = featColLoading || featItemLoading; + + return ( +
+ {error && ( +

+ {error}{' '} + + (Requires demo billing RPCs and{' '} + demo_billing_mode in the database.) + +

+ )} +
+

+ Collections:{' '} + + {loading || featLoading ? '…' : count} of {limLabel(maxCollections)} + +

+ +
+ + {actionErr && ( +

+ {actionErr} +

+ )} + + {!loading && collections.length === 0 ? ( +

+ No collections yet. Click 'Add collection' to start. +

+ ) : ( +
    + {collections.map(row => { + const itemCap = + maxItemsPer !== null && + maxItemsPer !== -1 && + row.item_count >= maxItemsPer; + const busyKey = `item:${row.id}`; + const delKey = `del:${row.id}`; + return ( +
  • +
    +
    +

    + {row.id.slice(0, 8)}… +

    +

    + Items: {row.item_count} of {limLabel(maxItemsPer)} +

    +
    +
    + + +
    +
    +
  • + ); + })} +
+ )} +
+ ); +} diff --git a/apps/web/src/components/dashboard/__tests__/AISummarizeResult.test.tsx b/apps/web/src/components/dashboard/__tests__/AISummarizeResult.test.tsx new file mode 100644 index 00000000..a9ed4a93 --- /dev/null +++ b/apps/web/src/components/dashboard/__tests__/AISummarizeResult.test.tsx @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { AISummarizeResult } from '../AISummarizeResult'; + +describe('AISummarizeResult', () => { + it('shows placeholder when there are no entries', () => { + render(); + expect(screen.getByText(/Simulate AI summarize/i)).toBeInTheDocument(); + }); + + it('renders entry timestamps and text', () => { + render( + + ); + expect(screen.getByRole('listitem')).toHaveTextContent('Hello'); + expect(screen.getByRole('listitem')).toHaveTextContent('world'); + expect(screen.getByText(/2024/i)).toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/components/dashboard/__tests__/DemoControlsPanel.test.tsx b/apps/web/src/components/dashboard/__tests__/DemoControlsPanel.test.tsx new file mode 100644 index 00000000..e5a2507b --- /dev/null +++ b/apps/web/src/components/dashboard/__tests__/DemoControlsPanel.test.tsx @@ -0,0 +1,110 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { DemoControlsPanel } from '../DemoControlsPanel'; + +const refreshSubscription = vi.fn().mockResolvedValue(undefined); +const refreshUsage = vi.fn().mockResolvedValue(undefined); + +vi.mock('@beakerstack/billing', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + usePlan: () => ({ + data: { id: 'beakerstack_free', display_name: 'Free' }, + loading: false, + error: null, + }), + useBillingContext: () => ({ + refreshSubscription, + config: {}, + }), + useUsage: () => ({ + used: 0, + limit: 10, + refresh: refreshUsage, + loading: false, + error: null, + exceeded: false, + remaining: 10, + resetsAt: null, + }), + }; +}); + +const demoSupabase = vi.hoisted(() => { + const rpc = vi.fn(); + const client = { rpc }; + return { rpc, client }; +}); + +vi.mock('@/lib/supabase', () => ({ + supabase: demoSupabase.client, + supabaseRpc: demoSupabase.client, +})); + +describe('DemoControlsPanel', () => { + beforeEach(() => { + vi.stubEnv('VITE_BILLING_DEMO_MODE', 'true'); + demoSupabase.rpc.mockReset(); + refreshSubscription.mockClear(); + refreshUsage.mockClear(); + demoSupabase.rpc.mockResolvedValue({ data: null, error: null }); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it('renders nothing when VITE_BILLING_DEMO_MODE is not true', () => { + vi.unstubAllEnvs(); + vi.stubEnv('VITE_BILLING_DEMO_MODE', 'false'); + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it('shows current plan and runs simulate upgrade RPC', async () => { + const user = userEvent.setup(); + render(); + expect(screen.getByText(/Current plan:/i)).toBeInTheDocument(); + expect(screen.getByText('Free')).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: /Switch to Pro/i })); + expect(demoSupabase.rpc).toHaveBeenCalledWith( + 'billing_demo_simulate_upgrade', + { + p_product_id: 'beakerstack', + p_plan_id: 'beakerstack_pro', + } + ); + await waitFor(() => { + expect(refreshSubscription).toHaveBeenCalled(); + }); + expect(screen.getByRole('status')).toHaveTextContent('Done.'); + }); + + it('shows friendly message when RPC throws non-Error', async () => { + const user = userEvent.setup(); + demoSupabase.rpc.mockRejectedValue('fail'); + render(); + await user.click(screen.getByRole('button', { name: /Switch to Max/i })); + await waitFor(() => { + expect(screen.getByRole('status')).toHaveTextContent(/Request failed/i); + }); + }); + + it('resets usage for each meter and refreshes usage', async () => { + const user = userEvent.setup(); + render(); + await user.click( + screen.getByRole('button', { name: /Reset all usage counters/i }) + ); + expect(demoSupabase.rpc).toHaveBeenCalledWith( + 'billing_demo_reset_usage', + expect.objectContaining({ p_product_id: 'beakerstack' }) + ); + await waitFor(() => { + expect(refreshUsage).toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/web/src/components/dashboard/__tests__/MeteredUsageDemo.test.tsx b/apps/web/src/components/dashboard/__tests__/MeteredUsageDemo.test.tsx new file mode 100644 index 00000000..bf8bf553 --- /dev/null +++ b/apps/web/src/components/dashboard/__tests__/MeteredUsageDemo.test.tsx @@ -0,0 +1,154 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter } from 'react-router-dom'; +import { MeteredUsageDemo } from '../MeteredUsageDemo'; + +const usage = vi.hoisted(() => ({ + used: 2, + limit: 10 as number | null, + resetsAt: '2026-06-01T00:00:00.000Z' as string | null, + exceeded: false, + loading: false, + error: null as { message: string } | null, + refresh: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('@beakerstack/billing', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + useBillingContext: () => ({ + config: { productId: 'beakerstack' }, + }), + useUsage: () => ({ + used: usage.used, + limit: usage.limit, + resetsAt: usage.resetsAt, + exceeded: usage.exceeded, + loading: usage.loading, + error: usage.error, + refresh: usage.refresh, + remaining: + usage.limit != null ? Math.max(0, usage.limit - usage.used) : null, + }), + }; +}); + +const meterSupabase = vi.hoisted(() => { + const rpc = vi.fn(); + const invoke = vi.fn(); + const client = { + rpc, + functions: { invoke }, + }; + return { rpc, invoke, client }; +}); + +vi.mock('@/lib/supabase', () => ({ + supabase: meterSupabase.client, + supabaseRpc: meterSupabase.client, +})); + +const renderMetered = () => + render( + + + + ); + +describe('MeteredUsageDemo', () => { + beforeEach(() => { + vi.unstubAllEnvs(); + usage.used = 2; + usage.limit = 10; + usage.resetsAt = '2026-06-01T00:00:00.000Z'; + usage.exceeded = false; + usage.loading = false; + usage.error = null; + usage.refresh.mockClear(); + meterSupabase.rpc.mockReset(); + meterSupabase.invoke.mockReset(); + meterSupabase.rpc.mockResolvedValue({ error: null }); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it('shows loading placeholder in cap line when usage is loading', () => { + usage.loading = true; + renderMetered(); + expect(screen.getByTestId('usage-indicator-expanded')).toHaveTextContent( + '…' + ); + }); + + it('renders unlimited cap copy when limit is null', () => { + usage.limit = null; + renderMetered(); + expect(screen.getByText(/unlimited/i)).toBeInTheDocument(); + }); + + it('uses em dash when limit set but resetsAt missing', () => { + usage.resetsAt = null; + renderMetered(); + expect(screen.getByText(/resets —/)).toBeInTheDocument(); + }); + + it('records usage then refreshes and shows AI result', async () => { + const user = userEvent.setup(); + renderMetered(); + await user.click( + screen.getByRole('button', { name: /Simulate AI summarize/i }) + ); + await waitFor(() => { + expect(meterSupabase.rpc).toHaveBeenCalledWith( + 'billing_record_usage_event', + expect.objectContaining({ + p_product_id: 'beakerstack', + p_event_type: 'ai_summarize', + p_quantity: 1, + }) + ); + }); + await waitFor(() => { + expect(usage.refresh).toHaveBeenCalled(); + }); + expect(screen.getByRole('listitem')).toBeInTheDocument(); + }); + + it('does not simulate when already exceeded', async () => { + const user = userEvent.setup(); + usage.exceeded = true; + renderMetered(); + expect(screen.getByText(/Limit reached/i)).toBeInTheDocument(); + expect(screen.getByRole('link', { name: /Upgrade/i })).toHaveAttribute( + 'href', + '/billing/plans' + ); + expect( + screen.queryByRole('button', { name: /Simulate AI summarize/i }) + ).not.toBeInTheDocument(); + await user.click(screen.getByRole('link', { name: /Upgrade/i })); + expect(meterSupabase.rpc).not.toHaveBeenCalled(); + }); + + it('shows usage hook error in alert', () => { + usage.error = { message: 'Usage unavailable' }; + renderMetered(); + expect(screen.getByRole('alert')).toHaveTextContent('Usage unavailable'); + }); + + it('maps RPC failure to billing error message', async () => { + const user = userEvent.setup(); + meterSupabase.rpc.mockRejectedValue(new Error('cap exceeded')); + renderMetered(); + await user.click( + screen.getByRole('button', { name: /Simulate AI summarize/i }) + ); + await waitFor(() => { + expect(screen.getByRole('alert')).toHaveTextContent('cap exceeded'); + }); + }); +}); diff --git a/apps/web/src/components/dashboard/__tests__/NumericCapsDemo.test.tsx b/apps/web/src/components/dashboard/__tests__/NumericCapsDemo.test.tsx new file mode 100644 index 00000000..12be485f --- /dev/null +++ b/apps/web/src/components/dashboard/__tests__/NumericCapsDemo.test.tsx @@ -0,0 +1,138 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { NumericCapsDemo } from '../NumericCapsDemo'; + +const demo = vi.hoisted(() => ({ + collections: [] as { id: string; item_count: number }[], + loading: false, + error: null as string | null, + addCollection: vi.fn().mockResolvedValue(undefined), + deleteCollection: vi.fn().mockResolvedValue(undefined), + addItem: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('@/billing/useDemoCollections', () => ({ + useDemoCollections: () => ({ + collections: demo.collections, + loading: demo.loading, + error: demo.error, + refresh: vi.fn(), + addCollection: demo.addCollection, + deleteCollection: demo.deleteCollection, + addItem: demo.addItem, + }), +})); + +const feat = vi.hoisted(() => ({ + colMax: 5 as number | string | null, + colLoading: false, + itemMax: 3 as number | string | null, + itemLoading: false, +})); + +vi.mock('@beakerstack/billing', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + useFeature: (key: string) => { + if (key === 'containers_per_account_max') { + return { value: feat.colMax, loading: feat.colLoading }; + } + if (key === 'items_per_container_max') { + return { value: feat.itemMax, loading: feat.itemLoading }; + } + return { value: null, loading: false }; + }, + }; +}); + +describe('NumericCapsDemo', () => { + beforeEach(() => { + demo.collections = []; + demo.loading = false; + demo.error = null; + demo.addCollection.mockClear(); + demo.deleteCollection.mockClear(); + demo.addItem.mockClear(); + feat.colMax = 5; + feat.colLoading = false; + feat.itemMax = 3; + feat.itemLoading = false; + }); + + it('shows collections error banner when hook reports error', () => { + demo.error = 'RPC failed'; + render(); + expect(screen.getByRole('alert')).toHaveTextContent('RPC failed'); + expect(screen.getByText(/demo_billing_mode/i)).toBeInTheDocument(); + }); + + it('shows empty state when there are no collections', () => { + render(); + expect(screen.getByText(/No collections yet/i)).toBeInTheDocument(); + }); + + it('shows ellipsis in cap labels while feature limits are loading', () => { + feat.colLoading = true; + render(); + expect(screen.getByText(/Collections:/i).textContent).toMatch(/…/); + }); + + it('shows infinity label when limits are unlimited (-1)', () => { + feat.colMax = -1; + feat.itemMax = -1; + demo.collections = [{ id: 'abc12345', item_count: 0 }]; + const { container } = render(); + expect(container.textContent).toContain('\u221e'); + }); + + it('adds a collection via wrap helper', async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole('button', { name: /Add collection/i })); + expect(demo.addCollection).toHaveBeenCalled(); + }); + + it('disables add collection at cap and shows Limit reached', () => { + feat.colMax = 1; + demo.collections = [{ id: 'row1', item_count: 0 }]; + render(); + const btn = screen.getByRole('button', { name: /Limit reached/i }); + expect(btn).toBeDisabled(); + }); + + it('adds item and deletes collection', async () => { + const user = userEvent.setup(); + demo.collections = [{ id: 'coll-1', item_count: 0 }]; + render(); + + await user.click(screen.getByRole('button', { name: /Add item/i })); + expect(demo.addItem).toHaveBeenCalledWith('coll-1'); + + await user.click( + screen.getByRole('button', { name: /Delete collection/i }) + ); + expect(demo.deleteCollection).toHaveBeenCalledWith('coll-1'); + }); + + it('disables add item when row hits per-collection item cap', () => { + feat.itemMax = 2; + demo.collections = [{ id: 'c2', item_count: 2 }]; + render(); + const limits = screen.getAllByRole('button', { name: /Limit reached/i }); + expect(limits.length).toBeGreaterThanOrEqual(1); + expect(limits[0]).toBeDisabled(); + }); + + it('surfaces non-Error rejects from addItem', async () => { + const user = userEvent.setup(); + demo.collections = [{ id: 'c9', item_count: 0 }]; + demo.addItem.mockRejectedValue('x'); + render(); + await user.click(screen.getByRole('button', { name: /Add item/i })); + await waitFor(() => { + expect(screen.getByRole('alert')).toHaveTextContent('Action failed.'); + }); + }); +}); diff --git a/apps/web/src/components/dashboard/__tests__/indexExports.test.ts b/apps/web/src/components/dashboard/__tests__/indexExports.test.ts new file mode 100644 index 00000000..96dfb362 --- /dev/null +++ b/apps/web/src/components/dashboard/__tests__/indexExports.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest'; +import { + AISummarizeResult, + BooleanGatesDemo, + DashboardDemoSection, + DemoControlsPanel, + MeteredUsageDemo, + NumericCapsDemo, +} from '../index'; + +describe('dashboard index exports', () => { + it('re-exports demo components', () => { + expect(typeof AISummarizeResult).toBe('function'); + expect(typeof BooleanGatesDemo).toBe('function'); + expect(typeof DashboardDemoSection).toBe('function'); + expect(typeof DemoControlsPanel).toBe('function'); + expect(typeof MeteredUsageDemo).toBe('function'); + expect(typeof NumericCapsDemo).toBe('function'); + }); +}); diff --git a/apps/web/src/components/dashboard/index.ts b/apps/web/src/components/dashboard/index.ts new file mode 100644 index 00000000..01023aa5 --- /dev/null +++ b/apps/web/src/components/dashboard/index.ts @@ -0,0 +1,9 @@ +export { AISummarizeResult, type SummaryEntry } from './AISummarizeResult'; +export { BooleanGatesDemo } from './BooleanGatesDemo'; +export { + DashboardDemoSection, + type DashboardDemoSectionProps, +} from './DashboardDemoSection'; +export { DemoControlsPanel } from './DemoControlsPanel'; +export { MeteredUsageDemo } from './MeteredUsageDemo'; +export { NumericCapsDemo } from './NumericCapsDemo'; diff --git a/apps/web/src/lib/__tests__/fakeAi.test.ts b/apps/web/src/lib/__tests__/fakeAi.test.ts new file mode 100644 index 00000000..0506177a --- /dev/null +++ b/apps/web/src/lib/__tests__/fakeAi.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from 'vitest'; +import { nextFakeAiSummary } from '../fakeAi'; + +describe('nextFakeAiSummary', () => { + it('returns non-empty text and rotates through snippets', () => { + const a = nextFakeAiSummary(); + const b = nextFakeAiSummary(); + expect(a.length).toBeGreaterThan(20); + expect(b.length).toBeGreaterThan(20); + expect(a).not.toBe(b); + }); +}); diff --git a/apps/web/src/lib/fakeAi.ts b/apps/web/src/lib/fakeAi.ts new file mode 100644 index 00000000..b4d86f8d --- /dev/null +++ b/apps/web/src/lib/fakeAi.ts @@ -0,0 +1,29 @@ +/** Canned “AI summary” paragraphs for dashboard demo (no API keys). */ + +const SNIPPETS: string[] = [ + `Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed non risus. Suspendisse lectus tortor, dignissim sit amet, adipiscing nec, ultricies sed, dolor. Cras elementum ultrices diam.`, + + `Maecenas ligula massa, varius a, semper congue, euismod non, mi. Proin porttitor, orci nec nonummy molestie, enim est eleifend mi, non fermentum diam nisl sit amet erat.`, + + `Duis semper. Duis arcu massa, scelerisque vitae, consequat in, pretium a, enim. Pellentesque congue. Ut in risus volutpat libero pharetra tempor. Cras vestibulum bibendum augue.`, + + `Praesent egestas tristique nibh. Sed a libero. Praesent ac massa at ligula laoreet iaculis. Fusce commodo diam libero, non interdum nunc luctus in. Integer tincidunt a nunc sed luctus.`, + + `Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Fusce id purus. Ut varius tincidunt libero. Phasellus dolor. Maecenas vestibulum mollis diam.`, + + `Aenean leo ligula, porttitor eu, consequat vitae, eleifend ac, enim. Aliquam lorem ante, dapibus in, viverra quis, feugiat a, tellus. Phasellus viverra nulla ut metus varius laoreet.`, + + `Quisque rutrum. Aenean imperdiet. Etiam ultricies nisi vel augue. Curabitur ullamcorper ultricies nisi. Nam eget dui. Etiam rhoncus. Maecenas tempus, tellus eget condimentum rhoncus.`, + + `Donec quam felis, ultricies nec, pellentesque eu, pretium quis, sem. Nulla consequat massa quis enim. Donec pede justo, fringilla vel, aliquet nec, vulputate eget, arcu.`, + + `In enim justo, rhoncus ut, imperdiet a, venenatis vitae, justo. Nullam dictum felis eu pede mollis pretium. Integer tincidunt. Cras dapibus. Vivamus elementum semper nisi.`, +]; + +let rotateIndex = 0; + +export function nextFakeAiSummary(): string { + const text = SNIPPETS[rotateIndex % SNIPPETS.length]; + rotateIndex += 1; + return text; +} diff --git a/apps/web/src/lib/supabase.ts b/apps/web/src/lib/supabase.ts index ecfbbb8a..1c2fd876 100644 --- a/apps/web/src/lib/supabase.ts +++ b/apps/web/src/lib/supabase.ts @@ -1,4 +1,4 @@ -import { createClient } from '@supabase/supabase-js'; +import { createClient, type SupabaseClient } from '@supabase/supabase-js'; import type { Database } from '@beakerstack/shared/types/database'; import { Logger } from '@beakerstack/shared/utils/logger'; @@ -27,4 +27,12 @@ if (import.meta.env.DEV) { export const supabase = createClient(supabaseUrl, supabaseAnonKey); -export type SupabaseClient = typeof supabase; +/** + * Same client as {@link supabase}. Use for `rpc()` calls whose names are not + * always inferred from `Database['public']['Functions']` (e.g. strict CI + * builds resolving an incomplete RPC map). + */ +export const supabaseRpc: SupabaseClient = + supabase as unknown as SupabaseClient; + +export type TypedSupabaseClient = typeof supabase; diff --git a/apps/web/src/pages/DashboardPage.tsx b/apps/web/src/pages/DashboardPage.tsx index 0f09379a..ca1f62cd 100644 --- a/apps/web/src/pages/DashboardPage.tsx +++ b/apps/web/src/pages/DashboardPage.tsx @@ -1,8 +1,10 @@ +import { Link } from 'react-router-dom'; import { AppHeader } from '@beakerstack/shared/components/navigation/AppHeader.web'; -import { - DASHBOARD_TITLE, - DASHBOARD_SUBTITLE, -} from '@beakerstack/shared/utils/strings'; +import { BooleanGatesDemo } from '@/components/dashboard/BooleanGatesDemo'; +import { DashboardDemoSection } from '@/components/dashboard/DashboardDemoSection'; +import { DemoControlsPanel } from '@/components/dashboard/DemoControlsPanel'; +import { MeteredUsageDemo } from '@/components/dashboard/MeteredUsageDemo'; +import { NumericCapsDemo } from '@/components/dashboard/NumericCapsDemo'; import { supabase } from '@/lib/supabase'; export default function DashboardPage() { @@ -10,15 +12,72 @@ export default function DashboardPage() {
-
-
-
-
-

- {DASHBOARD_TITLE} -

-

{DASHBOARD_SUBTITLE}

-
+
+
+

+ Welcome to BeakerStack +

+

+ This dashboard is a sandbox for exercising the billing primitives in{' '} + + @beakerstack/billing + {' '} + directly. Each section below demonstrates one capability with + working controls and code references. For the polished, + production-style billing UI, visit{' '} + + Billing + + . +

+
+ + View polished billing pages → + + {/* TODO: replace href when billing integration guide URL is published */} + + Read the integration guide → + +
+ +
+ + + + + + + + + + + + +
diff --git a/apps/web/src/pages/HomePage.tsx b/apps/web/src/pages/HomePage.tsx index d9b9c6dd..36720061 100644 --- a/apps/web/src/pages/HomePage.tsx +++ b/apps/web/src/pages/HomePage.tsx @@ -13,7 +13,7 @@ export default function HomePage() { {/* Main Content */} -
+

{HOME_TITLE} diff --git a/apps/web/src/pages/LoginPage.tsx b/apps/web/src/pages/LoginPage.tsx index 88cf06bb..77dd2eb1 100644 --- a/apps/web/src/pages/LoginPage.tsx +++ b/apps/web/src/pages/LoginPage.tsx @@ -48,7 +48,7 @@ export default function LoginPage() { return (
-
+

diff --git a/apps/web/src/pages/ProfilePage.tsx b/apps/web/src/pages/ProfilePage.tsx index c6bb4826..2dbadd1e 100644 --- a/apps/web/src/pages/ProfilePage.tsx +++ b/apps/web/src/pages/ProfilePage.tsx @@ -20,7 +20,7 @@ export default function ProfilePage() { {/* Main Content */} -
+
{/* Loading State */} {profile.loading && ( diff --git a/apps/web/src/pages/SignupPage.tsx b/apps/web/src/pages/SignupPage.tsx index 47ced867..c09bd5dc 100644 --- a/apps/web/src/pages/SignupPage.tsx +++ b/apps/web/src/pages/SignupPage.tsx @@ -54,7 +54,7 @@ export default function SignupPage() { return (
-
+

diff --git a/apps/web/src/pages/__tests__/AuthCallbackPage.coverage.test.tsx b/apps/web/src/pages/__tests__/AuthCallbackPage.coverage.test.tsx new file mode 100644 index 00000000..27a63f9a --- /dev/null +++ b/apps/web/src/pages/__tests__/AuthCallbackPage.coverage.test.tsx @@ -0,0 +1,209 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, act } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import AuthCallbackPage from '../AuthCallbackPage'; + +const mockNavigate = vi.fn(); + +const auth = vi.hoisted(() => ({ + user: null as { id: string; email?: string } | null, + loading: true, +})); + +vi.mock('react-router-dom', async () => { + const actual = + await vi.importActual( + 'react-router-dom' + ); + return { + ...actual, + useNavigate: () => mockNavigate, + }; +}); + +vi.mock('@beakerstack/shared/contexts/AuthContext', () => ({ + useAuthContext: () => ({ + user: auth.user, + loading: auth.loading, + session: null, + error: null, + signIn: vi.fn(), + signUp: vi.fn(), + signOut: vi.fn(), + signInWithGoogle: vi.fn(), + }), +})); + +describe('AuthCallbackPage (URL + auth branches)', () => { + const original = window.location; + + beforeEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); + auth.user = null; + auth.loading = true; + Object.defineProperty(window, 'location', { + configurable: true, + value: { + ...original, + hash: '', + search: '', + }, + }); + }); + + afterEach(() => { + vi.useRealTimers(); + Object.defineProperty(window, 'location', { + configurable: true, + value: original, + }); + }); + + it('shows OAuth error from query and schedules redirect to login', async () => { + vi.useFakeTimers(); + Object.defineProperty(window, 'location', { + configurable: true, + value: { + ...original, + hash: '', + search: '?error=access_denied&error_description=User+cancelled', + }, + }); + + await act(async () => { + render( + + + + ); + }); + + expect(screen.getByText(/Authentication Error/i)).toBeInTheDocument(); + expect(screen.getByText(/User cancelled/i)).toBeInTheDocument(); + + await act(async () => { + vi.advanceTimersByTime(3000); + }); + expect(mockNavigate).toHaveBeenCalledWith('/login', { replace: true }); + vi.useRealTimers(); + }); + + it('reads OAuth error from hash when query is empty', async () => { + Object.defineProperty(window, 'location', { + configurable: true, + value: { + ...original, + search: '', + hash: '#error=server_error&error_description=temp', + }, + }); + + render( + + + + ); + + expect(await screen.findByText(/temp/i)).toBeInTheDocument(); + }); + + it('uses generic copy when error_description is missing', async () => { + Object.defineProperty(window, 'location', { + configurable: true, + value: { + ...original, + search: '?error=access_denied', + hash: '', + }, + }); + + render( + + + + ); + + expect( + await screen.findByText(/Authentication failed\. Please try again\./i) + ).toBeInTheDocument(); + }); + + it('redirects to dashboard when hash token present and session is ready', async () => { + vi.useFakeTimers(); + Object.defineProperty(window, 'location', { + configurable: true, + value: { + ...original, + search: '', + hash: '#access_token=tok', + }, + }); + auth.loading = false; + auth.user = { id: 'u1', email: 'a@b.c' }; + + await act(async () => { + render( + + + + ); + }); + + await act(async () => { + vi.advanceTimersByTime(1000); + }); + expect(mockNavigate).toHaveBeenCalledWith('/dashboard', { + replace: true, + }); + vi.useRealTimers(); + }); + + it('sets session error when token hash exists but user never appears', async () => { + vi.useFakeTimers(); + Object.defineProperty(window, 'location', { + configurable: true, + value: { + ...original, + search: '', + hash: '#access_token=tok', + }, + }); + auth.loading = false; + auth.user = null; + + await act(async () => { + render( + + + + ); + }); + + await act(async () => { + vi.advanceTimersByTime(1000); + }); + expect(screen.getByText(/session not established/i)).toBeInTheDocument(); + + await act(async () => { + vi.advanceTimersByTime(3000); + }); + expect(mockNavigate).toHaveBeenCalledWith('/login', { replace: true }); + vi.useRealTimers(); + }); + + it('redirects immediately when already signed in without hash or error', () => { + auth.loading = false; + auth.user = { id: 'u1' }; + + render( + + + + ); + + expect(mockNavigate).toHaveBeenCalledWith('/dashboard', { + replace: true, + }); + }); +}); diff --git a/apps/web/src/pages/__tests__/DashboardPage.test.tsx b/apps/web/src/pages/__tests__/DashboardPage.test.tsx index 19f6f333..b2cf414c 100644 --- a/apps/web/src/pages/__tests__/DashboardPage.test.tsx +++ b/apps/web/src/pages/__tests__/DashboardPage.test.tsx @@ -1,59 +1,158 @@ +import type { ReactElement } from 'react'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen, waitFor, act } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { BrowserRouter } from 'react-router-dom'; +import { BillingProvider } from '@beakerstack/billing'; import DashboardPage from '../DashboardPage'; import { AuthProvider } from '@beakerstack/shared/contexts/AuthContext'; import { ProfileProvider } from '@beakerstack/shared/contexts/ProfileContext'; -import type { SupabaseClient } from '@supabase/supabase-js'; import { BRANDING, brandNameRegex } from '@beakerstack/shared/config/branding'; +import { beakerstackBillingConfig } from '@/billing/beakerstackBillingConfig'; +import { supabase } from '@/lib/supabase'; const mockNavigate = vi.fn(); -// Mock the supabase client import -vi.mock('@/lib/supabase', () => { - const mockFrom = vi.fn(() => ({ - select: vi.fn(() => ({ - eq: vi.fn(() => ({ - single: vi.fn().mockResolvedValue({ - data: null, - error: { code: 'PGRST116', message: 'No rows returned' }, - }), - })), - })), - })); - - interface MockChannel { - on: ReturnType; - subscribe: ReturnType; - unsubscribe: ReturnType; - } - - const createMockChannel = (): MockChannel => { - const channel = {} as MockChannel; - - channel.on = vi.fn().mockImplementation(() => channel); - channel.subscribe = vi - .fn() - .mockImplementation((callback: (status: string) => void) => { - callback('SUBSCRIBED'); - return channel; - }); - channel.unsubscribe = vi - .fn() - .mockResolvedValue({ status: 'ok', error: null }); +const FREE_PLAN_ROW = { + id: 'beakerstack_free', + product_id: 'beakerstack', + display_name: 'Free', + description: null, + price_cents: 0, + billing_period: 'free', + stripe_price_id_monthly: null, + stripe_price_id_annual: null, + stripe_product_id: null, + features: { + containers_per_account_max: 2, + items_per_container_max: 3, + feature_a: false, + feature_b: false, + }, + usage_limits: { ai_summarize: 30 }, + trial_period_days: 0, + is_public: true, + display_order: 1, +}; - return channel; - }; +const SUBSCRIPTION_ROW = { + id: 's1', + user_id: 'test-user-id', + product_id: 'beakerstack', + plan_id: 'beakerstack_free', + status: 'active', + stripe_customer_id: null, + stripe_subscription_id: null, + stripe_price_id: null, + current_period_start: null, + current_period_end: null, + cancel_at_period_end: false, + canceled_at: null, + trial_start: null, + trial_end: null, +}; - return { - supabase: { - from: mockFrom, - channel: vi.fn().mockImplementation(createMockChannel), - removeChannel: vi.fn().mockResolvedValue({ status: 'ok', error: null }), +vi.mock('@/lib/supabase', () => ({ + supabase: { + auth: { + getSession: vi.fn().mockResolvedValue({ + data: { + session: { + user: { + id: 'test-user-id', + email: 'test@example.com', + }, + }, + }, + }), + onAuthStateChange: vi.fn().mockReturnValue({ + data: { + subscription: { + unsubscribe: vi.fn(), + }, + }, + }), + signOut: vi.fn().mockResolvedValue({ error: null }), }, - }; -}); + from: vi.fn((table: string) => { + if (table === 'billing_subscriptions') { + return { + select: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ + maybeSingle: vi.fn().mockResolvedValue({ + data: SUBSCRIPTION_ROW, + error: null, + }), + }), + }), + }), + }; + } + if (table === 'billing_plans') { + return { + select: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ + maybeSingle: vi.fn().mockResolvedValue({ + data: FREE_PLAN_ROW, + error: null, + }), + }), + }), + }; + } + return { + select: vi.fn(() => ({ + eq: vi.fn(() => ({ + single: vi.fn().mockResolvedValue({ + data: null, + error: { code: 'PGRST116', message: 'No rows returned' }, + }), + })), + })), + }; + }), + rpc: vi.fn((name: string) => { + if (name === 'ensure_billing_subscription') { + return Promise.resolve({ data: null, error: null }); + } + if (name === 'billing_get_remaining_usage') { + return Promise.resolve({ + data: { + used: 0, + limit: 30, + remaining: 30, + periodEnd: '2025-12-31T00:00:00.000Z', + periodStart: '2025-12-01T00:00:00.000Z', + }, + error: null, + }); + } + if (name === 'billing_record_usage_event') { + return Promise.resolve({ data: null, error: null }); + } + if (name === 'billing_demo_get_collections') { + return Promise.resolve({ data: [], error: null }); + } + if (name?.startsWith('billing_demo_')) { + return Promise.resolve({ data: null, error: null }); + } + return Promise.resolve({ data: null, error: null }); + }), + channel: vi.fn().mockImplementation(() => { + const ch = { + on: vi.fn().mockReturnThis(), + subscribe: vi.fn().mockReturnThis(), + unsubscribe: vi.fn().mockResolvedValue(undefined), + }; + return ch; + }), + removeChannel: vi.fn().mockResolvedValue({ status: 'ok', error: null }), + functions: { + invoke: vi.fn().mockResolvedValue({ data: null, error: null }), + }, + }, +})); vi.mock('react-router-dom', async () => { const actual = await vi.importActual('react-router-dom'); @@ -63,79 +162,37 @@ vi.mock('react-router-dom', async () => { }; }); -describe('DashboardPage', () => { - let mockSupabaseClient: SupabaseClient; +const billingBase = 'http://localhost:5173'; + +function wrapDashboard(ui: ReactElement) { + return ( + + + + + supabase={supabase} + config={beakerstackBillingConfig} + checkoutSuccessUrl={`${billingBase}/billing?checkout=success`} + checkoutCancelUrl={`${billingBase}/billing/plans?checkout=cancel`} + portalReturnUrl={`${billingBase}/billing`} + > + {ui} + + + + + ); +} +describe('DashboardPage', () => { beforeEach(() => { vi.clearAllMocks(); - - // Mock database query for useProfile hook (returns profile not found, which is valid) - const mockFrom = vi.fn(() => ({ - select: vi.fn(() => ({ - eq: vi.fn(() => ({ - single: vi.fn().mockResolvedValue({ - data: null, - error: { code: 'PGRST116', message: 'No rows returned' }, // Profile not found is OK - }), - })), - })), - })); - - mockSupabaseClient = { - auth: { - getSession: vi.fn().mockResolvedValue({ - data: { - session: { - user: { - id: 'test-user-id', - email: 'test@example.com', - }, - }, - }, - }), - onAuthStateChange: vi.fn().mockReturnValue({ - data: { - subscription: { - unsubscribe: vi.fn(), - }, - }, - }), - signOut: vi.fn().mockResolvedValue({ error: null }), - }, - from: mockFrom, - } as unknown as SupabaseClient; }); const renderWithAuth = async (ui: React.ReactElement) => { - // Add channel and removeChannel methods to mock - const clientWithRealtime = - mockSupabaseClient as typeof mockSupabaseClient & { - channel: ReturnType; - removeChannel: ReturnType; - }; - clientWithRealtime.channel = vi.fn().mockReturnValue({ - on: vi.fn().mockReturnThis(), - subscribe: vi.fn(callback => { - callback('SUBSCRIBED'); - return { on: vi.fn().mockReturnThis(), subscribe: vi.fn() }; - }), - }); - clientWithRealtime.removeChannel = vi - .fn() - .mockResolvedValue({ status: 'ok', error: null }); - let result: ReturnType | null = null; await act(async () => { - result = render( - - - - {ui} - - - - ); - // Wait for the getSession promise to resolve + result = render(wrapDashboard(ui)); await new Promise(resolve => setTimeout(resolve, 0)); }); if (!result) { @@ -147,17 +204,23 @@ describe('DashboardPage', () => { it('renders dashboard page', async () => { await renderWithAuth(); - // Dashboard title should be visible - expect(screen.getByText('Welcome to your dashboard!')).toBeInTheDocument(); - // Header should be visible + expect( + screen.getByRole('heading', { name: /welcome to beakerstack/i }) + ).toBeInTheDocument(); expect(screen.getByText(BRANDING.displayName)).toBeInTheDocument(); }); + it('links to /billing for polished billing', async () => { + await renderWithAuth(); + const link = screen.getByRole('link', { + name: /view polished billing pages/i, + }); + expect(link).toHaveAttribute('href', '/billing'); + }); + it('displays user email when authenticated', async () => { await renderWithAuth(); - // Email is in the header's UserMenu dropdown, not directly visible - // Check that the header is rendered instead await waitFor(() => { expect(screen.getByText(BRANDING.displayName)).toBeInTheDocument(); }); @@ -166,15 +229,12 @@ describe('DashboardPage', () => { it('shows sign out button', async () => { await renderWithAuth(); - // Sign out is in the UserMenu dropdown, need to click avatar to see it - // For now, just verify the header is rendered expect(screen.getByText(BRANDING.displayName)).toBeInTheDocument(); }); it('shows home link', async () => { await renderWithAuth(); - // Home link is the brand text/icon in the header const homeLinks = screen.getAllByRole('link', { name: brandNameRegex() }); expect(homeLinks.length).toBeGreaterThan(0); }); @@ -183,7 +243,6 @@ describe('DashboardPage', () => { const user = userEvent.setup(); await renderWithAuth(); - // Click on the avatar to open the menu const avatar = screen.getByRole('button', { name: /user menu/i }) || screen.getByRole('img', { name: /avatar/i }) || @@ -192,31 +251,25 @@ describe('DashboardPage', () => { if (avatar) { await user.click(avatar); - // Then click sign out await waitFor(async () => { const signOutButton = screen.getByRole('button', { name: /sign out/i }); await user.click(signOutButton); }); await waitFor(() => { - expect(mockSupabaseClient.auth.signOut).toHaveBeenCalled(); + expect(supabase.auth.signOut).toHaveBeenCalled(); }); } else { - // If we can't find the avatar, just verify the page renders expect(screen.getByText(BRANDING.displayName)).toBeInTheDocument(); } }); it('shows loading state while signing out', async () => { - // This test is no longer applicable since sign out is in the dropdown - // Just verify the page renders await renderWithAuth(); expect(screen.getByText(BRANDING.displayName)).toBeInTheDocument(); }); it('navigates even if sign out API call fails (graceful degradation)', async () => { - // This test is no longer applicable since sign out is in the dropdown - // Just verify the page renders await renderWithAuth(); expect(screen.getByText(BRANDING.displayName)).toBeInTheDocument(); }); diff --git a/apps/web/src/pages/__tests__/ProfilePage.coverage.test.tsx b/apps/web/src/pages/__tests__/ProfilePage.coverage.test.tsx new file mode 100644 index 00000000..1af81b09 --- /dev/null +++ b/apps/web/src/pages/__tests__/ProfilePage.coverage.test.tsx @@ -0,0 +1,162 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { BrowserRouter } from 'react-router-dom'; +import ProfilePage from '../ProfilePage'; +import type { ProfileHeaderProps } from '@beakerstack/shared/components/profile/ProfileHeader.web'; +import type { ProfileStatsProps } from '@beakerstack/shared/components/profile/ProfileStats.web'; +import type { ProfileEditorProps } from '@beakerstack/shared/components/profile/ProfileEditor.web'; +const profileCtx = vi.hoisted(() => ({ + loading: false, + error: null as Error | null, + profile: null as { + display_name: string; + username: string; + id: string; + user_id: string; + bio: string | null; + avatar_url: string | null; + website: string | null; + location: string | null; + created_at: string; + updated_at: string; + } | null, + refreshProfile: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('@beakerstack/shared/contexts/AuthContext', () => ({ + useAuthContext: () => ({ + user: { id: 'u1', email: 'user@example.com' }, + session: null, + loading: false, + error: null, + signIn: vi.fn(), + signUp: vi.fn(), + signOut: vi.fn(), + signInWithGoogle: vi.fn(), + }), +})); + +vi.mock('@beakerstack/shared/contexts/ProfileContext', () => ({ + useProfileContext: () => ({ + loading: profileCtx.loading, + error: profileCtx.error, + profile: profileCtx.profile, + refreshProfile: profileCtx.refreshProfile, + supabaseClient: {}, + currentUser: null, + fetchProfile: vi.fn(), + createProfile: vi.fn(), + updateProfile: vi.fn(), + }), +})); + +vi.mock('@/lib/supabase', () => ({ + supabase: { auth: { signOut: vi.fn() } }, +})); + +vi.mock('@beakerstack/shared/components/navigation/AppHeader.web', () => ({ + AppHeader: () =>
Header
, +})); + +vi.mock('@beakerstack/shared/components/profile/ProfileHeader.web', () => ({ + ProfileHeader: ({ profile }: ProfileHeaderProps) => ( +
+ {profile ? profile.display_name : 'none'} +
+ ), +})); + +vi.mock('@beakerstack/shared/components/profile/ProfileStats.web', () => ({ + ProfileStats: ({ profile }: ProfileStatsProps) => + profile ?
stats
: null, +})); + +vi.mock('@beakerstack/shared/components/profile/ProfileEditor.web', () => ({ + ProfileEditor: ({ onSuccess, onError }: ProfileEditorProps) => ( +
+ + +
+ ), +})); + +const loggerError = vi.fn(); +vi.mock('@beakerstack/shared/utils/logger', () => ({ + Logger: { error: (...a: unknown[]) => loggerError(...a) }, +})); + +describe('ProfilePage (context-driven UI)', () => { + beforeEach(() => { + profileCtx.loading = false; + profileCtx.error = null; + profileCtx.profile = { + id: 'p1', + user_id: 'u1', + username: 'u', + display_name: 'Visible', + bio: null, + avatar_url: null, + website: null, + location: null, + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + }; + profileCtx.refreshProfile.mockClear(); + loggerError.mockClear(); + }); + + const renderPage = () => + render( + + + + ); + + it('shows loading state', () => { + profileCtx.loading = true; + renderPage(); + expect(screen.getByText(/Loading profile/i)).toBeInTheDocument(); + }); + + it('shows error panel when profile fails to load', () => { + profileCtx.error = new Error('Network down'); + renderPage(); + expect(screen.getByText(/Error loading profile/i)).toBeInTheDocument(); + expect(screen.getByText('Network down')).toBeInTheDocument(); + }); + + it('opens editor and refreshes profile on successful save', async () => { + const user = userEvent.setup(); + renderPage(); + await user.click(screen.getByRole('button', { name: /Edit Profile/i })); + await user.click(screen.getByRole('button', { name: /Save ok/i })); + await waitFor(() => { + expect(profileCtx.refreshProfile).toHaveBeenCalled(); + }); + expect(screen.queryByTestId('profile-editor')).not.toBeInTheDocument(); + }); + + it('logs when editor reports an error', async () => { + const user = userEvent.setup(); + renderPage(); + await user.click(screen.getByRole('button', { name: /Edit Profile/i })); + await user.click(screen.getByRole('button', { name: /Save bad/i })); + await waitFor(() => { + expect(loggerError).toHaveBeenCalledWith( + 'Profile save error:', + expect.any(Error) + ); + }); + }); + + it('renders stats when profile exists', () => { + renderPage(); + expect(screen.getByTestId('profile-stats')).toBeInTheDocument(); + expect(screen.getByTestId('app-header')).toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/pages/billing/BillingInvoicesPage.tsx b/apps/web/src/pages/billing/BillingInvoicesPage.tsx new file mode 100644 index 00000000..f661ef89 --- /dev/null +++ b/apps/web/src/pages/billing/BillingInvoicesPage.tsx @@ -0,0 +1,47 @@ +import { useInvoices } from '@beakerstack/billing'; +import { Link } from 'react-router-dom'; +import { beakerstackBillingConfig } from '../../billing/beakerstackBillingConfig'; +import { BillingPageShell } from '../../components/billing/BillingPageShell.web'; +import { BillingTabs } from '../../components/billing/BillingTabs.web'; +import { InvoiceTable } from '../../components/billing/InvoiceTable.web'; + +export default function BillingInvoicesPage() { + const { items, loading, hasMore, loadMore, error } = useInvoices< + typeof beakerstackBillingConfig + >({ pageSize: 20 }); + return ( + +

Billing

+
+ +
+

Invoices

+

+ Download invoices and receipts for your records. +

+ {error && ( +

+ {String(error?.message ?? error)} +

+ )} +
+ + {!loading && items.length === 0 && ( +

+ + Explore plans + +

+ )} +
+
+ ); +} diff --git a/apps/web/src/pages/billing/BillingOverviewPage.tsx b/apps/web/src/pages/billing/BillingOverviewPage.tsx new file mode 100644 index 00000000..940726bd --- /dev/null +++ b/apps/web/src/pages/billing/BillingOverviewPage.tsx @@ -0,0 +1,220 @@ +import { + useCustomerPortal, + useInvoices, + usePlan, + usePlanCatalog, + useUsage, + useBillingState, + useBillingStripeActions, + type SubscriptionRow, + type BillingUiStateKind, +} from '@beakerstack/billing'; +import { useAuthContext } from '@beakerstack/shared/contexts/AuthContext'; +import { + beakerstackBillingConfig, + BEAKERSTACK_METER_AI_SUMMARIZE, +} from '../../billing/beakerstackBillingConfig'; +import { formatMonthYear } from '../../billing/formatters'; +import { useDemoCollectionCount } from '../../billing/useDemoCollectionCount'; +import { Banner } from '../../components/billing/Banner.web'; +import { Button } from '@beakerstack/shared/components/primitives/Button.web'; +import { BillingPageShell } from '../../components/billing/BillingPageShell.web'; +import { BillingTabs } from '../../components/billing/BillingTabs.web'; +import { CurrentPlanCard } from '../../components/billing/CurrentPlanCard.web'; +import { InvoiceList } from '../../components/billing/InvoiceList.web'; +import { StatCard } from '../../components/billing/StatCard.web'; + +export default function BillingOverviewPage() { + const { kind, subscription } = + useBillingState(); + const { plans: catalogPlans } = + usePlanCatalog(); + const { + reactivateSubscription, + pending: stripeActionPend, + error: stripeActionErr, + } = useBillingStripeActions(); + const { + openPortal, + pending: portalPend, + error: portalError, + } = useCustomerPortal(); + const { data: currentPlan, loading: planLoad } = + usePlan(); + const { + used, + limit, + loading: usageLoad, + } = useUsage< + typeof beakerstackBillingConfig, + typeof BEAKERSTACK_METER_AI_SUMMARIZE + >(BEAKERSTACK_METER_AI_SUMMARIZE); + const { items: invoices, loading: invLoad } = useInvoices< + typeof beakerstackBillingConfig + >({ pageSize: 3 }); + const { count: colCount, loading: colLoad } = useDemoCollectionCount(); + const { user } = useAuthContext(); + + const isFree = + kind === 'free' || + currentPlan?.price_cents === 0 || + !subscription?.stripe_subscription_id; + + const pendingTargetName = + subscription?.pending_target_plan_id != null + ? catalogPlans.find(p => p.id === subscription.pending_target_plan_id) + ?.display_name + : undefined; + + const periodSubcopy = + kind === 'downgrade_pending' && + subscription?.current_period_end && + pendingTargetName + ? `You'll move to ${pendingTargetName} on ${new Date( + subscription.current_period_end + ).toLocaleDateString()}.` + : null; + + return ( + +

Billing

+
+ +
+
+ {portalError ? ( + + {portalError.message} For hosted deploys, ensure the Edge secret{' '} + + BILLING_ALLOWED_ORIGINS + {' '} + includes this site's origin. + + ) : null} + {stripeActionErr ? ( + + {stripeActionErr.message} + + ) : null} + + void reactivateSubscription().then(() => window.location.reload()) + } + reactivatePending={stripeActionPend} + /> + {planLoad && kind === 'loading' ? ( +
+ ) : ( + void openPortal()} + managePaymentPending={portalPend} + periodSubcopy={periodSubcopy} + /> + )} +
+ + { + const cap = currentPlan?.features + .containers_per_account_max as number; + if (cap === -1) return `${colCount ?? 0} of unlimited`; + return `${colCount ?? 0} of ${cap}`; + })() + } + /> + +
+ {!isFree && !invLoad && invoices.length > 0 && ( + + )} +
+ + ); +} + +function OverviewBanners({ + kind, + subscription, + pendingTargetName, + onReactivate, + reactivatePending, +}: { + kind: BillingUiStateKind; + subscription: SubscriptionRow | null; + pendingTargetName?: string; + onReactivate: () => void; + reactivatePending: boolean; +}): JSX.Element | null { + const reactivateAction = ( + + ); + + if (kind === 'loading' || kind === 'no_subscription') return null; + if (kind === 'payment_failed') { + return ( + + Your last payment did not go through. Use "Manage payment & + invoices" to update your card in the Stripe customer portal. + + ); + } + if (kind === 'downgrade_pending' && subscription?.current_period_end) { + const target = pendingTargetName ?? 'your next plan'; + return ( + + You'll be moved to {target} on{' '} + {new Date(subscription.current_period_end).toLocaleDateString()}. You + can reactivate your current subscription before then if you change your + mind. + + ); + } + if (kind === 'cancelled_pending' && subscription?.current_period_end) { + return ( + + Your subscription is cancelled and ends on{' '} + {new Date(subscription.current_period_end).toLocaleDateString()}. + Reactivate to keep your current plan, or manage billing in the customer + portal. + + ); + } + return null; +} diff --git a/apps/web/src/pages/billing/BillingPlansPage.tsx b/apps/web/src/pages/billing/BillingPlansPage.tsx new file mode 100644 index 00000000..68a2e79f --- /dev/null +++ b/apps/web/src/pages/billing/BillingPlansPage.tsx @@ -0,0 +1,327 @@ +import type { Plan } from '@beakerstack/billing'; +import { + resolveCadence, + useBillingState, + useBillingStripeActions, + useCheckout, + usePlan, + usePlanCatalog, + useSubscription, + useUsage, +} from '@beakerstack/billing'; +import { useCallback, useMemo, useState } from 'react'; +import { useSearchParams } from 'react-router-dom'; +import { + beakerstackBillingConfig, + BEAKERSTACK_METER_AI_SUMMARIZE, +} from '../../billing/beakerstackBillingConfig'; +import { + annualListCentsFromSync, + formatSavingsCalloutFromCopy, + planAnnualSavingsCopy, +} from '../../billing/billingSyncDisplay'; +import { + computeDowngradeBlockers, + type DowngradeBlockersResult, +} from '../../billing/constraintBlockers'; +import { useDemoCollectionCount } from '../../billing/useDemoCollectionCount'; +import { + CadenceToggle, + getCadenceFromSearch, +} from '../../components/billing/CadenceToggle.web'; +import { BillingPageShell } from '../../components/billing/BillingPageShell.web'; +import { BillingTabs } from '../../components/billing/BillingTabs.web'; +import { ConfirmDowngradeModal } from '../../components/billing/ConfirmDowngradeModal.web'; +import { PlanCard } from '../../components/billing/PlanCard.web'; + +type Primary = { + label: string; + onClick: () => void; + disabled: boolean; + loading: boolean; + variant?: 'primary' | 'secondary'; +}; + +export default function BillingPlansPage() { + const [search] = useSearchParams(); + const cadence = getCadenceFromSearch(search); + const { plans, loading: catLoad } = + usePlanCatalog(); + const { data: current } = usePlan(); + const { data: subscription } = + useSubscription(); + const { kind: billingKind } = + useBillingState(); + const { startCheckout, pending: checkoutPend } = + useCheckout(); + const { + updateSubscription, + scheduleCancelToFree, + pending: actionPend, + } = useBillingStripeActions(); + const { used: aiUsed } = useUsage< + typeof beakerstackBillingConfig, + typeof BEAKERSTACK_METER_AI_SUMMARIZE + >(BEAKERSTACK_METER_AI_SUMMARIZE); + const { count: colCount = 0, maxItemsInAnyCollection = 0 } = + useDemoCollectionCount(); + + const [modal, setModal] = useState(false); + const pending = checkoutPend || actionPend; + + const currentCadence = useMemo( + () => + current && subscription ? resolveCadence(current, subscription) : null, + [current, subscription] + ); + const hasPaidStripe = Boolean(subscription?.stripe_subscription_id); + + const emptyBlockers = (): DowngradeBlockersResult => ({ + hard: [], + soft: [], + }); + + const blockers = useCallback( + (target: Plan) => { + if (!current) return emptyBlockers(); + if (target.id === current.id) return emptyBlockers(); + if ( + target.id === 'beakerstack_free' && + (current.id !== 'beakerstack_free' || hasPaidStripe) + ) { + return computeDowngradeBlockers( + current, + target, + { + collectionCount: colCount ?? 0, + maxItemsInAnyCollection: maxItemsInAnyCollection ?? 0, + aiUsedThisPeriod: aiUsed ?? 0, + }, + plans, + beakerstackBillingConfig + ); + } + if ( + (target.display_order ?? 0) < (current.display_order ?? 0) && + target.id !== 'beakerstack_free' + ) { + return computeDowngradeBlockers( + current, + target, + { + collectionCount: colCount ?? 0, + maxItemsInAnyCollection: maxItemsInAnyCollection ?? 0, + aiUsedThisPeriod: aiUsed ?? 0, + }, + plans, + beakerstackBillingConfig + ); + } + return emptyBlockers(); + }, + [current, colCount, maxItemsInAnyCollection, aiUsed, hasPaidStripe, plans] + ); + + const getPrimary = useCallback( + (p: Plan): Primary => { + if (!current) { + return { + label: '…', + onClick: () => {}, + disabled: true, + loading: false, + }; + } + const b = blockers(p); + const hasHardBlock = b.hard.length > 0; + if (p.id === current.id) { + if (p.price_cents === 0 || p.id === 'beakerstack_free') { + return { + label: 'Current plan', + onClick: () => {}, + disabled: true, + loading: false, + }; + } + if (currentCadence === cadence) { + return { + label: 'Current plan', + onClick: () => {}, + disabled: true, + loading: false, + }; + } + return { + label: + cadence === 'annual' ? 'Switch to annual' : 'Switch to monthly', + onClick: async () => { + const r = await updateSubscription(p.id, cadence); + if (r) window.location.reload(); + }, + disabled: false, + loading: pending, + }; + } + if (!hasPaidStripe && p.price_cents > 0) { + const trialDays = p.trial_period_days ?? 0; + const label = + trialDays > 0 + ? `Start ${trialDays}-day free trial` + : `Upgrade to ${p.display_name}`; + return { + label, + onClick: async () => { + const r = await startCheckout(p.id, cadence); + if (r?.checkoutUrl) window.location.href = r.checkoutUrl; + }, + disabled: false, + loading: pending, + }; + } + if (p.id === 'beakerstack_free' && hasPaidStripe) { + return { + label: 'Downgrade to Free', + onClick: () => { + if (hasHardBlock) return; + setModal(true); + }, + disabled: hasHardBlock, + loading: false, + variant: 'secondary', + }; + } + if (hasPaidStripe && p.price_cents > 0) { + if ((p.display_order ?? 0) > (current.display_order ?? 0)) { + return { + label: `Upgrade to ${p.display_name}`, + onClick: () => + void updateSubscription(p.id, cadence).then(() => + window.location.reload() + ), + disabled: false, + loading: pending, + }; + } + if ((p.display_order ?? 0) < (current.display_order ?? 0)) { + return { + label: `Downgrade to ${p.display_name}`, + onClick: () => + void updateSubscription(p.id, cadence).then(() => + window.location.reload() + ), + disabled: hasHardBlock, + loading: pending, + variant: 'secondary', + }; + } + } + return { + label: 'Current plan', + onClick: () => {}, + disabled: true, + loading: false, + }; + }, + [ + current, + blockers, + currentCadence, + cadence, + hasPaidStripe, + startCheckout, + updateSubscription, + pending, + ] + ); + + return ( + +

Billing

+
+ +
+

+ Choose a plan +

+

+ Switch plans or update your billing cadence anytime. +

+
+ +
+ {catLoad ? ( +

Loading plans…

+ ) : ( +
+ {plans.map(p => { + const displayCents = + p.price_cents === 0 + ? 0 + : cadence === 'annual' + ? annualListCentsFromSync(p.id, p.price_cents) + : p.price_cents; + const head = + p.price_cents === 0 + ? 'US$0' + : new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + maximumFractionDigits: 0, + }).format(displayCents / 100); + const sub = + p.price_cents === 0 + ? 'Free forever' + : cadence === 'monthly' + ? 'per month' + : 'per year, billed annually'; + const pr = getPrimary(p); + const savingsCallout = + cadence === 'annual' && p.price_cents > 0 + ? formatSavingsCalloutFromCopy( + planAnnualSavingsCopy(p.id, p.price_cents) + ) + : null; + return ( + + ); + })} +
+ )} +

+ All plans billed in USD. Taxes calculated at checkout where applicable. + Cancel anytime. Plans with a trial convert to paid at trial end unless + you cancel before then (manage in Stripe customer portal). +

+ setModal(false)} + onConfirm={async () => { + const ok = await scheduleCancelToFree(); + if (ok) { + setModal(false); + window.location.reload(); + } + }} + planName='Free' + bodyText="Your paid subscription is scheduled to cancel. You'll be on the Free plan when the current period ends." + pending={actionPend} + /> +
+ ); +} diff --git a/apps/web/src/pages/billing/BillingUsagePage.tsx b/apps/web/src/pages/billing/BillingUsagePage.tsx new file mode 100644 index 00000000..032f9d36 --- /dev/null +++ b/apps/web/src/pages/billing/BillingUsagePage.tsx @@ -0,0 +1,116 @@ +import { + usePlan, + useBillingState, + useBillingConfig, +} from '@beakerstack/billing'; +import { UsageIndicator } from '@beakerstack/billing/web'; +import { + beakerstackBillingConfig, + BEAKERSTACK_METER_AI_SUMMARIZE, +} from '../../billing/beakerstackBillingConfig'; +import { + booleanFeatureLabel, + mergeUsageLimitsCopy, + mergeUsageMeterCopy, +} from '../../billing/planPresentation'; +import { useDemoCollectionCount } from '../../billing/useDemoCollectionCount'; +import { Banner } from '../../components/billing/Banner.web'; +import { BillingPageShell } from '../../components/billing/BillingPageShell.web'; +import { BillingTabs } from '../../components/billing/BillingTabs.web'; +import { FeatureLimitRow } from '../../components/billing/FeatureLimitRow.web'; +import { PlanFeatureRow } from '../../components/billing/PlanFeatureRow.web'; + +export default function BillingUsagePage() { + const billingConfig = useBillingConfig(); + const meterCopy = mergeUsageMeterCopy(billingConfig); + const limitsCopy = mergeUsageLimitsCopy(billingConfig); + const featureALabel = booleanFeatureLabel(billingConfig, 'feature_a'); + const featureBLabel = booleanFeatureLabel(billingConfig, 'feature_b'); + const { data: plan } = usePlan(); + const { kind, subscription } = + useBillingState(); + const { count: colCount = 0, maxItemsInAnyCollection = 0 } = + useDemoCollectionCount(); + if (!plan) { + return ( + +

Billing

+
+ +
+

Loading plan…

+
+ ); + } + const containers = plan.features.containers_per_account_max as number; + const itemsCap = plan.features.items_per_container_max as number; + return ( + +

Billing

+
+ +
+
+ {kind === 'payment_failed' && ( + + Payment failed. Limits may change if your plan lapses. + + )} +
+ {subscription?.status === 'free' || + !subscription?.stripe_subscription_id + ? `Your usage resets at the start of the next calendar month (free tier).` + : 'Your usage resets on your next billing date (see Usage below for the exact reset date for meters).'} +
+
+

Usage

+ {Object.keys(plan.usage_limits).map(m => ( +
+ + meter={m as typeof BEAKERSTACK_METER_AI_SUMMARIZE} + variant='expanded' + label={meterCopy[m]?.label ?? m} + description={meterCopy[m]?.description} + /> +
+ ))} +
+
+

Limits

+
+ + +

+ {limitsCopy.collectionsFootnote} +

+
+
+
+

Plan features

+
+ + +
+
+
+
+ ); +} diff --git a/apps/web/src/pages/billing/__tests__/BillingInvoicesPage.test.tsx b/apps/web/src/pages/billing/__tests__/BillingInvoicesPage.test.tsx new file mode 100644 index 00000000..57caf3cf --- /dev/null +++ b/apps/web/src/pages/billing/__tests__/BillingInvoicesPage.test.tsx @@ -0,0 +1,41 @@ +import { describe, expect, it, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import BillingInvoicesPage from '../BillingInvoicesPage'; + +const loadMore = vi.fn(); + +vi.mock('@beakerstack/billing', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + useInvoices: () => ({ + items: [], + loading: false, + hasMore: false, + loadMore, + error: null, + refresh: vi.fn(), + }), + }; +}); + +vi.mock('@/components/billing/BillingPageShell.web', () => ({ + BillingPageShell: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})); + +describe('BillingInvoicesPage', () => { + it('renders invoices heading and empty-state hint', () => { + render( + + + + ); + expect( + screen.getByRole('heading', { name: 'Invoices' }) + ).toBeInTheDocument(); + expect(screen.getByText(/Explore plans/i)).toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/pages/billing/__tests__/BillingOverviewPage.test.tsx b/apps/web/src/pages/billing/__tests__/BillingOverviewPage.test.tsx new file mode 100644 index 00000000..273c670f --- /dev/null +++ b/apps/web/src/pages/billing/__tests__/BillingOverviewPage.test.tsx @@ -0,0 +1,343 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import { fireEvent, render, screen, within } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import type { BillingUiStateKind, Plan } from '@beakerstack/billing'; +import BillingOverviewPage from '../BillingOverviewPage'; + +const state = vi.hoisted(() => { + const plan: Plan = { + id: 'beakerstack_pro', + product_id: 'beakerstack', + display_name: 'Pro', + description: null, + price_cents: 1900, + billing_period: 'monthly', + stripe_price_id_monthly: null, + stripe_price_id_annual: null, + stripe_product_id: null, + features: { + feature_a: true, + feature_b: false, + containers_per_account_max: -1, + items_per_container_max: 25, + }, + usage_limits: { ai_summarize: 500 }, + trial_period_days: 0, + is_public: true, + display_order: 2, + }; + return { + plan, + kind: 'paid_active' as BillingUiStateKind, + subscription: { + id: 's1', + user_id: 'u1', + product_id: 'beakerstack', + plan_id: 'beakerstack_pro', + stripe_customer_id: 'cus', + stripe_subscription_id: 'sub_x', + stripe_price_id: 'price', + status: 'active', + current_period_start: null, + current_period_end: null, + cancel_at_period_end: false, + pending_target_plan_id: null, + canceled_at: null, + trial_start: null, + trial_end: null, + }, + }; +}); + +const { reactivateSpy } = vi.hoisted(() => ({ + reactivateSpy: vi.fn().mockResolvedValue(true), +})); + +const ov = vi.hoisted(() => ({ + portalError: null as Error | null, + stripeActionErr: null as Error | null, + planLoading: false, + usageLimit: 500 as number | null, + usageLoading: false, + invoiceItems: [] as Array<{ + id: string; + created_at: string; + status: string; + amount_paid: number; + currency: string; + description?: string | null; + }>, + invoiceLoading: false, + authUser: { created_at: '2024-01-15T00:00:00.000Z' } as { + created_at?: string; + } | null, +})); + +vi.mock('@beakerstack/billing', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + useBillingState: () => ({ + kind: state.kind, + subscription: state.subscription, + }), + usePlanCatalog: () => ({ + plans: [ + { + id: 'beakerstack_free', + product_id: 'beakerstack', + display_name: 'Free', + description: null, + price_cents: 0, + billing_period: 'free', + stripe_price_id_monthly: null, + stripe_price_id_annual: null, + stripe_product_id: null, + features: {}, + usage_limits: {}, + trial_period_days: 0, + is_public: true, + display_order: 1, + }, + state.plan, + ], + loading: false, + error: null, + refresh: vi.fn(), + }), + useBillingStripeActions: () => ({ + reactivateSubscription: reactivateSpy, + cancelSubscriptionImmediately: vi.fn().mockResolvedValue(true), + updateSubscription: vi.fn(), + scheduleCancelToFree: vi.fn(), + pending: false, + error: ov.stripeActionErr, + }), + useCustomerPortal: () => ({ + openPortal: vi.fn(), + pending: false, + error: ov.portalError, + }), + usePlan: () => ({ + data: state.plan, + loading: ov.planLoading, + error: null, + }), + useUsage: () => ({ + used: 3, + limit: ov.usageLimit, + remaining: ov.usageLimit != null ? Math.max(0, ov.usageLimit - 3) : null, + resetsAt: '', + loading: ov.usageLoading, + error: null, + exceeded: false, + refresh: vi.fn(), + }), + useInvoices: () => ({ + items: ov.invoiceItems, + loading: ov.invoiceLoading, + hasMore: false, + loadMore: vi.fn(), + error: null, + refresh: vi.fn(), + }), + }; +}); + +vi.mock('@beakerstack/shared/contexts/AuthContext', () => ({ + useAuthContext: () => ({ + user: ov.authUser, + }), +})); + +vi.mock('@/billing/useDemoCollectionCount', () => ({ + useDemoCollectionCount: () => ({ count: 2, loading: false }), +})); + +vi.mock('@/components/billing/BillingPageShell.web', () => ({ + BillingPageShell: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})); + +describe('BillingOverviewPage', () => { + beforeEach(() => { + reactivateSpy.mockClear(); + vi.stubGlobal('location', { ...window.location, reload: vi.fn() }); + ov.portalError = null; + ov.stripeActionErr = null; + ov.planLoading = false; + ov.usageLimit = 500; + ov.usageLoading = false; + ov.invoiceItems = []; + ov.invoiceLoading = false; + ov.authUser = { created_at: '2024-01-15T00:00:00.000Z' }; + state.kind = 'paid_active'; + Object.assign(state.subscription, { + stripe_subscription_id: 'sub_x', + status: 'active', + cancel_at_period_end: false, + pending_target_plan_id: null, + trial_end: null, + current_period_end: null, + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('shows Billing overview heading and usage stats', () => { + render( + + + + ); + expect( + screen.getByRole('heading', { name: 'Billing' }) + ).toBeInTheDocument(); + expect(screen.getByText(/AI summaries/i)).toBeInTheDocument(); + }); + + it('shows reactivate for cancelled_pending', () => { + state.kind = 'cancelled_pending'; + Object.assign(state.subscription, { + cancel_at_period_end: true, + current_period_end: '2026-12-31T00:00:00.000Z', + }); + render( + + + + ); + const btn = screen.getByRole('button', { + name: /Reactivate subscription/i, + }); + expect(btn).toBeInTheDocument(); + fireEvent.click(btn); + expect(reactivateSpy).toHaveBeenCalled(); + }); + + it('shows free-plan headline when treating plan as free', () => { + state.kind = 'free'; + Object.assign(state.subscription, { + stripe_subscription_id: null, + status: 'free', + }); + render( + + + + ); + expect( + screen.getByRole('heading', { name: /Free plan/i }) + ).toBeInTheDocument(); + }); + + it('shows portal error banner', () => { + ov.portalError = new Error('Portal blocked'); + render( + + + + ); + expect( + screen.getByText(/Could not open billing portal/i) + ).toBeInTheDocument(); + expect(screen.getByText(/Portal blocked/i)).toBeInTheDocument(); + }); + + it('shows Stripe subscription action error banner', () => { + ov.stripeActionErr = new Error('Stripe action failed'); + render( + + + + ); + expect(screen.getByText(/Subscription action failed/i)).toBeInTheDocument(); + expect(screen.getByText(/Stripe action failed/i)).toBeInTheDocument(); + }); + + it('shows payment failed banner', () => { + state.kind = 'payment_failed'; + render( + + + + ); + expect(screen.getByText('Payment problem')).toBeInTheDocument(); + expect( + screen.getByText(/Your last payment did not go through/i) + ).toBeInTheDocument(); + }); + + it('shows downgrade pending banner and period copy on plan card', () => { + state.kind = 'downgrade_pending'; + Object.assign(state.subscription, { + stripe_subscription_id: 'sub_x', + pending_target_plan_id: 'beakerstack_free', + current_period_end: '2026-07-01T00:00:00.000Z', + cancel_at_period_end: true, + }); + render( + + + + ); + expect(screen.getByText(/Plan change scheduled/i)).toBeInTheDocument(); + expect(screen.getByText(/You'll move to Free on/i)).toBeInTheDocument(); + }); + + it('shows loading skeleton when plan is loading in loading billing state', () => { + state.kind = 'loading'; + ov.planLoading = true; + const { container } = render( + + + + ); + expect(container.querySelector('.animate-pulse')).toBeTruthy(); + }); + + it('shows unlimited usage copy when meter has no limit', () => { + ov.usageLimit = null; + render( + + + + ); + expect(screen.getByText('3 used (unlimited)')).toBeInTheDocument(); + }); + + it('shows recent invoices for paid users when invoice list loads', () => { + ov.invoiceItems = [ + { + id: 'inv_1', + created_at: '2026-01-10T00:00:00.000Z', + status: 'paid', + amount_paid: 1900, + currency: 'usd', + description: 'Pro', + }, + ]; + render( + + + + ); + expect(screen.getByText(/Recent activity/i)).toBeInTheDocument(); + }); + + it('shows em dash for member since when user has no created_at', () => { + ov.authUser = { created_at: undefined }; + render( + + + + ); + const label = screen.getByText('Member since'); + const card = label.closest('.rounded-xl'); + expect(card).toBeTruthy(); + expect(within(card as HTMLElement).getByText('—')).toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/pages/billing/__tests__/BillingPlansPage.test.tsx b/apps/web/src/pages/billing/__tests__/BillingPlansPage.test.tsx new file mode 100644 index 00000000..03df215a --- /dev/null +++ b/apps/web/src/pages/billing/__tests__/BillingPlansPage.test.tsx @@ -0,0 +1,416 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter } from 'react-router-dom'; +import type { + BillingUiStateKind, + Plan, + SubscriptionRow, +} from '@beakerstack/billing'; +import { beakerstackBillingConfig } from '@/billing/beakerstackBillingConfig'; +import BillingPlansPage from '../BillingPlansPage'; + +const plansState = vi.hoisted(() => { + const freePlan: Plan = { + id: 'beakerstack_free', + product_id: 'beakerstack', + display_name: 'Free', + description: null, + price_cents: 0, + billing_period: 'free', + stripe_price_id_monthly: null, + stripe_price_id_annual: null, + stripe_product_id: null, + features: {}, + usage_limits: {}, + trial_period_days: 0, + is_public: true, + display_order: 1, + }; + const proPlan: Plan = { + id: 'beakerstack_pro', + product_id: 'beakerstack', + display_name: 'Pro', + description: null, + price_cents: 1900, + billing_period: 'monthly', + stripe_price_id_monthly: null, + stripe_price_id_annual: null, + stripe_product_id: null, + features: { + feature_a: true, + feature_b: false, + containers_per_account_max: -1, + items_per_container_max: 25, + }, + usage_limits: { ai_summarize: 500 }, + trial_period_days: 0, + is_public: true, + display_order: 2, + }; + const maxPlan: Plan = { + id: 'beakerstack_max', + product_id: 'beakerstack', + display_name: 'Max', + description: null, + price_cents: 4900, + billing_period: 'monthly', + stripe_price_id_monthly: null, + stripe_price_id_annual: null, + stripe_product_id: null, + features: { + feature_a: true, + feature_b: true, + containers_per_account_max: -1, + items_per_container_max: -1, + }, + usage_limits: { ai_summarize: -1 }, + trial_period_days: 5, + is_public: true, + display_order: 3, + }; + return { + freePlan, + proPlan, + maxPlan, + current: proPlan, + catLoading: false, + currentNull: false, + billingKind: 'paid_active' as BillingUiStateKind, + subscription: { + id: 's1', + user_id: 'u1', + product_id: 'beakerstack', + plan_id: 'beakerstack_pro', + stripe_customer_id: 'cus', + stripe_subscription_id: 'sub_1', + stripe_price_id: 'price', + status: 'active', + current_period_start: null, + current_period_end: null, + cancel_at_period_end: false, + pending_target_plan_id: null, + canceled_at: null, + trial_start: null, + trial_end: null, + } as SubscriptionRow | null, + }; +}); + +const checkoutSpies = vi.hoisted(() => ({ + startCheckout: vi.fn().mockResolvedValue(null), + updateSubscription: vi.fn().mockResolvedValue(true), + scheduleCancelToFree: vi.fn().mockResolvedValue(true), +})); + +vi.mock('@beakerstack/billing', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + useBillingConfig: () => beakerstackBillingConfig, + usePlanCatalog: () => ({ + plans: [plansState.freePlan, plansState.proPlan, plansState.maxPlan], + loading: plansState.catLoading, + error: null, + refresh: vi.fn(), + }), + usePlan: () => ({ + data: plansState.currentNull ? null : plansState.current, + loading: false, + error: null, + }), + useSubscription: () => ({ + data: plansState.subscription, + loading: false, + error: null, + refresh: vi.fn(), + }), + useCheckout: () => ({ + startCheckout: checkoutSpies.startCheckout, + pending: false, + error: null, + }), + useBillingState: () => ({ + kind: plansState.billingKind, + plan: plansState.current, + subscription: plansState.subscription, + }), + useBillingStripeActions: () => ({ + updateSubscription: checkoutSpies.updateSubscription, + scheduleCancelToFree: checkoutSpies.scheduleCancelToFree, + reactivateSubscription: vi.fn().mockResolvedValue(true), + cancelSubscriptionImmediately: vi.fn().mockResolvedValue(true), + pending: false, + error: null, + }), + useUsage: () => ({ + used: 0, + limit: 10, + remaining: 10, + resetsAt: '', + loading: false, + error: null, + exceeded: false, + refresh: vi.fn(), + }), + }; +}); + +vi.mock('@/billing/useDemoCollectionCount', () => ({ + useDemoCollectionCount: () => ({ count: 0, loading: false }), +})); + +vi.mock('@/components/billing/BillingPageShell.web', () => ({ + BillingPageShell: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})); + +describe('BillingPlansPage', () => { + const renderPage = (initialEntry = '/billing/plans') => + render( + + + + ); + + beforeEach(() => { + plansState.catLoading = false; + plansState.currentNull = false; + checkoutSpies.startCheckout.mockReset(); + checkoutSpies.updateSubscription.mockReset(); + checkoutSpies.scheduleCancelToFree.mockReset(); + checkoutSpies.startCheckout.mockResolvedValue(null); + checkoutSpies.updateSubscription.mockResolvedValue(true); + checkoutSpies.scheduleCancelToFree.mockResolvedValue(true); + plansState.billingKind = 'paid_active'; + plansState.current = plansState.proPlan; + plansState.subscription = { + id: 's1', + user_id: 'u1', + product_id: 'beakerstack', + plan_id: 'beakerstack_pro', + stripe_customer_id: 'cus', + stripe_subscription_id: 'sub_1', + stripe_price_id: 'price', + status: 'active', + current_period_start: null, + current_period_end: null, + cancel_at_period_end: false, + pending_target_plan_id: null, + canceled_at: null, + trial_start: null, + trial_end: null, + }; + vi.stubGlobal('location', { ...window.location, reload: vi.fn() }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('renders plan cards when catalog is ready', () => { + renderPage(); + expect( + screen.getByRole('heading', { name: 'Choose a plan' }) + ).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Pro' })).toBeInTheDocument(); + }); + + it('shows Free as current plan when subscription is null', () => { + plansState.current = plansState.freePlan; + plansState.subscription = null; + + renderPage(); + + expect( + screen.getByRole('button', { name: 'Current plan' }) + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'Start 5-day free trial' }) + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'Upgrade to Pro' }) + ).toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: /Switch to/i }) + ).not.toBeInTheDocument(); + }); + + it('shows Free as current plan on annual toggle without Stripe subscription', () => { + plansState.current = plansState.freePlan; + plansState.subscription = null; + + renderPage('/billing/plans?cadence=annual'); + + expect( + screen.getByRole('button', { name: 'Current plan' }) + ).toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: /Switch to annual/i }) + ).not.toBeInTheDocument(); + }); + + it('shows Free as current plan when subscription exists but has no Stripe price', () => { + plansState.current = plansState.freePlan; + plansState.subscription = { + id: 's_free', + user_id: 'u1', + product_id: 'beakerstack', + plan_id: 'beakerstack_free', + stripe_customer_id: null, + stripe_subscription_id: null, + stripe_price_id: null, + status: 'free', + current_period_start: null, + current_period_end: null, + cancel_at_period_end: false, + pending_target_plan_id: null, + canceled_at: null, + trial_start: null, + trial_end: null, + }; + + renderPage('/billing/plans?cadence=annual'); + + expect( + screen.getByRole('button', { name: 'Current plan' }) + ).toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: /Switch to/i }) + ).not.toBeInTheDocument(); + }); + + it('keeps cadence switch CTA for paid current plan when cadence differs', () => { + plansState.current = { + ...plansState.proPlan, + stripe_price_id_monthly: 'price_pro_monthly', + stripe_price_id_annual: 'price_pro_annual', + }; + plansState.subscription = { + id: 's_paid', + user_id: 'u1', + product_id: 'beakerstack', + plan_id: 'beakerstack_pro', + stripe_customer_id: 'cus_paid', + stripe_subscription_id: 'sub_paid', + stripe_price_id: 'price_pro_monthly', + status: 'active', + current_period_start: null, + current_period_end: null, + cancel_at_period_end: false, + pending_target_plan_id: null, + canceled_at: null, + trial_start: null, + trial_end: null, + }; + + renderPage('/billing/plans?cadence=annual'); + + expect( + screen.getByRole('button', { name: 'Switch to annual' }) + ).toBeInTheDocument(); + }); + + it('shows Scheduled on target plan when downgrade is pending', () => { + plansState.billingKind = 'downgrade_pending'; + plansState.subscription = { + id: 's1', + user_id: 'u1', + product_id: 'beakerstack', + plan_id: 'beakerstack_pro', + stripe_customer_id: 'cus', + stripe_subscription_id: 'sub_1', + stripe_price_id: 'price', + status: 'active', + current_period_start: null, + current_period_end: null, + cancel_at_period_end: true, + pending_target_plan_id: 'beakerstack_free', + canceled_at: null, + trial_start: null, + trial_end: null, + }; + renderPage(); + expect(screen.getByText('Scheduled')).toBeInTheDocument(); + }); + + it('shows catalog loading placeholder', () => { + plansState.catLoading = true; + renderPage(); + expect(screen.getByText(/Loading plans/i)).toBeInTheDocument(); + }); + + it('assigns checkout URL when upgrading from Free', async () => { + const user = userEvent.setup(); + const hrefSpy = vi + .spyOn(window.location, 'href', 'set') + .mockImplementation(() => {}); + plansState.current = plansState.freePlan; + plansState.subscription = null; + checkoutSpies.startCheckout.mockResolvedValue({ + checkoutUrl: 'https://checkout.example/session', + }); + renderPage(); + await user.click(screen.getByRole('button', { name: /Upgrade to Pro/i })); + await waitFor(() => { + expect(hrefSpy).toHaveBeenCalledWith('https://checkout.example/session'); + }); + hrefSpy.mockRestore(); + }); + + it('calls updateSubscription when upgrading to a higher paid plan', async () => { + const user = userEvent.setup(); + renderPage(); + await user.click(screen.getByRole('button', { name: /Upgrade to Max/i })); + await waitFor(() => { + expect(checkoutSpies.updateSubscription).toHaveBeenCalledWith( + 'beakerstack_max', + 'monthly' + ); + }); + }); + + it('confirms downgrade to Free via modal', async () => { + const user = userEvent.setup(); + const reload = vi.fn(); + vi.stubGlobal('location', { ...window.location, reload }); + plansState.current = plansState.proPlan; + plansState.subscription = { + id: 's1', + user_id: 'u1', + product_id: 'beakerstack', + plan_id: 'beakerstack_pro', + stripe_customer_id: 'cus', + stripe_subscription_id: 'sub_1', + stripe_price_id: 'price', + status: 'active', + current_period_start: null, + current_period_end: null, + cancel_at_period_end: false, + pending_target_plan_id: null, + canceled_at: null, + trial_start: null, + trial_end: null, + }; + renderPage(); + await user.click( + screen.getByRole('button', { name: /Downgrade to Free/i }) + ); + await user.click( + screen.getByRole('button', { name: /Confirm downgrade/i }) + ); + await waitFor(() => { + expect(checkoutSpies.scheduleCancelToFree).toHaveBeenCalled(); + }); + expect(reload).toHaveBeenCalled(); + }); + + it('renders disabled plan CTAs when current plan is not yet loaded', () => { + plansState.currentNull = true; + renderPage(); + const dots = screen.getAllByRole('button', { name: '…' }); + expect(dots.length).toBeGreaterThan(0); + expect(dots[0]).toBeDisabled(); + }); +}); diff --git a/apps/web/src/pages/billing/__tests__/BillingUsagePage.test.tsx b/apps/web/src/pages/billing/__tests__/BillingUsagePage.test.tsx new file mode 100644 index 00000000..1e8b076f --- /dev/null +++ b/apps/web/src/pages/billing/__tests__/BillingUsagePage.test.tsx @@ -0,0 +1,215 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import type { Plan } from '@beakerstack/billing'; +import BillingUsagePage from '../BillingUsagePage'; + +vi.mock('@/components/billing/BillingPageShell.web', () => ({ + BillingPageShell: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})); + +vi.mock('@beakerstack/billing/web', () => ({ + UsageIndicator: (props: { label?: string }) => ( +
{props.label ?? 'meter'}
+ ), +})); + +vi.mock('@/billing/useDemoCollectionCount', () => ({ + useDemoCollectionCount: () => ({ + count: 3, + maxItemsInAnyCollection: 12, + loading: false, + }), +})); + +const mockPlan: Plan = { + id: 'beakerstack_pro', + product_id: 'beakerstack', + display_name: 'Pro', + description: null, + price_cents: 1900, + billing_period: 'monthly', + stripe_price_id_monthly: null, + stripe_price_id_annual: null, + stripe_product_id: null, + features: { + feature_a: true, + feature_b: false, + containers_per_account_max: -1, + items_per_container_max: 25, + }, + usage_limits: { ai_summarize: 500 }, + trial_period_days: 0, + is_public: true, + display_order: 2, +}; + +const billingTestState = vi.hoisted(() => ({ + plan: null as Plan | null, + kind: 'paid_active' as + | 'paid_active' + | 'free' + | 'payment_failed' + | 'trial_ending', + subscription: { + id: 'sub-row', + user_id: 'u1', + product_id: 'beakerstack', + plan_id: 'beakerstack_pro', + stripe_customer_id: 'cus_x', + stripe_subscription_id: 'sub_x', + stripe_price_id: 'price_x', + status: 'active', + current_period_start: null, + current_period_end: null, + cancel_at_period_end: false, + canceled_at: null, + trial_start: null, + trial_end: null, + }, +})); + +const subscriptionBase = { + id: 'sub-row', + user_id: 'u1', + product_id: 'beakerstack', + plan_id: 'beakerstack_pro', + stripe_customer_id: 'cus_x', + stripe_subscription_id: 'sub_x', + stripe_price_id: 'price_x', + status: 'active', + current_period_start: null, + current_period_end: null, + cancel_at_period_end: false, + canceled_at: null, + trial_start: null, + trial_end: null, +}; + +vi.mock('@beakerstack/billing', async importOriginal => { + const actual = await importOriginal(); + const testBillingConfig = actual.defineBillingConfig({ + productId: 'beakerstack', + displayName: 'BeakerStack', + description: 'test', + plans: [ + { + id: 'beakerstack_pro', + displayName: 'Pro', + priceCents: 1900, + billingPeriod: 'monthly', + stripePriceIdMonthly: null, + stripePriceIdAnnual: null, + stripeProductId: null, + features: { + feature_a: true, + feature_b: false, + containers_per_account_max: -1, + items_per_container_max: 25, + }, + usageLimits: { ai_summarize: 500 }, + trialPeriodDays: 0, + isPublic: true, + displayOrder: 2, + }, + ], + usageMeterCopy: { + ai_summarize: { + label: 'AI summarize', + description: 'Test meter description.', + }, + }, + usageLimitsCopy: { + collectionsRowName: 'Collections', + itemsRowName: 'Items per collection (max in one collection)', + collectionsFootnote: 'Test footnote.', + }, + }); + return { + ...actual, + useBillingConfig: () => testBillingConfig, + usePlan: () => ({ data: billingTestState.plan }), + useBillingState: () => ({ + kind: billingTestState.kind, + subscription: billingTestState.subscription, + }), + }; +}); + +describe('BillingUsagePage', () => { + beforeEach(() => { + billingTestState.plan = mockPlan; + billingTestState.kind = 'paid_active'; + Object.assign(billingTestState.subscription, subscriptionBase); + }); + + it('shows loading when plan is missing', () => { + billingTestState.plan = null; + render( + + + + ); + expect(screen.getByText('Loading plan…')).toBeInTheDocument(); + }); + + it('renders usage, limits, and plan features for an active subscription', () => { + render( + + + + ); + expect(screen.getByRole('heading', { name: 'Usage' })).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Limits' })).toBeInTheDocument(); + expect( + screen.getByRole('heading', { name: 'Plan features' }) + ).toBeInTheDocument(); + expect(screen.getAllByTestId('usage-indicator').length).toBeGreaterThan(0); + expect(screen.getByText(/next billing date/i)).toBeInTheDocument(); + }); + + it('shows payment failed banner', () => { + billingTestState.kind = 'payment_failed'; + Object.assign(billingTestState.subscription, subscriptionBase, { + status: 'past_due', + }); + render( + + + + ); + expect(screen.getByText(/Payment failed/i)).toBeInTheDocument(); + }); + + it('does not show trial-ending banner when trial_ending', () => { + const soon = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(); + billingTestState.kind = 'trial_ending'; + Object.assign(billingTestState.subscription, subscriptionBase, { + status: 'trialing', + trial_end: soon, + }); + render( + + + + ); + expect(screen.queryByText(/trial is ending/i)).not.toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Usage' })).toBeInTheDocument(); + }); + + it('shows free-tier reset copy without stripe subscription', () => { + billingTestState.kind = 'free'; + Object.assign(billingTestState.subscription, subscriptionBase, { + status: 'free', + stripe_subscription_id: null, + }); + render( + + + + ); + expect(screen.getByText(/calendar month/i)).toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/types/database.ts b/apps/web/src/types/database.ts deleted file mode 100644 index cd2465fb..00000000 --- a/apps/web/src/types/database.ts +++ /dev/null @@ -1,222 +0,0 @@ -export type Json = - | string - | number - | boolean - | null - | { [key: string]: Json | undefined } - | Json[] - -export type Database = { - // Allows to automatically instantiate createClient with right options - // instead of createClient(URL, KEY) - __InternalSupabase: { - PostgrestVersion: "13.0.5" - } - graphql_public: { - Tables: { - [_ in never]: never - } - Views: { - [_ in never]: never - } - Functions: { - graphql: { - Args: { - extensions?: Json - operationName?: string - query?: string - variables?: Json - } - Returns: Json - } - } - Enums: { - [_ in never]: never - } - CompositeTypes: { - [_ in never]: never - } - } - public: { - Tables: { - user_profiles: { - Row: { - avatar_url: string | null - bio: string | null - created_at: string | null - display_name: string | null - id: string - location: string | null - updated_at: string | null - user_id: string - username: string | null - website: string | null - } - Insert: { - avatar_url?: string | null - bio?: string | null - created_at?: string | null - display_name?: string | null - id?: string - location?: string | null - updated_at?: string | null - user_id: string - username?: string | null - website?: string | null - } - Update: { - avatar_url?: string | null - bio?: string | null - created_at?: string | null - display_name?: string | null - id?: string - location?: string | null - updated_at?: string | null - user_id?: string - username?: string | null - website?: string | null - } - Relationships: [] - } - } - Views: { - [_ in never]: never - } - Functions: { - generate_username: { Args: never; Returns: string } - is_valid_email: { Args: { email: string }; Returns: boolean } - } - Enums: { - [_ in never]: never - } - CompositeTypes: { - [_ in never]: never - } - } -} - -type DatabaseWithoutInternals = Omit - -type DefaultSchema = DatabaseWithoutInternals[Extract] - -export type Tables< - DefaultSchemaTableNameOrOptions extends - | keyof (DefaultSchema["Tables"] & DefaultSchema["Views"]) - | { schema: keyof DatabaseWithoutInternals }, - TableName extends DefaultSchemaTableNameOrOptions extends { - schema: keyof DatabaseWithoutInternals - } - ? keyof (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] & - DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"]) - : never = never, -> = DefaultSchemaTableNameOrOptions extends { - schema: keyof DatabaseWithoutInternals -} - ? (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] & - DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"])[TableName] extends { - Row: infer R - } - ? R - : never - : DefaultSchemaTableNameOrOptions extends keyof (DefaultSchema["Tables"] & - DefaultSchema["Views"]) - ? (DefaultSchema["Tables"] & - DefaultSchema["Views"])[DefaultSchemaTableNameOrOptions] extends { - Row: infer R - } - ? R - : never - : never - -export type TablesInsert< - DefaultSchemaTableNameOrOptions extends - | keyof DefaultSchema["Tables"] - | { schema: keyof DatabaseWithoutInternals }, - TableName extends DefaultSchemaTableNameOrOptions extends { - schema: keyof DatabaseWithoutInternals - } - ? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] - : never = never, -> = DefaultSchemaTableNameOrOptions extends { - schema: keyof DatabaseWithoutInternals -} - ? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends { - Insert: infer I - } - ? I - : never - : DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"] - ? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends { - Insert: infer I - } - ? I - : never - : never - -export type TablesUpdate< - DefaultSchemaTableNameOrOptions extends - | keyof DefaultSchema["Tables"] - | { schema: keyof DatabaseWithoutInternals }, - TableName extends DefaultSchemaTableNameOrOptions extends { - schema: keyof DatabaseWithoutInternals - } - ? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] - : never = never, -> = DefaultSchemaTableNameOrOptions extends { - schema: keyof DatabaseWithoutInternals -} - ? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends { - Update: infer U - } - ? U - : never - : DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"] - ? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends { - Update: infer U - } - ? U - : never - : never - -export type Enums< - DefaultSchemaEnumNameOrOptions extends - | keyof DefaultSchema["Enums"] - | { schema: keyof DatabaseWithoutInternals }, - EnumName extends DefaultSchemaEnumNameOrOptions extends { - schema: keyof DatabaseWithoutInternals - } - ? keyof DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"] - : never = never, -> = DefaultSchemaEnumNameOrOptions extends { - schema: keyof DatabaseWithoutInternals -} - ? DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"][EnumName] - : DefaultSchemaEnumNameOrOptions extends keyof DefaultSchema["Enums"] - ? DefaultSchema["Enums"][DefaultSchemaEnumNameOrOptions] - : never - -export type CompositeTypes< - PublicCompositeTypeNameOrOptions extends - | keyof DefaultSchema["CompositeTypes"] - | { schema: keyof DatabaseWithoutInternals }, - CompositeTypeName extends PublicCompositeTypeNameOrOptions extends { - schema: keyof DatabaseWithoutInternals - } - ? keyof DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"] - : never = never, -> = PublicCompositeTypeNameOrOptions extends { - schema: keyof DatabaseWithoutInternals -} - ? DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"][CompositeTypeName] - : PublicCompositeTypeNameOrOptions extends keyof DefaultSchema["CompositeTypes"] - ? DefaultSchema["CompositeTypes"][PublicCompositeTypeNameOrOptions] - : never - -export const Constants = { - graphql_public: { - Enums: {}, - }, - public: { - Enums: {}, - }, -} as const diff --git a/docs/README.md b/docs/README.md index 330ec121..6181ed7e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -10,6 +10,7 @@ Start on the repo root **[README.md](../README.md)** and **[QUICKSTART.md](../QU | [pr-preview-setup.md](pr-preview-setup.md) | AWS PR previews, DNS, CloudFormation | | [supabase-staging-production-setup.md](supabase-staging-production-setup.md) | Remote staging/production Supabase projects | | [supabase-preview-setup.md](supabase-preview-setup.md) | Shared PR preview database and redirects | +| [stripe-billing-setup.md](stripe-billing-setup.md) | Stripe + Supabase Edge billing (keys, webhooks, sync, local vs hosted) | | [reference/github-actions-secrets.md](reference/github-actions-secrets.md) | Actions secret/variable names (regenerate with `npm run docs:actions-secrets`) | | [branch-protection-setup.md](branch-protection-setup.md) | Branch rules | @@ -41,12 +42,14 @@ Start on the repo root **[README.md](../README.md)** and **[QUICKSTART.md](../QU ## Guides -| Document | Purpose | -| ---------------------------------------------------------------- | --------------------------- | -| [guides/MOBILE.md](guides/MOBILE.md) | Native rebuilds, dev client | -| [guides/DEBUGGING_NAVIGATION.md](guides/DEBUGGING_NAVIGATION.md) | Navigation debugging notes | -| [mobile-ios-patching.md](mobile-ios-patching.md) | iOS-specific patches | -| [REALTIME_DEVELOPMENT.md](REALTIME_DEVELOPMENT.md) | Realtime dev notes | +| Document | Purpose | +| ---------------------------------------------------------------- | -------------------------------------------------------------------------- | +| [stripe-billing-setup.md](stripe-billing-setup.md) | Stripe account → webhooks → Edge secrets → price sync | +| [guides/billing-plan-catalog.md](guides/billing-plan-catalog.md) | Plan `features` / `usage_limits`, `billing:apply-plans`, rollout checklist | +| [guides/MOBILE.md](guides/MOBILE.md) | Native rebuilds, dev client | +| [guides/DEBUGGING_NAVIGATION.md](guides/DEBUGGING_NAVIGATION.md) | Navigation debugging notes | +| [mobile-ios-patching.md](mobile-ios-patching.md) | iOS-specific patches | +| [REALTIME_DEVELOPMENT.md](REALTIME_DEVELOPMENT.md) | Realtime dev notes | ## Architecture diff --git a/docs/guides/MOBILE.md b/docs/guides/MOBILE.md index 5ea74174..bcab0475 100644 --- a/docs/guides/MOBILE.md +++ b/docs/guides/MOBILE.md @@ -22,3 +22,7 @@ Use these when native projects are stale, simulators lost the app, or native mod | `npm run prebuild:clean` | Regenerate `ios/` and `android/` with Expo prebuild (destructive; review diffs) | For EAS builds, dev-client install flows, and CI-driven previews, see [MOBILE_BUILD_TESTING.md](../MOBILE_BUILD_TESTING.md) and [oauth/MOBILE_OAUTH_SETUP.md](../oauth/MOBILE_OAUTH_SETUP.md). + +## Local Supabase schema + +Database migrations live only under **`supabase/migrations/`** at the repository root. Run `supabase start`, `supabase migration new …`, and `supabase db reset` from the repo root — not only from `apps/mobile`. Details: [`apps/mobile/supabase/migrations/README.md`](../../apps/mobile/supabase/migrations/README.md). diff --git a/docs/guides/billing-plan-catalog.md b/docs/guides/billing-plan-catalog.md new file mode 100644 index 00000000..653f0123 --- /dev/null +++ b/docs/guides/billing-plan-catalog.md @@ -0,0 +1,97 @@ +# Billing plan catalog (features and usage limits) + +This guide is the **checklist for changing entitlements** in BeakerStack: booleans, numeric caps, and metered `usage_limits`. It complements [stripe-billing-setup.md](../stripe-billing-setup.md) (Stripe keys, webhooks, price sync). + +## Source of truth + +| Layer | Role | +| ------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | +| **`public.billing_plans`** | **Runtime source of truth** for `features` and `usage_limits`. Hooks read the plan row by `billing_subscriptions.plan_id`. | +| **App billing config** ([`apps/web/src/billing/beakerstackBillingConfig.ts`](../../apps/web/src/billing/beakerstackBillingConfig.ts)) | TypeScript catalog: `defineBillingConfig`, `planFeatureRows`, copy for UI. Must match DB or the plan page and RPCs disagree. | +| **Stripe** | Prices and subscriptions. `stripe-webhook` resolves `plan_id` from Stripe price IDs; it does **not** overwrite `features` / `usage_limits`. | + +**`trial_period_days`:** when non-zero on a paid plan, Edge checkout passes it to Stripe as `subscription_data.trial_period_days`. Keep config, seeds, and `billing:apply-plans` in sync. Involuntary return to Free after a trial is covered in [beakerstack-billing-v1.md §Involuntary downgrade](../specs/beakerstack-billing-v1.md). + +Changing a row in **`billing_plans`** applies **immediately** to every subscription with that `plan_id`. **Usage counts** (`billing_usage_aggregates`) are unchanged; only limits and feature flags change, so `remaining` and gates update on the next read. + +There is **no per-user snapshot** of entitlements on `billing_subscriptions` for features (only `plan_id`). **Grandfathering** different limits on the same Stripe price is not supported without new schema (out of scope for v1). + +## Footgun: missing meter keys in `usage_limits` + +`billing_get_remaining_usage` treats a meter as **unlimited** when that `event_type` key is **absent** from the plan’s `usage_limits` JSON (not “zero usage”). When you add a new meter, add an explicit numeric limit for **every** plan tier that should be capped (use `-1` in config / DB convention for unlimited where applicable). + +Removing a meter key without a coordinated app change can unintentionally grant **unlimited** use for that `event_type`. + +## Change checklist + +| Change | Edit in app | Database | Usage / notes | +| --------------------------- | --------------------------------------- | ----------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | +| Add boolean feature | `plans[].features`, `planFeatureRows` | Push with `billing:apply-plans` or SQL `UPDATE` | Missing key behaves as false in boolean UI / `useFeature`. | +| Add meter | `plans[].usageLimits`, `usageMeterCopy` | Same | Every plan row needs a key or the meter is **unlimited** on that tier. | +| Raise / lower cap | Config + DB | Same | `used` unchanged; `remaining` recomputes. | +| Rename a meter `event_type` | Config + app call sites | `UPDATE` + optional migration of `billing_usage_aggregates` | Old `event_type` rows no longer count toward the new key unless migrated. | +| Remove a feature key | Config + references | Replace JSON | For meters, prefer explicit policy (e.g. stop invoking RPC) rather than deleting the key alone. | + +## Files to keep aligned (template) + +- [`apps/web/src/billing/beakerstackBillingConfig.ts`](../../apps/web/src/billing/beakerstackBillingConfig.ts) +- [`supabase/seed.sql`](../../supabase/seed.sql) and [`apps/mobile/supabase/seed.sql`](../../apps/mobile/supabase/seed.sql) (same billing seed; used on `supabase db reset`) + +After editing the TypeScript catalog, run **`npm run billing:apply-plans`** against each environment that should match, **or** update seeds if you only care about fresh local resets. + +## Apply script vs SQL migration + +### `npm run billing:apply-plans` (default for iteration) + +- Pushes `features`, `usage_limits`, and safe display fields from the billing config module into **`billing_plans`** using the **service role**. +- **Does not** modify Stripe price columns (`stripe_price_id_monthly`, `stripe_price_id_annual`, `stripe_product_id`). +- Requires **`SUPABASE_URL`** and **`SUPABASE_SERVICE_ROLE_KEY`**. Never expose the service role in client apps. +- Use **`--dry-run`** to print the payload that would be written for each plan (no Supabase credentials required; no database calls). + +Use this for local, PR preview, staging, or production when you are comfortable applying DML outside a migration file. + +### Versioned SQL migration (audit trail) + +- Add a migration under `supabase/migrations/` that `UPDATE`s `billing_plans` (or uses `jsonb_set`) so the change is reviewed in PR and applied with your normal `db push` / pipeline. +- Prefer this when compliance or release process requires **all** database changes in git. + +You can use **both**: migration in git for production, and the script locally for speed—as long as the **canonical** definition (TS config or migration) wins and people do not overwrite each other unintentionally. + +## Running `billing:apply-plans` + +From the **repository root**, with Supabase credentials for the target project: + +```bash +export SUPABASE_URL="https://.supabase.co" # or http://127.0.0.1:54321 locally +export SUPABASE_SERVICE_ROLE_KEY="" + +npm run billing:apply-plans +# Preview only: +npm run billing:apply-plans -- --dry-run +``` + +The script loads [`beakerstackBillingConfig`](../../apps/web/src/billing/beakerstackBillingConfig.ts) by default and updates one row per plan `id` under `productId`. If a plan id is missing from the database, the script exits with an error (it does not insert plans). + +To load a different module (must export `beakerstackBillingConfig` or a `default` catalog with `productId` and `plans`): + +```bash +export BILLING_PLAN_CONFIG_MODULE="apps/web/src/billing/beakerstackBillingConfig.ts" +npm run billing:apply-plans +``` + +Stripe price sync remains **`npm run billing:sync-stripe`** ([stripe-billing-setup.md](../stripe-billing-setup.md)). + +## Environment checklist + +Apply changes (script or migration) everywhere you maintain real data: + +1. Local Docker (`supabase start` — often `db reset` reapplies seeds instead) +2. PR preview Supabase +3. Staging +4. Production + +## Related code (for debugging) + +- Plan fetch: `packages/billing/src/hooks/usePlan.ts` → `billing_plans` by `plan_id` +- Usage: `billing_get_remaining_usage` in `supabase/migrations/20250424120000_billing_v1.sql` +- Plan card lines: `apps/web/src/billing/planPresentation.ts` → `planFeatureLine(plan, row)` uses **`plan.features`** from the DB-backed `Plan` diff --git a/docs/pr-preview-setup.md b/docs/pr-preview-setup.md index cc9c6ae8..b18a8685 100644 --- a/docs/pr-preview-setup.md +++ b/docs/pr-preview-setup.md @@ -89,22 +89,25 @@ variables → Actions) before enabling the workflow. ### Secrets -| Secret | Purpose | -| ------------------------------ | ---------------------------------------------------------------- | -| `AWS_ACCESS_KEY_ID` | Deployer credentials (CloudFormation, S3, CloudFront). | -| `AWS_SECRET_ACCESS_KEY` | Matches above. | -| `AWS_SESSION_TOKEN` | Optional if using temporary credentials. | -| `PR_PREVIEW_CERTIFICATE_ARN` | ACM certificate ARN (`us-east-1`). | -| `SUPABASE_PREVIEW_PROJECT_REF` | Target Supabase project ref (dedicated preview project). | -| `SUPABASE_PREVIEW_DB_PASSWORD` | Database password for the preview project. | -| `SUPABASE_PREVIEW_DB_URL` | Connection URI (used for teardown schema drop). | -| `SUPABASE_ACCESS_TOKEN` | Supabase personal access token (CLI authentication). | -| `PREVIEW_SUPABASE_URL` | Supabase API URL (Settings → API → Project URL). | -| `PREVIEW_SUPABASE_ANON_KEY` | Supabase anon key (Settings → API → anon public key). | -| `PRODUCTION_SUPABASE_URL` | _(optional)_ Production Supabase URL for production deployments. | -| `PRODUCTION_SUPABASE_ANON_KEY` | _(optional)_ Production Supabase anon key. | -| `STAGING_SUPABASE_URL` | _(optional)_ Staging Supabase URL for staging deployments. | -| `STAGING_SUPABASE_ANON_KEY` | _(optional)_ Staging Supabase anon key. | +| Secret | Purpose | +| -------------------------------------- | --------------------------------------------------------------------- | +| `AWS_ACCESS_KEY_ID` | Deployer credentials (CloudFormation, S3, CloudFront). | +| `AWS_SECRET_ACCESS_KEY` | Matches above. | +| `AWS_SESSION_TOKEN` | Optional if using temporary credentials. | +| `PR_PREVIEW_CERTIFICATE_ARN` | ACM certificate ARN (`us-east-1`). | +| `SUPABASE_PREVIEW_PROJECT_REF` | Target Supabase project ref (dedicated preview project). | +| `SUPABASE_PREVIEW_DB_PASSWORD` | Database password for the preview project. | +| `SUPABASE_PREVIEW_DB_URL` | Connection URI (used for teardown schema drop). | +| `SUPABASE_ACCESS_TOKEN` | Supabase personal access token (CLI authentication). | +| `PREVIEW_SUPABASE_URL` | Supabase API URL (Settings → API → Project URL). | +| `PREVIEW_SUPABASE_ANON_KEY` | Supabase anon key (Settings → API → anon public key). | +| `PREVIEW_STRIPE_SECRET_KEY` | Stripe test secret key used by preview Edge billing functions. | +| `PREVIEW_STRIPE_WEBHOOK_SECRET` | Stripe signing secret (`whsec_...`) for the preview webhook endpoint. | +| `PR_TESTING_SUPABASE_SERVICE_ROLE_KEY` | Service role key used when setting preview Edge billing secrets. | +| `PRODUCTION_SUPABASE_URL` | _(optional)_ Production Supabase URL for production deployments. | +| `PRODUCTION_SUPABASE_ANON_KEY` | _(optional)_ Production Supabase anon key. | +| `STAGING_SUPABASE_URL` | _(optional)_ Staging Supabase URL for staging deployments. | +| `STAGING_SUPABASE_ANON_KEY` | _(optional)_ Staging Supabase anon key. | ### Variables @@ -135,13 +138,16 @@ Triggered for `opened`, `reopened`, `synchronize`, `ready_for_review`. - Resets migrations + seed data. - Generates TypeScript types for all packages. - Uses `--skip-if-unchanged` to avoid contacting Supabase when no migrations changed. +5. Deploy billing Edge functions for preview: + - Sets preview project Edge secrets (`STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`, `SUPABASE_URL`, `SUPABASE_SERVICE_ROLE_KEY`). + - Deploys `stripe-webhook` and `billing-stripe`. -5. Run `scripts/pr-preview/deploy-web.sh` +6. Run `scripts/pr-preview/deploy-web.sh` - Builds Vite web app with `VITE_BASE_PATH="/pr-"`. - Syncs to `s3://beakerstack.com-deploy/pr-/`. - Invalidates CloudFront path `/pr-/*` on deploy distribution. - Performs `curl` check on `https://deploy./pr-/`. -6. Post/update PR comment with web preview link. +7. Post/update PR comment with web preview link. ### Teardown (`teardown-preview` job) diff --git a/docs/reference/github-actions-secrets.md b/docs/reference/github-actions-secrets.md index bdb4dfa6..75e6bec9 100644 --- a/docs/reference/github-actions-secrets.md +++ b/docs/reference/github-actions-secrets.md @@ -16,15 +16,24 @@ This file lists repository **secrets** and **variables** the setup wizard can sy | `STAGING_SUPABASE_ANON_KEY` | no | staging | | `STAGING_SUPABASE_PROJECT_REF` | no | staging | | `STAGING_SUPABASE_DB_PASSWORD` | no | staging | +| `STAGING_STRIPE_SECRET_KEY` | no | staging | +| `STAGING_STRIPE_WEBHOOK_SECRET` | no | staging | +| `STAGING_BILLING_ALLOWED_ORIGINS` | yes | staging | | `PRODUCTION_SUPABASE_URL` | no | production | | `PRODUCTION_SUPABASE_ANON_KEY` | no | production | | `PRODUCTION_SUPABASE_PROJECT_REF` | no | production | | `PRODUCTION_SUPABASE_DB_PASSWORD` | no | production | +| `PRODUCTION_STRIPE_SECRET_KEY` | no | production | +| `PRODUCTION_STRIPE_WEBHOOK_SECRET` | no | production | +| `PRODUCTION_BILLING_ALLOWED_ORIGINS` | yes | production | | `PREVIEW_SUPABASE_URL` | no | preview | | `PREVIEW_SUPABASE_ANON_KEY` | no | preview | | `SUPABASE_PREVIEW_PROJECT_REF` | no | preview | | `SUPABASE_PREVIEW_DB_PASSWORD` | no | preview | | `SUPABASE_PREVIEW_DB_URL` | no | preview | +| `PREVIEW_STRIPE_SECRET_KEY` | no | preview | +| `PREVIEW_STRIPE_WEBHOOK_SECRET` | no | preview | +| `PREVIEW_BILLING_ALLOWED_ORIGINS` | yes | preview | | `PR_PREVIEW_CERTIFICATE_ARN` | no | preview | | `EXPO_TOKEN` | no | expo | | `EXPO_PROJECT_ID` | no | expo | diff --git a/docs/reviews/code-review-billing-v1-followup.md b/docs/reviews/code-review-billing-v1-followup.md new file mode 100644 index 00000000..1e99332f --- /dev/null +++ b/docs/reviews/code-review-billing-v1-followup.md @@ -0,0 +1,56 @@ +# Follow-up code review: billing fixes (post `code-review-billing-v1`) + +**Review date:** 2026-04-27 +**Purpose:** Verify that the codebase changes address the concerns listed in [code-review-billing-v1.md](./code-review-billing-v1.md). + +--- + +## Summary + +| Original concern | Status | Notes | +| ------------------------------------------------------------------------------- | ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **High — open redirect** (unvalidated `successUrl` / `cancelUrl` / `returnUrl`) | **Resolved** | `assertRedirectUrlAllowed()` in [`supabase/functions/_shared/billing-origins.ts`](../../supabase/functions/_shared/billing-origins.ts) validates `new URL(...).origin` against a allowlist before Stripe calls. Called from checkout and portal handlers in [`billing-stripe/index.ts`](../../supabase/functions/billing-stripe/index.ts). | +| **Medium — error detail leakage** | **Resolved** | Catch-all path returns only `{ error: 'stripe_error' }` (no raw `message` to the client). `RedirectValidationError` maps to `invalid_redirect_url`. Full errors are logged with `console.error`. | +| **Medium — CORS `*`** | **Resolved** | [`supabase/functions/_shared/cors.ts`](../../supabase/functions/_shared/cors.ts) uses `corsHeadersForRequest(req)`: reflects `Origin` when it is in the same allowlist; no `*` for browser requests with a matching origin; `*` only when `Origin` header is absent (typical for non-browser callers). | +| **Operational — `SUPABASE_ANON_KEY` for `billing-stripe`** | **Partially addressed** | The function still **requires** `SUPABASE_ANON_KEY` in env (explicit check at lines 36–41 of `billing-stripe/index.ts`). Deploy workflows do **not** pass it through `supabase secrets set`; that continues to rely on **Supabase-managed** Edge runtime defaults, which is the usual pattern. No code change required if the platform always injects the anon key; consider a one-line smoke test in runbooks after deploy. | +| **Naming — `downgrade_to_free` vs behavior** | **Resolved** | Action renamed to `schedule_cancel_to_free` in the Edge contract; [`useBillingStripeActions.ts`](../../packages/billing/src/hooks/useBillingStripeActions.ts) sends `schedule_cancel_to_free`. | +| **Design — client-trusted usage metering** | **Unchanged (by design)** | Still appropriate for soft limits; not a regression. | +| **Low — `resolvePlanId` PostgREST filter** | **Unchanged** | Still acceptable given Stripe-sourced price IDs. | +| **Low — webhook payload retention / backups** | **Unchanged** | Operational/policy concern only. | +| **Consistency — demo JWT in CI vs `supabase-cli-defaults`** | **Unchanged** | Low priority; not part of the billing fix wave. | + +--- + +## What was verified in code + +### Redirect allowlist + +- **`BILLING_ALLOWED_ORIGINS`** — comma-separated origins merged in `getBillingAllowedOrigins()` (documented in [`env.example`](../../env.example), [`supabase/functions/README.md`](../../supabase/functions/README.md)). +- **Local dev** — When `SUPABASE_URL` is loopback (`localhost`, `127.0.0.1`, or `[::1]`), common dev origins (Vite `5173`, Expo `8081`, `:3000`, including `http://[::1]:…`) are merged automatically so teams are not blocked without secrets. +- **Live Stripe keys** — If `STRIPE_SECRET_KEY` starts with `sk_live_`, `http:` redirect URLs are rejected (HTTPS-only for live). + +### CI/CD alignment + +- **`deploy-staging.yml`**, **`deploy-production.yml`**, and **`pr-preview-environment.yml`** pass **`PREVIEW_/STAGING_/PRODUCTION_BILLING_ALLOWED_ORIGINS`** into `supabase secrets set` as **`BILLING_ALLOWED_ORIGINS`**, alongside Stripe and Supabase URL/service-role secrets. + +### Tests / hooks + +- **`packages/billing`** tests expect `action: 'schedule_cancel_to_free'` in `useBillingStripeActions.test.tsx` (per repository grep). + +### Webhook function + +- **`stripe-webhook`** uses the same `corsHeadersForRequest` / `jsonResponse(..., req)` pattern; no raw error message in client JSON for processing failures (unchanged security posture for the main path). + +--- + +## Remaining small gaps (non-blocking) + +1. **Custom URL schemes (mobile)** — README mentions listing origins such as `myapp://`. Behavior depends on how `new URL(...)` parses the redirect string you pass from the client and whether `url.origin` matches what you put in `BILLING_ALLOWED_ORIGINS`. Worth one manual test per platform (Expo deep link) before go-live. + +Setup manifest sync for billing origins (`PREVIEW_/STAGING_/PRODUCTION_BILLING_ALLOWED_ORIGINS`) and IPv6 loopback dev URLs (`http://[::1]:…`) were added after this doc’s initial draft — see [`scripts/lib/setup-manifest.mjs`](../../scripts/lib/setup-manifest.mjs) and [`billing-origins.ts`](../../supabase/functions/_shared/billing-origins.ts). + +--- + +## Conclusion + +The **security-critical** items from the prior review (**redirect validation**, **sanitized errors**, **tighter CORS**) are **implemented coherently** and wired through CI secrets for hosted environments. The **API naming** concern for cancel-at-period-end is **fixed**. Optional **deep-link verification** per platform and **by-design** usage metering remain on the backlog—not regressions from the fixes. diff --git a/docs/reviews/code-review-billing-v1.md b/docs/reviews/code-review-billing-v1.md new file mode 100644 index 00000000..fa72bfda --- /dev/null +++ b/docs/reviews/code-review-billing-v1.md @@ -0,0 +1,110 @@ +# Code review: `billing-v1` branch vs `main` + +**Review date:** 2026-04-27 +**Comparison:** `main...billing-v1` (merge-base three-dot diff) +**Scale:** ~227 files changed, ~15k insertions / ~675 deletions + +This review focuses on **security** (including accidental secret exposure), **operational risk** in CI and Edge Functions, and **overall code quality**. It is not an exhaustive line-by-line read of every new test file. + +--- + +## Executive summary + +The branch delivers a **coherent billing v1**: a `packages/billing` library, Supabase schema + RLS, two Edge Functions (`billing-stripe`, `stripe-webhook`), web and mobile UI, sync tooling, and broad test coverage. **No real Stripe or cloud Supabase secrets appear committed**; `env.example` uses placeholders, and workflow references use `secrets.*` indirection. + +**Main gaps to address before production hardening:** (1) **redirect URL validation** on the checkout/portal Edge Function, (2) **information disclosure** in Stripe error responses, (3) **operational confirmation** that hosted Edge runtimes still expose `SUPABASE_ANON_KEY` to `billing-stripe` after `supabase secrets set` (4) **usage metering trust model** (client-driven RPC) if usage ever drives real charges. + +--- + +## Security audit + +### Strengths + +- **Webhook integrity:** `stripe-webhook` uses `constructEventAsync` with `STRIPE_WEBHOOK_SECRET`. Invalid signatures return 400; no dependency on JWT for that function (see `supabase/config.toml`: `verify_jwt = false` for `stripe-webhook`, `true` for `billing-stripe`). +- **User-scoped billing actions:** `billing-stripe` resolves the user from the **caller's JWT** via `authClient.auth.getUser()`, then uses the **service role** only for database operations tied to that `user.id`. Mutations (checkout, portal, subscription update, cancel) are scoped to the authenticated user. +- **RLS:** `billing_subscriptions`, usage tables, and `billing_invoices` use **select-own** policies; webhook and service paths use the service role. `billing_webhook_events` has **no** authenticated policies (service role only), which is appropriate for sensitive payloads. +- **Idempotent webhook logging:** Duplicate Stripe event IDs are handled; retries can reprocess failed events (`processed = false`). +- **JWT verification:** Customer-facing Stripe operations require JWT verification on `billing-stripe`, reducing anonymous abuse. + +### Findings (ordered by severity) + +| Severity | Topic | Details | +| ---------- | ------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **High** | Open redirect / phishing via Checkout & Customer Portal | `billing-stripe` passes `success_url`, `cancel_url`, and `return_url` from the **request body** into Stripe (`checkout.sessions.create`, `billingPortal.sessions.create`) with **no server-side allowlist** of origins or paths. A user with a valid session could call `functions.invoke` with attacker-controlled URLs (or a modified client could), steering post-payment redirects away from your app. **Recommendation:** Validate URLs against an allowlist (exact origins from env, e.g. `ALLOWED_CHECKOUT_ORIGINS`, or match `new URL(url).origin` to configured app origins). | +| **Medium** | Error detail leakage | On catch-all errors, `billing-stripe` returns `{ error: 'stripe_error', message: msg }` where `msg` is the raw Stripe/API message. That can expose internal identifiers or operational hints. Prefer a stable client-facing code plus logged server detail only. | +| **Medium** | CORS policy | `_shared/cors.ts` sets `Access-Control-Allow-Origin: *`. Supabase Edge Functions still enforce auth for `billing-stripe`; `stripe-webhook` is not browser-called. Risk is **low** for CSRF to the function (custom JSON body + user JWT), but `*` is broader than necessary if you ever expand CORS use. | +| **Low** | `resolvePlanId` filter construction | `stripe-webhook` builds a PostgREST `.or('stripe_price_id_monthly.eq.${id},...')` filter. Stripe price IDs are normally safe; if untrusted data could reach this string, it could be fragile. In practice the value comes from Stripe objects. Optional hardening: use `.or()` with separate `.eq` filters or parameterized RPC. | +| **Low** | Webhook payload storage | Full Stripe `event` objects are stored in `billing_webhook_events.payload` (PII and payment metadata). This is **correctly** not exposed to `authenticated` via RLS, but treat DB backups and admin access as **sensitive** and consider retention limits. | +| **Design** | Usage events are client-initiated | `billing_record_usage_event` is `SECURITY DEFINER` and callable by `authenticated` users, who can record usage for **themselves**. That is fine for “soft” limits; it is **not** a tamper-proof meter for revenue-grade billing. If overages or enforcement must be strict, move metering to trusted servers or signed server events. | + +### Webhook → DB edge cases (quality / correctness, not auth bypass) + +- `syncInvoiceRow` throws if no `billing_subscriptions` row exists for the Stripe customer, causing a 500 and `processed: false` on the event. That is a reasonable **retry** story but worth monitoring in production. +- `mapStripeStatus` defaults unknown Stripe statuses to `'active'`, which may be optimistic; worth revisiting as Stripe adds statuses. + +--- + +## Secrets, keys, and sensitive data + +### No evidence of committed live secrets in this branch + +- **`env.example`:** Placeholders only (`sk_test_…`, `whsec_…` patterns as documentation, not real keys). +- **Edge Functions:** Read secrets from `Deno.env` only. +- **GitHub workflows:** Use `${{ secrets.* }}` for Stripe, Supabase, and AWS; no inline tokens in the diff reviewed. +- **`.github/workflows/test.yml`:** Continues to embed the **public** Supabase CLI demo JWTs for local CI against `127.0.0.1:54321` (expected for this repo’s pattern; same class of “non-secret” as `tests/utils/supabase-cli-defaults.ts`). + +### Consistency check (low priority) + +- `test.yml` `SUPABASE_SERVICE_ROLE_KEY` signature differs from the `LOCAL_SUPABASE_DEMO_SERVICE_ROLE_KEY` in `tests/utils/supabase-cli-defaults.ts` (third segment of the JWT). If CI ever uses helpers that require bit-identical keys, align with the **current** `supabase start` output. This predates or sits alongside the billing work; not introduced as a billing secret leak. + +### Documentation and skills + +- New Stripe-related **agent skills** under `.agents` / symlinks and `.claude` are **documentation and references**, not credentials. + +--- + +## Code quality and architecture + +### Positives + +- **Clear separation:** `packages/billing` holds provider, hooks, and UI building blocks; apps supply `defineBillingConfig` data and route shells (`BillingProviderLayout`). +- **Schema validation:** `productBillingConfigSchema` (Zod) in the provider reduces misconfiguration. +- **SQL design:** Migrations add indexes, `SECURITY DEFINER` RPCs with `search_path` pinned, and explicit `REVOKE`/`GRANT` for RPCs. Realtime on `billing_subscriptions` is documented and scoped. +- **Tests:** Extensive unit and component tests for billing hooks, presentational components, and pages; `packages/billing` integrated into coverage upload in `test.yml`. +- **Tooling:** `scripts/sync-billing-stripe.mjs` is documented and idempotent-friendly for Stripe price linkage. + +### Minor quality notes + +- **Naming:** `downgrade_to_free` in `billing-stripe` implements **cancel at period end** (`cancel_at_period_end: true`), not an immediate free plan. The client hook name `scheduleCancelToFree` matches behavior better than the action name; consider renaming the action for API clarity. +- **Stripe API version:** Pinned to `2023-10-16` consistently; plan periodic upgrades per Stripe’s release notes. +- **Duplication (resolved):** Duplicate migration SQL under `apps/mobile/supabase/migrations/` was removed; canonical migrations live only under repo-root `supabase/migrations/` — developers run Supabase CLI migration workflows from the repository root (see `apps/mobile/supabase/migrations/README.md`). + +--- + +## CI/CD and operations + +- **Deploy workflows** (`deploy-staging.yml`, `deploy-production.yml`, and PR preview) now **link** the project, **set** Stripe and Supabase-related secrets, and **deploy** `stripe-webhook` and `billing-stripe`. This matches `supabase/functions/README.md`. +- **Follow-up to verify in each environment:** After first deploy, confirm `billing-stripe` logs show `SUPABASE_ANON_KEY` present (Supabase hosted projects usually inject it; the README notes “confirm if calls fail”). The workflows set `SUPABASE_URL` and `SUPABASE_SERVICE_ROLE_KEY` explicitly; if `ANON` were ever missing, authenticated `getUser()` would fail. +- **PR preview** sets preview Stripe secrets on the shared preview project—ensure Stripe **test** keys and webhook endpoints are scoped to that environment. + +--- + +## Testing and coverage + +- New `packages/billing` tests and web billing page tests improve confidence. +- `supabase/tests/billing.test.sql` checks RLS and core objects; consider extending pgTAP for `billing_invoices` RLS if you want parity with `billing_subscriptions` checks. + +--- + +## Recommended next steps (short list) + +1. Add **URL allowlisting** for `successUrl`, `cancelUrl`, and `returnUrl` in `billing-stripe` (and optionally require HTTPS in production). +2. **Sanitize** client-visible errors from the Edge Function; log full errors server-side only. +3. Document or automate **Edge secret** expectations (`SUPABASE_ANON_KEY` for `billing-stripe`) in runbooks and smoke-test after deploy. +4. If usage-based billing becomes revenue-critical, **relocate usage recording** to trusted backends or verified pipelines. + +--- + +## Conclusion + +The `billing-v1` branch is a **substantial, well-structured** billing foundation with **sound RLS, webhook verification, and authenticated Edge access**. The highest-impact security improvement is **validating redirect URLs** passed into Stripe. No committed repository secrets were identified in the reviewed material; continue to keep Stripe and Supabase keys in **GitHub Actions secrets** and Supabase **Edge secrets** only. diff --git a/docs/reviews/spec-compliance-audit-billing-v1.md b/docs/reviews/spec-compliance-audit-billing-v1.md new file mode 100644 index 00000000..d59cee00 --- /dev/null +++ b/docs/reviews/spec-compliance-audit-billing-v1.md @@ -0,0 +1,354 @@ +# Spec-to-code compliance audit — billing-v1 vs `main` + +**Branch reviewed:** `billing-v1` +**Specifications:** `docs/specs/beakerstac-billing-v1.md`, `docs/specs/beakerstack-billing-ui-v1.md` +**Methodology:** Phases 1–5 completed as requested; report written after Phase 5. +**Constraint:** Analysis only — no code changes. + +--- + +## Phase 1 — Spec ingestion (atomic requirements) + +Each requirement is one verifiable statement. IDs are stable for traceability below. + +### Core billing (`beakerstac-billing-v1.md`) + +| ID | Requirement | +| ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| REQ-001 | Stripe must remain the source of truth for subscription state; DB stays aligned via webhooks or Stripe-backed actions. | +| REQ-002 | Entitlements must be derived from plan configuration + subscription (no separately stored entitlement rows). | +| REQ-003 | Schema must support multiple products without structural changes per product. | +| REQ-004 | Usage limits must be enforceable by the app via module-provided checks (`getRemainingUsage` / `hasExceededLimit` pattern); module must not silently block actions alone. | +| REQ-005 | Free tier must be first-class with status distinct from paid Stripe subscriptions (`free`, no Stripe subscription id where applicable). | +| REQ-006 | Demo shortcuts must not live inside reusable billing logic except documented guardrails; demo upgrades via gated RPCs per appendix. | +| REQ-007 | Tables `billing_products`, `billing_plans`, `billing_subscriptions`, `billing_usage_events`, `billing_usage_aggregates`, `billing_webhook_events` exist with intended semantics (products/plans/subscriptions/usage/events/log). | +| REQ-008 | RLS must restrict subscriptions and usage rows so users only **select** their own rows; subscription mutations via trusted paths only. | +| REQ-009 | API surface includes entitlement/feature access semantics equivalent to `canUserAccessFeature(userId, productId, featureName)`. | +| REQ-010 | API surface includes current plan resolution equivalent to `getUserPlan(userId, productId)`. | +| REQ-011 | API surface includes `getFeatureValue` semantics for boolean or numeric limits. | +| REQ-012 | API surface includes `getRemainingUsage` returning used/limit/remaining/periodEnd for an event type. | +| REQ-013 | API surface includes `hasExceededLimit`. | +| REQ-014 | API surface includes `recordUsageEvent` including optional **metadata**. | +| REQ-015 | API surface includes `initiateCheckout` with optional `successUrl`, `cancelUrl`, **`trialDays`**. | +| REQ-016 | API surface includes customer portal URL retrieval (`getCustomerPortalUrl`). | +| REQ-017 | API surface includes `downgradeToFree` (cancel paid subscription at period end). | +| REQ-018 | API surface includes `cancelSubscriptionImmediately`. | +| REQ-019 | API surface includes `getPublicPlans` and `getPlan(planId)`. | +| REQ-020 | React **PricingTable** matches documented props (`productId`, `currentUserId`, `highlightPlanId`, `onCheckout`). | +| REQ-021 | React **UpgradePrompt** matches documented props (`productId`, `reason`, `suggestedPlanId`, `onUpgrade`). | +| REQ-022 | React **UsageIndicator** documents progress/visualization and updates as usage changes (including expanded UX where specified). | +| REQ-023 | React **SubscriptionStatus** exists per spec snippet. | +| REQ-024 | React **CustomerPortalLink** exists per spec snippet. | +| REQ-025 | React **FeatureGate** accepts product + feature identifier and fallback (`featureName` in spec snippet). | +| REQ-026 | Declarative billing config seeds DB idempotently (sync/setup script pattern). | +| REQ-027 | Missing subscription for a product must be corrected toward free tier (`ensure` semantics). | +| REQ-028 | Usage periods use billing period for paid users and **calendar month** for free tier users. | +| REQ-029 | Webhook handler processes listed lifecycle events including checkout completion, subscription updated/deleted, trial ending signal, invoice payment failed/succeeded (per spec lists). | +| REQ-030 | Webhooks verify Stripe signatures; events logged in `billing_webhook_events`; processing idempotent for retries. | +| REQ-031 | Module reads Stripe keys from environment (`STRIPE_PUBLISHABLE_KEY`, `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`) per spec’s configuration section. | + +### Appendix / template alignment (`beakerstac-billing-v1.md` appendix) + +| ID | Requirement | +| ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | +| REQ-032 | Template demo tier vocabulary is app-owned (Free/Pro/Max-style ids), not hardcoded inside `packages/billing`. | +| REQ-033 | `/billing-demo` route demonstrates three entitlement surfaces per appendix **or** equivalent documented migration path. | +| REQ-034 | Demo RPCs (`simulateUpgrade`-style, usage reset) are gated server-side; client demo flags are UX-only. | +| REQ-035 | Webhook testing documentation exists at **`apps/web/docs/billing-demo.md`** with Stripe CLI scenarios idempotency guidance (appendix explicit path). | + +### Billing UI v1 (`beakerstack-billing-ui-v1.md`) + +| ID | Requirement | +| ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| REQ-036 | `/billing`, `/billing/usage`, `/billing/plans`, `/billing/invoices` exist under **ProtectedRoute**, wrapped once by **`BillingProvider`** at route-group level as illustrated. | +| REQ-037 | Each `/billing/*` page renders shared **`BillingTabs`** below title with four tabs (`NavLink`). | +| REQ-038 | **`UserMenu.web`** and **`UserMenu.native`** include **Billing** (`CreditCard`) linking to `/billing`, ordered Profile → Billing → Dashboard above Sign Out. | +| REQ-039 | **BillingOverviewPage** matches §3.1 structure (banner(s), current plan card behavior free vs paid, quick stats, optional recent invoices when present). | +| REQ-040 | **BillingUsagePage** matches §3.2 (reset-period semantics card, meter section with UsageIndicator expanded usage, limits rows, plan features rows). | +| REQ-041 | **BillingPlansPage** matches §3.3 (cadence toggle persisted in URL query; PlanCard grid; downgrade modal; constraint warnings disable downgrade when hard constraints fail). | +| REQ-042 | **BillingInvoicesPage** matches §3.4 (columns, Stripe-hosted links, load-more pagination **20** rows). | +| REQ-043 | Nine-state matrix behaviors render consistently across Overview / Plans / Invoices where applicable (Loading, No subscription→free ensure, Free, Paid active, Cancelled-pending + **reactivate**, Payment failed, Trial active, Trial ending, Post-downgrade-pending). | +| REQ-044 | **`billing_plans`** gains **`stripe_price_id_monthly`** / **`stripe_price_id_annual`**; **`billing_subscriptions`** records **`stripe_price_id`** for cadence resolution; **`billing_invoices`** exists with webhook-only writes. | +| REQ-045 | **`stripe-webhook`** handles **`invoice.created`**, **`invoice.finalized`**, **`invoice.paid`**, **`invoice.payment_failed`**, **`invoice.payment_succeeded`**, **`invoice.voided`** per §6 with idempotent upserts into **`billing_invoices`**. | +| REQ-046 | Invoice timing edge case per §6: if subscription lookup fails, **log** webhook row and reconcile later (must not necessarily fail entire webhook processing path). | +| REQ-047 | **`UsageIndicator`** gains **`expanded`** variant with label + reset text (`packages/billing`). **`SubscriptionStatusBadge`** exists (`packages/billing`). | +| REQ-048 | **`/billing-demo`** removed after parity; dashboard links **Manage billing** → `/billing`. | +| REQ-049 | Mobile parity for `/billing` routes ships per rollout §8 step 10 **or** explicitly deferred with mirror scaffolding — spec presents both. | + +### Ambiguities / assumptions + +1. **Naming drift:** Appendix mentions RPC **`ensure_free_subscription`** while core migration defines **`ensure_billing_subscription`** — treated as same intent unless repo standardizes names elsewhere. +2. **`stripe_price_id` column evolution:** Core DDL uses singular plan price column; UI appendix replaces with monthly/annual — interpreted as additive migration superseding core DDL snapshot (not an internal contradiction if specs read chronologically). +3. **`PricingTable` vs template `/billing/plans`:** Core component library vs UI spec **PlanCard** grid — both describe pricing UX at different layers; compliance judged separately (REQ-020 vs REQ-041). + +--- + +## Phase 2 — Code survey (inventory) + +### `packages/billing` + +- **Entry / exports:** `src/index.ts`, `src/web.ts`, `src/native.ts`, `src/client.ts` +- **Configuration / types:** `src/schema.ts` (`defineBillingConfig`, Zod schemas), `src/types.ts` +- **Provider:** `BillingProvider.tsx`, realtime subscription refresh on `billing_subscriptions` +- **Hooks:** `useBillingContext`, `useSubscription`, `usePlan`, `usePlanCatalog`, `useFeature`, `useUsage`, `useRecordUsage`, `useCheckout`, `useCustomerPortal`, `useBillingStripeActions`, `useInvoices`, `useBillingState`, `useBillingConfig` +- **Components:** `PricingTable.*`, `UpgradePrompt.*`, `UsageIndicator.*`, `SubscriptionStatus.*`, `SubscriptionStatusBadge.*`, `CustomerPortalLink.*`, `FeatureGate.*`, `BillingErrorBoundary.tsx` +- **Tests:** Vitest files alongside hooks/components (`*.test.tsx`), `schema.test.ts`, `entrypoints.test.ts`, etc. + +### Supabase (`supabase/`) + +- **Migrations:** `20250424120000_billing_v1.sql` (core tables, RLS, RPCs, realtime publication), `20260426000100_billing_demo_collections.sql`, `20260427120000_billing_ui_v1.sql` (monthly/annual prices + invoices + subscription price id) +- **Functions:** `stripe-webhook/index.ts`, `billing-stripe/index.ts`, `_shared/cors.ts` +- **Tests:** `supabase/tests/billing.test.sql` + +### Apps / tooling + +- **`apps/web`:** `BillingProviderLayout.tsx`, `pages/billing/*`, `components/billing/*`, `billing/beakerstackBillingConfig.ts`, docs `billing-testing.md`, deprecated stub `billing-demo.md` +- **`apps/mobile`:** `screens/BillingScreen.tsx`, `billing/beakerstackBillingConfig.ts`; DB migrations are only at repo root `supabase/migrations/` (see `apps/mobile/supabase/migrations/README.md`). +- **`scripts/sync-billing-stripe.mjs`**, `billing-sync.json` + +### `packages/shared` + +- Navigation (`UserMenu.web.tsx`, `UserMenu.native.tsx`), primitives added/enhanced (`Button`, `Modal`, `Skeleton`) supporting billing UI polish. + +--- + +## Phase 3 — Traceability (REQ → implementation locus) + +Locations cite **`billing-v1`** branch files. + +| REQ | Primary mapping | +| ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| REQ-001 | `supabase/functions/stripe-webhook/index.ts` (Stripe-driven updates); `supabase/functions/billing-stripe/index.ts` (Stripe API mutations) | +| REQ-002 | `billing_plans.features` JSON + client hooks reading plans (`packages/billing/src/hooks/usePlan.ts`, `useFeature.ts`) | +| REQ-003 | `billing_products.id`, `billing_plans.product_id`, `billing_subscriptions.product_id` (`supabase/migrations/20250424120000_billing_v1.sql` L7–51) | +| REQ-004 | RPCs `billing_get_remaining_usage`, `billing_has_exceeded_limit` (`20250424120000_billing_v1.sql` L269–352); `useUsage` (`packages/billing/src/hooks/useUsage.ts` L36–94) | +| REQ-005 | `ensure_billing_subscription` (`20250424120000_billing_v1.sql` L167–214); provider calls RPC (`packages/billing/src/BillingProvider.tsx` L104–112) | +| REQ-006 | `billing_system_flags`, `billing_demo_*` RPCs gated (`20250424120000_billing_v1.sql` L358–449); demo collections gated (`20260426000100_billing_demo_collections.sql`) | +| REQ-007 | `20250424120000_billing_v1.sql` L7–88 (+ UI migration `20260427120000_billing_ui_v1.sql` for invoice table / split prices) | +| REQ-008 | Policies `20250424120000_billing_v1.sql` L112–136; invoices policy `20260427120000_billing_ui_v1.sql` L55–59 | +| REQ-009 | `useFeature` (`packages/billing/src/hooks/useFeature.ts`); boolean gates (`packages/billing/src/components/FeatureGate.web.tsx` L8–17) | +| REQ-010 | `usePlan` + `useSubscription` (`packages/billing/src/hooks/usePlan.ts` — file not fully quoted here; used by pages) | +| REQ-011 | `useFeature` numeric/boolean (`packages/billing/src/hooks/useFeature.ts` L22–29) | +| REQ-012 | RPC `billing_get_remaining_usage` (`20250424120000_billing_v1.sql` L269–326); `useUsage` (`packages/billing/src/hooks/useUsage.ts` L40–66) | +| REQ-013 | RPC `billing_has_exceeded_limit` (`20250424120000_billing_v1.sql` L333–348); consumer exposure via `useUsage.exceeded` (`packages/billing/src/hooks/useUsage.ts` L79–82) — **no standalone exported hook named `hasExceededLimit`** | +| REQ-014 | RPC accepts metadata (`20250424120000_billing_v1.sql` L220–252); client passes `{}` only (`packages/billing/src/hooks/useRecordUsage.ts` L31–38) | +| REQ-015 | `billing-stripe` checkout (`supabase/functions/billing-stripe/index.ts` L81–138); **no `trialDays` / trial_period_days wiring** | +| REQ-016 | `useCustomerPortal` (`packages/billing/src/hooks/useCustomerPortal.ts` L24–49); Edge `portal` action (`billing-stripe/index.ts` L140–164) | +| REQ-017 | `scheduleCancelToFree` → `downgrade_to_free` (`packages/billing/src/hooks/useBillingStripeActions.ts` L65–67`; `billing-stripe/index.ts` L218–239) | +| REQ-018 | Edge `cancel_immediately` (`billing-stripe/index.ts` L242–261`) — **not exposed via `useBillingStripeActions` / package hooks\*\* | +| REQ-019 | `usePlanCatalog` (`packages/billing/src/hooks/usePlanCatalog.ts` L25–31`) | +| REQ-020 | `PricingTable.web.tsx` (`packages/billing/src/components/PricingTable.web.tsx` L7–47`) vs spec props | +| REQ-021 | `UpgradePrompt.types.ts` uses **`targetTier`** (`packages/billing/src/components/UpgradePrompt.types.ts` L3–14`) vs **`suggestedPlanId`\*\* in core spec | +| REQ-022 | `UsageIndicator.web.tsx` variants (`packages/billing/src/components/UsageIndicator.web.tsx` L11–99); realtime sub refresh applies to **subscriptions only** (`BillingProvider.tsx` L119–153`) | +| REQ-023 | `SubscriptionStatus.web.tsx` / `.native.tsx` present under `packages/billing/src/components/` | +| REQ-024 | `CustomerPortalLink.web.tsx` / `.native.tsx` | +| REQ-025 | `FeatureGate.types.ts` prop **`feature`** (`packages/billing/src/components/FeatureGate.types.ts` L5–11`) vs **`featureName`\*\* snippet | +| REQ-026 | `scripts/sync-billing-stripe.mjs`; config `apps/web/src/billing/billing-sync.json` | +| REQ-027 | `BillingProvider.tsx` calls `ensure_billing_subscription` (`packages/billing/src/BillingProvider.tsx` L104–112`) | +| REQ-028 | `billing_usage_period` (`20250424120000_billing_v1.sql` L145–161`) | +| REQ-029 | `stripe-webhook/index.ts` switch (`supabase/functions/stripe-webhook/index.ts` L146–292`) | +| REQ-030 | Signature verification (`stripe-webhook/index.ts` L83–93`, L96–141); duplicate processed (`L106–114`) | +| REQ-031 | Edge Functions read **server** secrets (`stripe-webhook/index.ts` L68–76`; documented in `apps/web/docs/billing-testing.md` L7–14`) — **publishable key not referenced inside Edge handlers reviewed** | +| REQ-032 | `apps/web/src/billing/beakerstackBillingConfig.ts` supplies ids/copy | +| REQ-033 | `/billing/*` replaces demo folder (`apps/web/docs/billing-demo.md` L5–8`; routes `apps/web/src/App.tsx` L38–50`) | +| REQ-034 | `billing_demo_simulate_upgrade`, `billing_demo_reset_usage` (`20250424120000_billing_v1.sql` L374–449`) | +| REQ-035 | **`billing-testing.md`** hosts Stripe CLI guidance (`apps/web/docs/billing-testing.md` L43–65`); **`billing-demo.md`** redirects (`billing-demo.md` L1–16`) | +| REQ-036 | `apps/web/src/App.tsx` L38–50`; `BillingProviderLayout.tsx` wraps outlet | +| REQ-037 | `apps/web/src/components/billing/BillingTabs.web.tsx` L4–17`; pages include tabs (e.g. `BillingOverviewPage.tsx` L52–54`) | +| REQ-038 | `packages/shared/src/components/navigation/UserMenu.web.tsx` L76–97`; verify native separately | +| REQ-039 | `BillingOverviewPage.tsx`, `CurrentPlanCard.web.tsx`, `OverviewBanners` (`BillingOverviewPage.tsx` L106–149`) | +| REQ-040 | `BillingUsagePage.tsx` (imports show UsageIndicator, FeatureLimitRow, PlanFeatureRow, Banner, BillingTabs) | +| REQ-041 | `BillingPlansPage.tsx`, `PlanCard.web.tsx`, `CadenceToggle.web.tsx`, `ConfirmDowngradeModal.web.tsx`, `constraintBlockers.ts` | +| REQ-042 | `BillingInvoicesPage.tsx` + `InvoiceTable.web.tsx`; pagination via `useInvoices` (`packages/billing/src/hooks/useInvoices.ts` L13–24`) | +| REQ-043 | `useBillingState` (`packages/billing/src/hooks/useBillingState.ts` L30–77`); banners `BillingOverviewPage.tsx` L106–149`; **`downgrade_pending` never derived** (`useBillingState.ts` L8–10`) | +| REQ-044 | Migration `20260427120000_billing_ui_v1.sql`; webhook invoice handlers (`stripe-webhook/index.ts` L268–288`, `syncInvoiceRow` L339–392`) | +| REQ-045 | Cases match (`stripe-webhook/index.ts` L254–288`) | +| REQ-046 | `syncInvoiceRow` throws if no user (`stripe-webhook/index.ts` L350–354`) vs UI spec §6 edge-case wording | +| REQ-047 | `UsageIndicator.types.ts` variant **`expanded`** (`packages/billing/src/components/UsageIndicator.types.ts` L4–12`); `SubscriptionStatusBadge.\*` in package | +| REQ-048 | Dashboard link (`apps/web/src/pages/DashboardPage.tsx` L23–25`); `/billing-demo`route absent`App.tsx` | +| REQ-049 | `apps/mobile/src/screens/BillingScreen.tsx` exists — **parity depth not exhaustively audited here** | + +--- + +## Phase 4 — Gap classification + +Statuses: **MET**, **PARTIAL**, **DIVERGENT**, **MISSING**, **AMBIGUOUS**. + +### Executive summary (also §1 below) + +### Traceability table + +| REQ ID | Status | Code location | Note | +| ------- | --------- | ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| REQ-001 | MET | `stripe-webhook/index.ts` L146–247 | Stripe events drive subscription rows; checkout writes via upsert after Stripe retrieval | +| REQ-002 | MET | `20250424120000_billing_v1.sql` L14–24`, hooks | Features stored on plans; subscribers inherit via `plan_id` | +| REQ-003 | MET | `20250424120000_billing_v1.sql` L7–51 | Composite uniqueness `(user_id, product_id)` | +| REQ-004 | MET | RPC + `useUsage.exceeded` | App-layer enforcement supported | +| REQ-005 | MET | `ensure_billing_subscription` + `'free'` rows | Matches intended free-tier semantics | +| REQ-006 | MET | Demo RPC gating | Matches appendix defense-in-depth pattern | +| REQ-007 | MET | migrations | UI migration adjusts price columns per appendix | +| REQ-008 | MET | policies | Webhook paths use service role; tables locked down for anon/authenticated writes | +| REQ-009 | PARTIAL | `useFeature.ts` | Covers boolean gate; **numeric entitlement semantics require app-side counting** (appendix aligns); no standalone **`canUserAccessFeature`** export | +| REQ-010 | MET | `usePlan.ts` (provider-backed) | Equivalent for React consumers | +| REQ-011 | PARTIAL | `useFeature.ts` L22–29 | Numbers yield **`enabled: true`** always (`L26–27`) — weak match for “numeric limit” interpretation without separate counting | +| REQ-012 | MET | RPC + `useUsage.ts` | Returns period boundaries + limits | +| REQ-013 | PARTIAL | `billing_has_exceeded_limit` SQL + `useUsage` | RPC exists; **package lacks exported function/hook named like spec** | +| REQ-014 | PARTIAL | `useRecordUsage.ts` | RPC supports metadata; hook **fixes `{}`** (`L37`) | +| REQ-015 | MISSING | `billing-stripe/index.ts` L117–135 | **`trialDays`** / subscription trial not passed from plan DB fields | +| REQ-016 | MET | `useCustomerPortal.ts` | Opens portal URL via Edge | +| REQ-017 | MET | `billing-stripe/index.ts` L236–238 | Sets **`cancel_at_period_end`** | +| REQ-018 | MISSING | `billing-stripe/index.ts` L242–261 | Implemented server-side **only**; **no client hook** exposing immediate cancel | +| REQ-019 | PARTIAL | `usePlanCatalog.ts` | Public plans covered; **no dedicated `getPlan(planId)` helper** beyond catalog filtering | +| REQ-020 | DIVERGENT | `PricingTable.types.ts` L1–6`, `PricingTable.web.tsx` | Props **`onSelectPlan`, `highlightCurrent`** vs spec **`productId`, `currentUserId`, `highlightPlanId`, `onCheckout`** (`beakerstac-billing-v1.md` L359–367`) | +| REQ-021 | DIVERGENT | `UpgradePrompt.types.ts` | **`targetTier`** vs **`suggestedPlanId`** (`beakerstac-billing-v1.md` L376–384`) | +| REQ-022 | PARTIAL | `UsageIndicator.web.tsx`, `BillingProvider.tsx` | Visualization MET for indicators; **usage aggregates do not use realtime channel** — refresh mostly subscription-driven / explicit refresh paths | +| REQ-023 | MET | `SubscriptionStatus.*` | Present | +| REQ-024 | MET | `CustomerPortalLink.*` | Present | +| REQ-025 | DIVERGENT | `FeatureGate.types.ts` | Prop **`feature`** vs snippet **`featureName`** (`beakerstac-billing-v1.md` L428–434`) | +| REQ-026 | MET | `scripts/sync-billing-stripe.mjs` | Idempotent Stripe price linkage pattern implemented | +| REQ-027 | MET | `BillingProvider.tsx` L104–112 | Calls ensure RPC on auth | +| REQ-028 | MET | `billing_usage_period` SQL | Free calendar month vs subscription periods encoded | +| REQ-029 | MET | `stripe-webhook/index.ts` | Listed handlers implemented (`checkout.session.completed`, subscription updated/deleted, trial signal noop L250–252`, invoices L254–288`) | +| REQ-030 | MET | `stripe-webhook/index.ts` L83–141`, L106–114 | Signature verification + dedupe | +| REQ-031 | PARTIAL | Docs + Edge | **`STRIPE_PUBLISHABLE_KEY`** not consumed in reviewed Edge Functions — acceptable if interpreted as **client-only**, but strict literal REQ only partly evidenced in repo scope audited | +| REQ-032 | MET | App billing config | Product vocabulary lives in app layer | +| REQ-033 | MET | Docs + `/billing` routes | Appendix demo folder replaced; `/billing` exercises flows | +| REQ-034 | MET | SQL RPCs | Matches gated-template RPC design | +| REQ-035 | DIVERGENT | `billing-testing.md`, `billing-demo.md` | Appendix mandates **`apps/web/docs/billing-demo.md`** as primary CLI doc surface (`beakerstac-billing-v1.md` L837–843`); repo **consolidated into `billing-testing.md`** with **`billing-demo.md` deprecated stub** (`billing-demo.md` L1–16`) | +| REQ-036 | MET | `App.tsx` L38–50 | Matches ProtectedRoute + nested Billing layout pattern | +| REQ-037 | MET | `BillingTabs.web.tsx` | Tab routes implemented | +| REQ-038 | MET | `UserMenu.web.tsx` L76–97 | Order Profile → Billing → Dashboard; CreditCard used | +| REQ-039 | PARTIAL | `BillingOverviewPage.tsx` | Structure broadly matches; **cancelled-pending lacks explicit “reactivate” button** — copy routes users to portal (`BillingOverviewPage.tsx` L131–137`) vs UI spec matrix expecting **reactivate control** (`beakerstack-billing-ui-v1.md` L281`) | +| REQ-040 | MET | `BillingUsagePage.tsx` + billing components | Reset semantics + meter + limits surfaces implemented (verified via imports / structure in Phase 2 survey) | +| REQ-041 | MET | `BillingPlansPage.tsx`, constraints | Cadence query params + downgrade modal + blockers present in surveyed sections | +| REQ-042 | MET | `useInvoices.ts` default **pageSize 20** (`L13–14`) | Matches “next 20” intent | +| REQ-043 | PARTIAL | `useBillingState.ts`, Overview banners | **Nine-state matrix not fully implemented**: **`downgrade_pending` never emitted** (`useBillingState.ts` L8–10`, `deriveKind` L30–77` lacks downgrade scheduling state); cancelled-pending **reactivate affordance** differs | +| REQ-044 | MET | migrations + webhook | Monthly/annual columns + invoices table | +| REQ-045 | MET | `stripe-webhook/index.ts` | Invoice lifecycle upserts | +| REQ-046 | DIVERGENT | `stripe-webhook/index.ts` L350–354 | Spec: **log for reconciliation if missing subscription row** (`beakerstack-billing-ui-v1.md` L385`). Code **`throw new Error(...)`\*\* failing webhook processing | +| REQ-047 | MET | `UsageIndicator.types.ts`, badge components | Expanded variant + SubscriptionStatusBadge shipped | +| REQ-048 | MET | `DashboardPage.tsx`, `App.tsx` | Manage billing link; no `/billing-demo` route | +| REQ-049 | AMBIGUOUS | `apps/mobile/.../BillingScreen.tsx` | Spec allows follow-up; presence of screen suggests partial parity — **full matrix/visual parity not verified** | + +--- + +### Detailed findings by severity + +#### Blocking + +None identified as absolute ship-blockers solely from static reading; **closest operational risks**: + +1. **`syncInvoiceRow` throws without subscription-customer linkage** — undermines webhook reliability vs explicit reconciliation guidance. + +```350:354:supabase/functions/stripe-webhook/index.ts + if (!userId) { + throw new Error( + `No subscription row for Stripe customer ${customerId} (invoice ${invoice.id}); retry after checkout syncs.` + ); + } +``` + +Violates UI spec language: + +> “If no subscription exists yet (rare timing edge case), **log** to `billing_webhook_events` and let a later subscription-created event reconcile.” (`beakerstack-billing-ui-v1.md` L385`) + +#### Significant + +1. **`cancelSubscriptionImmediately` not exposed in `@beakerstack/billing` hooks** despite Edge implementation (`billing-stripe` **`cancel_immediately`**). + +```242:261:supabase/functions/billing-stripe/index.ts +async function handleCancel( + admin: ReturnType, + userId: string, + body: Body +): Promise { + ... + await stripe.subscriptions.cancel(sub.stripe_subscription_id); + return jsonResponse({ ok: true }); +} +``` + +Core spec public API lists **`cancelSubscriptionImmediately`** (`beakerstac-billing-v1.md` L308–311`). + +2. **Checkout ignores configured trials** — core spec ties trials to **`trialPeriodDays`** on plans and checkout-created subscriptions (`beakerstac-billing-v1.md` L539–542`). **`billing-stripe`Checkout.Session.create** includes **no`subscription_data.trial_period_days`** (`billing-stripe/index.ts` L117–135`). + +3. **`PricingTable` API diverges from core spec** — props and UX don’t match documented component contract (`beakerstac-billing-v1.md` L359–367`vs`PricingTable.types.ts`/`PricingTable.web.tsx`). + +4. **Billing UI state matrix omits “Post-downgrade-pending” / `downgrade_pending`** — explicitly acknowledged as unimplemented in source comments: + +```8:10:packages/billing/src/hooks/useBillingState.ts + * `downgrade_pending` is reserved for a future `scheduled_plan_id` / subscription schedule; v1 does not set it. +``` + +UI spec matrix row requires Overview banner + Plans indicators (`beakerstack-billing-ui-v1.md` L283–285`). + +5. **`recordUsageEvent` metadata plumbing incomplete at hook layer** — RPC accepts **`p_metadata`** (`20250424120000_billing_v1.sql` L223–252`), hook hardcodes `{}`: + +```31:38:packages/billing/src/hooks/useRecordUsage.ts + const { error: rpcErr } = await supabase.rpc( + 'billing_record_usage_event', + { + p_product_id: config.productId, + p_event_type: meterKey, + p_quantity: quantity, + p_metadata: {}, + } + ); +``` + +Core API signature includes **`metadata?: Record`** (`beakerstac-billing-v1.md` L271–278`). + +#### Minor + +1. **Prop naming drift:** `FeatureGate` **`feature`** vs **`featureName`** (`FeatureGate.types.ts` vs `beakerstac-billing-v1.md` L428–434`); **`UpgradePrompt`** **`targetTier`** vs **`suggestedPlanId`** (`UpgradePrompt.types.ts` vs core spec). + +2. **`hasExceededLimit` / `getPublicPlans` naming:** Behavior exists under RPC / **`usePlanCatalog`**, but not as documented standalone TS surface — portability for non-React consumers weaker than spec prose. + +3. **Documentation path drift:** Appendix insists **`billing-demo.md`** as CLI guide anchor (`beakerstac-billing-v1.md` L837`). Repo intentionally moved content: + +```1:4:apps/web/docs/billing-demo.md +# Billing demo (deprecated) + +> **Replaced by** production billing at **`/billing`** ... +``` + +4. **Cancelled subscription UX:** Overview banner tells users to reactivate via portal (`BillingOverviewPage.tsx` L131–137`) rather than providing an explicit **reactivate** CTA per UI table (`beakerstack-billing-ui-v1.md` L281`). + +--- + +## Phase 5 — Reverse check (code vs spec) + +Sample of **`billing-v1` vs `main`** changes **without** a direct REQ counterpart — categorized. + +### Infrastructure / scaffolding + +- **`packages/shared` primitives** (`Button`, `Modal`, `Skeleton`) — UI spec §10 open questions anticipated primitives for billing pages (`beakerstack-billing-ui-v1.md` L428–432`). +- **CI / deploy workflows** (`\.github/workflows/*.yml`) — operational wiring for Edge Functions and previews. +- **`packages/test-utils`, coverage scripts** (`scripts/merge-coverage.js`) — test hygiene. +- **Symlinked / mirrored skill docs** (`.agents`, `.augment`, `.claude` paths in diff stat) — repo housekeeping, not billing behavior. + +### Undocumented features (relative to specs) + +- **`update_subscription` / cadence switching** via **`billing-stripe`** (`billing-stripe/index.ts` L167–215`) — **extends** core checkout/downgrade API; aligns with UI cadence spec but not enumerated as named TS functions in core billing spec §Public API. +- **Realtime subscription refresh** (`BillingProvider.tsx` L119–153`) — improves UX beyond explicit spec text. + +### Possible scope creep / ancillary changes + +- **`FormButton` refactors** (`packages/shared/src/components/forms/FormButton.*`) — adjacent UI primitive churn beyond billing-only scope (may support Modal/Button adoption). +- **`skills-lock.json`, `.changeset`** — release/process artifacts. + +--- + +## 5 — Open questions and assumptions + +1. **Whether core spec “Public API” MUST be exported verbatim as functions** vs hooks/RPC — interpreted as **functional equivalence acceptable for React monorepo**, but non-React adopters would notice gaps (`hasExceededLimit`, `cancelSubscriptionImmediately`). +2. **`stripe.listen` documentation anchor file** — appendix vs repo consolidation — treat **`billing-testing.md`** as authoritative **unless** strict appendix compliance is required. +3. **Mobile parity depth** — `BillingScreen.tsx` exists; full four-route parity vs web was **not** line-verified for this audit. + +--- + +## 1 — Executive summary + +The **`billing-v1`** branch delivers the bulk of **BeakerStack Billing v1** and **Billing UI v1**: schema/RPCs in migrations, **`stripe-webhook`** and **`billing-stripe`** Edge Functions, a substantial **`@beakerstack/billing`** hook/component library, **`/billing`** routes with Overview/Usage/Plans/Invoices, cadence-aware Stripe checkout and subscription updates, invoice mirroring, pg tests, and docs consolidated under **`apps/web/docs/billing-testing.md`**. Strong alignment exists on **Stripe-as-source-of-truth**, **free-tier ensure RPC**, **usage period logic**, **RLS posture**, **invoice upserts**, and **PlanCard/BillingTabs** UX. + +Gaps cluster around **literal API/component contracts** in the core spec (**PricingTable props**, **`UpgradePrompt`/`FeatureGate` naming**, **`trialDays` checkout**, **`cancelSubscriptionImmediately` exposure**, **`recordUsage` metadata**), **UI state completeness** (**post-downgrade-pending**, richer cancelled-pending **reactivate** affordance), and **one webhook edge-case behavior** (**invoice sync throws** vs **log-and-reconcile**). Documentation location diverges from the appendix path (**`billing-demo.md`** vs **`billing-testing.md`**) by deliberate deprecation notes rather than omission of content. diff --git a/docs/specs/beakerstack-billing-ui-v1.md b/docs/specs/beakerstack-billing-ui-v1.md new file mode 100644 index 00000000..26ba8101 --- /dev/null +++ b/docs/specs/beakerstack-billing-ui-v1.md @@ -0,0 +1,436 @@ +--- +name: BeakerStack Billing UI v1 +overview: Polish the existing developer-demo billing surface into production-quality, B2C-flavored billing pages that match the existing visual language of the BeakerStack template. Adds /billing route family with Overview, Usage, Plans, and Invoices sub-pages. Extends the data model for monthly/annual cadence and invoice history. Stays scoped to a single route family without a global settings shell. +todos: + - id: routes-and-tabs + content: Add /billing route family with BillingTabs sub-navigation, BillingProvider wrapping the route group, and avatar menu integration + status: pending + - id: db-additions + content: Add stripe_price_id_monthly/annual to billing_plans, create billing_invoices table with RLS, extend webhook handler for invoice events + status: pending + - id: invoice-page + content: Build BillingInvoicesPage with paginated table, status badges, and links to Stripe-hosted invoice/PDF URLs + status: pending + - id: overview-page + content: Build BillingOverviewPage with current plan card, payment status banner, quick stats, and primary CTAs + status: pending + - id: usage-page + content: Build BillingUsagePage with all meters, feature caps, and reset-date semantics rendered clearly + status: pending + - id: plans-page + content: Build BillingPlansPage with monthly/annual toggle, three-up plan cards, current-plan affordance, constraint warnings on downgrade + status: pending + - id: state-matrix + content: Implement the nine subscription states across all four pages with appropriate banners, badges, and inline messaging + status: pending + - id: components + content: Add new shared components (PlanCard, PlanFeatureList, ConstraintWarning, InvoiceTable, BillingTabs) at the right boundary (packages/billing vs apps/web) + status: pending +isProject: false +--- + +# BeakerStack Billing UI v1 + +This spec polishes the existing developer-demo billing surface (`/billing-demo`) into production-quality billing pages that match the BeakerStack template's existing visual language. It adds a `/billing` route family with four sub-pages, extends the data model for monthly/annual subscription cadence and invoice history, and integrates with the existing avatar menu. It deliberately does NOT introduce a settings shell — billing is a sibling route to `/profile`, following the same page-shell pattern that already works. + +## 1. Visual conventions + +**Match the existing template, do not invent a new design language.** Before implementing, the agent must inspect the current visual treatment in: + +- `apps/web/src/pages/ProfilePage.tsx` and the `ProfileHeader.web`, `ProfileStats.web`, `ProfileEditor.web` components — for card style, spacing, section headers, form treatment. +- `packages/shared/src/components/navigation/AppHeader.web.tsx` — for header height, logo placement, dropdown style. +- `packages/shared/src/components/navigation/UserMenu.web.tsx` — for menu item style, divider treatment. +- Any existing `Button`, `Input`, and `Card` primitives in `packages/shared` — reuse, do not rebuild. + +The current language reads as: white surfaces on `bg-gray-50` page background, rounded cards with subtle borders (`rounded-xl border border-gray-200 bg-white`), indigo/blue primary accent (matching the existing Sign In button), neutral gray text scale, system font stack, Lucide icons. Card padding is generous (`p-6` to `p-8`). Section headings are bold and clearly separated from body. Cards stack vertically with consistent vertical rhythm (`space-y-6` between cards). + +Billing pages MUST use the same patterns. New components added by this spec inherit these conventions; if a Tailwind class pattern shows up three times across existing components, treat it as the convention and reuse it. + +**Centered container width:** the existing pattern uses `max-w-[800px]` for profile. Billing pages with denser content (Plans three-up grid, Invoices table) may use `max-w-[1024px]` for those specific pages. Overview and Usage stay at `max-w-[800px]` to match Profile's feel. + +**Typography:** + +- Page title: `text-2xl font-bold` (matches existing pages) +- Section/card title: `text-lg font-semibold` +- Body: default (`text-base text-gray-900`) +- Secondary text: `text-sm text-gray-600` +- Tertiary/meta: `text-xs text-gray-500` + +**Color semantic mapping** (use Tailwind classes consistently): + +- Primary action: `bg-indigo-600 hover:bg-indigo-700 text-white` (matches existing Sign In) +- Destructive action: `bg-red-600 hover:bg-red-700 text-white` +- Success state: `bg-green-50 border-green-200 text-green-900` +- Warning state: `bg-amber-50 border-amber-200 text-amber-900` +- Error state: `bg-red-50 border-red-200 text-red-900` +- Info state: `bg-blue-50 border-blue-200 text-blue-900` + +## 2. Routes and navigation + +### Route inventory + +| Route | Page | Purpose | +| ------------------- | --------------------- | ---------------------------------------------------------------------- | +| `/billing` | `BillingOverviewPage` | Current plan, payment status, quick stats, primary CTAs | +| `/billing/usage` | `BillingUsagePage` | Detailed meters and feature caps with reset semantics | +| `/billing/plans` | `BillingPlansPage` | Update plan, monthly/annual toggle, downgrade with constraint warnings | +| `/billing/invoices` | `BillingInvoicesPage` | Paginated invoice history with links to Stripe-hosted invoice/PDF URLs | + +All four routes are protected via `ProtectedRoute.web`. All four are wrapped in a single `` at the route-group level in `App.tsx` so the provider hydrates once and the four pages share state. The provider is added in `App.tsx` like: + +```tsx +}> + + + + } + > + } /> + } /> + } /> + } /> + + +``` + +The legacy `/billing-demo` route stays in place during migration and is removed once `/billing` ships. The "Billing demo" link on the dashboard is replaced with a "Manage billing" link to `/billing`. + +### Sub-navigation: BillingTabs + +Every `/billing/*` page renders a shared `` component immediately below the page title and above the page body. It contains four tabs: **Overview**, **Usage**, **Plans**, **Invoices**. The active tab is highlighted using the existing accent color. Tabs are React Router `` elements so the active state is driven by the URL. + +Tab bar visual treatment: horizontal row, underlined active tab in indigo, inactive tabs in `text-gray-600`. Spacing: `border-b border-gray-200` along the bottom, tabs have `px-4 py-3`. Mobile (< 640px): tabs become horizontally scrollable with no wrap. + +``` +┌─────────────────────────────────────────────────────┐ +│ Billing │ +│ │ +│ Overview · Usage · Plans · Invoices │ +│ ─────── │ +│ │ +│ [page content] │ +└─────────────────────────────────────────────────────┘ +``` + +### Avatar menu integration + +Update `UserMenu.web.tsx` and `UserMenu.native.tsx` to insert a **Billing** item. Final menu order: + +``` +test@local.dev (header) +───────── +Profile +Billing ← new +Dashboard +───────── +Sign Out +``` + +Billing icon: Lucide `CreditCard`. Profile keeps its existing icon. Dashboard keeps its existing icon. The Billing item links to `/billing` (Overview). + +## 3. Page specifications + +### 3.1 BillingOverviewPage (`/billing`) + +**Purpose:** glanceable summary. The user lands here from the avatar menu and sees their current state in one screen without scrolling. Decisions to take action (change plan, fix payment, view invoices) are one click away. + +**Layout (top to bottom):** + +1. Page header: "Billing" title, BillingTabs. +2. **Status banner** (conditional, see state matrix below). Examples: "Your payment failed on Apr 23. Update your card to keep your subscription active." with a "Update payment method" link to Stripe portal. Uses `Banner` component (new, see §5). +3. **Current plan card.** Large card with: + - Plan name (`text-2xl font-bold`): "Pro" + - Price + cadence: "$19/month" or "$190/year" + - Status pill: "Active" / "Cancelling Apr 25" / "Trial ends in 3 days" + - Renewal text: "Renews on May 25, 2026" or "Ends on May 25, 2026" + - Two primary buttons: "Change plan" (→ `/billing/plans`), "Manage payment & invoices" (→ Stripe portal, opens new tab) +4. **Quick stats grid** (3 columns on desktop, stacked on mobile). Each stat is a small card with label and value: + - "This month's usage": "127 of 500 AI summaries" + - "Collections": "4 of unlimited" + - "Member since": "April 2026" +5. **Recent activity card** (optional v1, recommended): list of 3 most recent invoices with date, amount, status badge, and "View all invoices →" link to `/billing/invoices`. If no invoices yet, this card is omitted entirely (don't render an empty card). + +**Free tier variation:** instead of the current plan card showing price and renewal, it shows "You're on the Free plan" with a prominent "Upgrade to Pro" CTA. Quick stats grid still renders. Recent activity card is omitted. + +**Mobile:** stats grid collapses to single column. Buttons in current plan card stack vertically. + +**Component composition:** + +- `` — new, app-level (`apps/web/src/components/billing/Banner.web.tsx`) +- `` — new, app-level +- `` — new, generic enough to live in `packages/shared` if a similar primitive exists; otherwise app-level +- `` — new, used in compact mode here and full mode in `/billing/invoices`. App-level. + +### 3.2 BillingUsagePage (`/billing/usage`) + +**Purpose:** the reference page for "what am I using and what are my limits." Less about decisions, more about awareness. This is the page a user lands on when they think "am I about to hit my limit?" + +**Layout (top to bottom):** + +1. Page header + BillingTabs. +2. **Reset period card** (small info card at top): "Your usage resets on May 25, 2026 (your next billing date)." For free users: "Your usage resets on May 1, 2026 (start of next calendar month)." +3. **Metered usage section.** Heading: "Usage". For each meter (v1 has one: AI summarize), render a ``: + - Meter name and description + - Progress bar (use existing `` from `packages/billing`, in its expanded variant) + - "127 of 500 used · resets May 25" text below + - For unlimited tiers: "127 used this period · unlimited" +4. **Feature limits section.** Heading: "Limits". For each numeric feature cap (collections, items per collection), render a row: + - Feature name + - Current usage / cap (e.g. "4 collections of unlimited", "Items per collection: 25 max") + - For caps that are at risk (>80% used), show in amber; at limit, in red. +5. **Plan features section.** Heading: "Plan features". For each boolean feature in the user's plan, render a row with feature name and a check (✓) or X icon plus "Available" / "Not available on your plan". The "Not available" rows include a small "Upgrade to unlock" link to `/billing/plans`. + +**Empty/free state:** all sections still render but show free-tier limits. There's no "this page is empty" variant. + +**Component composition:** + +- `` — exists in `packages/billing`. May need an "expanded" variant prop that includes label and reset text. +- `` — new, app-level +- `` — new, app-level + +### 3.3 BillingPlansPage (`/billing/plans`) + +**Purpose:** the decision surface. User compares plans and changes tier. This page must work both for upgrades and downgrades, must clearly mark current plan, and must warn before a downgrade that would violate current usage. + +**Layout (top to bottom):** + +1. Page header + BillingTabs. +2. **Title row:** "Choose a plan" (h2) with subtitle "Switch plans or update your billing cadence anytime." +3. **Monthly/Annual toggle** (centered, prominent). Pill toggle with "Monthly" and "Annually · Save 17%" labels. State persists in URL query param (`?cadence=annual`) so the toggle is shareable and survives refresh. +4. **Three-up plan grid:** Free, Pro, Max as `` components. Each card contains: + - Tier name (`text-xl font-semibold`) + - One-line description ("For getting started", "For active users", "For power users") + - Price block: large price number, cadence below ("/month" or "/year, billed annually") + - Primary action button (state varies, see below) + - Optional constraint warning card (see below) + - "What's included" feature list with check/X icons +5. Below grid: small text — "All plans billed in USD. Taxes calculated at checkout where applicable. Cancel anytime." + +**PlanCard button states (for each card, given current user state):** + +| User's current plan | Card represents | Button text | Action | +| ------------------- | --------------- | ------------------------------------------------------------------ | ------------------------- | +| Free | Free | "Current plan" (disabled) | none | +| Free | Pro | "Upgrade to Pro" (primary) | initiate Stripe checkout | +| Free | Max | "Upgrade to Max" (primary) | initiate Stripe checkout | +| Pro | Free | "Downgrade to Free" (secondary, opens confirm) | downgrade flow | +| Pro | Pro | "Current plan" (disabled) OR "Switch to annual" if cadence differs | toggle cadence via Stripe | +| Pro | Max | "Upgrade to Max" (primary) | Stripe upgrade | +| Max | Free | "Downgrade to Free" (secondary, opens confirm) | downgrade flow | +| Max | Pro | "Downgrade to Pro" (secondary, opens confirm) | downgrade flow | +| Max | Max | "Current plan" (disabled) OR "Switch to annual" | toggle cadence via Stripe | + +**Constraint warning:** if the current user's usage or feature consumption would exceed the target plan's limits, the target plan card shows a `` card inside it (above the button). Examples: + +- Downgrading Pro → Free: "You currently have 4 collections. The Free plan allows 2. You'll need to delete 2 collections before downgrading." +- Downgrading Max → Pro: "You're using Feature B, which is only available on Max. It will be disabled if you downgrade." +- Downgrading after exceeding metered cap mid-period: "You've used 127 AI summaries this month. The Free plan allows 30 per month." + +When constraint warning is present, the action button is disabled with text "Resolve issues to downgrade" OR enabled with text "Downgrade anyway" depending on whether the constraint is hard (numeric cap exceeded) or soft (feature usage). For v1, all constraints are hard — button is disabled. Document this in code comments so it's easy to relax later. + +**Implementation note (BeakerStack):** boolean entitlement loss is treated as a **soft** constraint (warning shown, downgrade CTA remains enabled); **collections cap, items-per-collection cap, and metered usage** against the target plan remain **hard** (CTA disabled until resolved). This supersedes the “v1 all hard” behavior for booleans only. + +**Confirmation modal for downgrades:** clicking a downgrade button opens a modal with: + +- Title: "Downgrade to [Plan]?" +- Body: explains what changes (feature loss, cap reduction, when change takes effect — typically end of current billing period) +- "Cancel" and "Confirm downgrade" buttons (the confirm is destructive-styled) + +**Cadence toggle behavior for active subscribers:** if user is on Pro Monthly and toggles to Annual view, the Pro card shows "Switch to annual" instead of "Current plan." Clicking opens a confirmation explaining proration handled by Stripe. Implementation: call existing `initiateCheckout` with the annual price ID (Stripe handles the proration on subscription update). + +**Component composition:** + +- `` — new, app-level +- `` — new, app-level (a styled list of features with check/X icons; reused from PlanCard and potentially from a future public pricing page) +- `` — new, app-level +- `` — new, app-level +- `` — new, app-level + +### 3.4 BillingInvoicesPage (`/billing/invoices`) + +**Purpose:** historical record. Users come here to find an invoice for expense reporting or to verify a charge. + +**Layout:** + +1. Page header + BillingTabs. +2. Title: "Invoices" with subtitle "Download invoices and receipts for your records." +3. **InvoiceTable** with columns: + - Date (formatted: "Apr 25, 2026") + - Description (e.g. "Pro · Monthly") + - Amount (formatted: "$19.00") + - Status (badge: "Paid" green, "Open" blue, "Failed" red, "Refunded" gray, "Void" gray) + - Actions ("View" → opens `hosted_invoice_url` in new tab; "PDF" → opens `invoice_pdf_url` in new tab) +4. **Pagination:** "Load more" button at the bottom; loads next 20. Avoids pagination controls for v1. +5. **Empty state:** if user has no invoices (free tier user, never paid): "No invoices yet. Your invoices will appear here after your first payment." with link to `/billing/plans` to upgrade. + +**Component composition:** + +- `` — new, app-level +- `` — new, generic enough that it could live in `packages/shared` (used elsewhere too in future) + +## 4. State matrix + +The four pages must handle the following subscription states correctly. Each row lists what each page shows for that state. + +| State | Overview banner | Plans page indicator | Invoices | +| ---------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- | +| **Loading** | Skeleton card | Skeleton grid | Skeleton table | +| **No subscription** (rare; should be auto-corrected to Free by `ensure_free_subscription` RPC) | Trigger ensure-free; render Free state | Free shown as current | Empty state | +| **Free** | Free plan card with upgrade CTA | Free shown as current; Pro/Max have "Upgrade" | Empty state with upgrade CTA | +| **Paid active** | Plan card with renewal date | Current plan disabled; others have switch/up/down | Full table | +| **Paid cancelled-pending** (cancelled but billing period not yet over) | Amber banner: "Your subscription is cancelled and ends [date]. Reactivate?" + reactivate button | Current plan still shows as current with "Cancelled — ends [date]" sub-label; reactivate available | Full table | +| **Payment failed** | Red banner: "Payment failed on [date]. Update payment method to avoid service interruption." with portal link | Same as paid active | Most recent invoice shows Failed status | +| **Trial active** | Blue banner: "Your trial ends on [date]. Add payment method to continue." with portal link | Current plan shown with "Trial" label | Empty or trial invoice | +| **Trial ending** (last 3 days) | Amber banner with stronger urgency copy | Same as trial active | Empty or trial invoice | +| **Post-downgrade-pending** (user requested downgrade, takes effect at period end) | Info banner: "You'll be moved to [Plan] on [date]." | Current plan shows as current with "Downgrading to [Plan] on [date]"; target plan card shows "Scheduled" instead of "Current plan" | Full table | + +The Usage page is largely state-independent — it always shows current usage against current plan limits. The only state-dependent behavior is rendering a small banner at top during payment-failed or trial-ending states reminding the user that access may change. + +## 5. New components + +### Components in `packages/billing` (generic, reusable) + +These are extensions to the existing module. No domain vocabulary, no tier-specific names. + +| Component | Purpose | Lives at | +| ----------------------------- | -------------------------------------------------------------------------------- | -------------------------------------------------- | +| `` (existing) | Add an `expanded` variant prop showing label + reset text | `packages/billing/src/components/UsageIndicator.*` | +| `` | Pill rendering Active / Cancelling / Trial / Failed states from a `Subscription` | new in `packages/billing/src/components/` | + +### Components in `apps/web/src/components/billing/` (template-level, opinionated) + +These live in the app, not the package. They're the styled, BeakerStack-flavored building blocks that forks edit directly to change look. + +| Component | Purpose | +| ------------------------------- | -------------------------------------------------------------------------------------- | +| `BillingTabs.web.tsx` | The four-tab sub-navigation across all `/billing/*` pages | +| `Banner.web.tsx` | Status banners (info/warning/error/success variants) used at top of pages | +| `CurrentPlanCard.web.tsx` | The large plan card on Overview | +| `StatCard.web.tsx` | The small quick-stat cards on Overview | +| `PlanCard.web.tsx` | One of three cards in the Plans grid | +| `PlanFeatureList.web.tsx` | Check/X feature list inside PlanCard | +| `CadenceToggle.web.tsx` | Monthly/Annual pill toggle | +| `ConstraintWarning.web.tsx` | The yellow callout shown inside a plan card when downgrade would violate current usage | +| `ConfirmDowngradeModal.web.tsx` | Modal opened by downgrade actions | +| `InvoiceTable.web.tsx` | The paginated invoice table | +| `StatusBadge.web.tsx` | Generic status pill (could be promoted to `packages/shared` if reused) | +| `FeatureLimitRow.web.tsx` | One row in the Usage page's feature limits section | +| `PlanFeatureRow.web.tsx` | One row in the Usage page's plan features section | + +For each `.web.tsx`, ship a `.native.tsx` sibling for mobile parity. Shared logic and prop types go in a sibling `.ts` file. + +**Boundary rationale:** anything that needs to look generically "billing-y" with no design opinion stays in `packages/billing`. Anything that has BeakerStack-specific styling, copy patterns, or layout opinions lives in `apps/web`. The test: a fork that wants a totally different look should be able to rewrite all of `apps/web/src/components/billing/` without touching `packages/billing`. + +## 6. Data model additions + +### `billing_plans` table + +Add two columns: + +```sql +ALTER TABLE billing_plans + ADD COLUMN stripe_price_id_monthly text, + ADD COLUMN stripe_price_id_annual text; +``` + +Migrate any existing `stripe_price_id` data into `stripe_price_id_monthly` and drop the old column once migration is verified. The existing `billing_subscriptions.stripe_price_id` (which records what the user actually subscribed to) is unchanged — that field is the source of truth for "what cadence is this user on." + +**Plan selection logic:** "what plan is this user on" resolves by joining `billing_subscriptions.stripe_price_id` against either `billing_plans.stripe_price_id_monthly` or `billing_plans.stripe_price_id_annual`. The plan row is the same regardless of cadence. Cadence is derived from which column matched. + +**Sync script update:** `scripts/sync-billing-stripe.mjs` (or the package equivalent) must be updated to ensure both monthly and annual Stripe Price objects exist for each paid plan and to populate both columns. + +### `billing_invoices` table (new) + +```sql +CREATE TABLE billing_invoices ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + stripe_invoice_id text NOT NULL UNIQUE, + stripe_customer_id text NOT NULL, + stripe_subscription_id text, + amount_due integer NOT NULL, -- cents + amount_paid integer NOT NULL, -- cents + currency text NOT NULL, + status text NOT NULL, -- paid, open, void, uncollectible, draft + description text, + hosted_invoice_url text, + invoice_pdf_url text, + period_start timestamptz, + period_end timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + finalized_at timestamptz, + paid_at timestamptz +); + +CREATE INDEX idx_billing_invoices_user_id_created ON billing_invoices(user_id, created_at DESC); +CREATE INDEX idx_billing_invoices_stripe_id ON billing_invoices(stripe_invoice_id); +``` + +**RLS:** + +- `SELECT` policy: `auth.uid() = user_id` (users see their own invoices only) +- No client `INSERT` / `UPDATE` / `DELETE` — webhook handler writes via service role only + +### Webhook handler additions + +Extend `supabase/functions/stripe-webhook` to handle these additional events idempotently (using the existing `billing_webhook_events` log for dedup): + +- `invoice.created` → upsert `billing_invoices` row +- `invoice.finalized` → update row, set `finalized_at`, populate `hosted_invoice_url` and `invoice_pdf_url` +- `invoice.paid` → update status to `paid`, set `paid_at`, `amount_paid` +- `invoice.payment_failed` → update status, do NOT mark subscription as failed yet (Stripe handles dunning); flag for UI banner via subscription status update +- `invoice.payment_succeeded` → already handled by existing subscription update path; ensure invoice row consistent +- `invoice.voided` → update status to `void` + +Each handler resolves `user_id` by looking up `stripe_customer_id` against `billing_subscriptions`. If no subscription exists yet (rare timing edge case), log to `billing_webhook_events` and let a later subscription-created event reconcile. + +## 7. Non-goals (v1) + +These are explicitly out of scope. Document them in code comments where the temptation to build them might arise, so future contributors know they were deliberately deferred. + +- **Native payment method editing.** Always link out to Stripe customer portal for card updates. The `hosted_invoice_url` and Stripe portal handle every payment-method-adjacent flow. +- **PDF invoice generation.** We use Stripe's `invoice_pdf_url` directly. Don't generate PDFs in BeakerStack. +- **Native invoice itemization.** The invoice table shows summary; users click "View" to see Stripe-hosted line items. +- **Team / seats / per-user pricing.** Not part of v1. The data model assumes one subscription per user. +- **Proration preview.** Stripe handles proration server-side at upgrade/downgrade time. We don't compute or display previews; we trust Stripe. +- **Tax handling beyond what Stripe Tax provides.** If Stripe Tax is enabled at the account level, it's automatic. We don't render tax breakdowns in our UI. +- **Settings shell.** Profile and Billing remain sibling top-level routes. When a third or fourth settings section is added later, revisit consolidating into `/settings/*` with a shell. +- **Public pricing page.** `` and `` are designed for reuse on a future public `/pricing` route, but that route is not built in v1. The components must accept a prop indicating "logged out" mode where button actions go to signup instead of checkout, but we do not wire the public route in this spec. +- **Coupons, promo codes, referrals.** All handled via Stripe directly if needed; no UI in v1. +- **Usage-based metered billing in Stripe.** Our metered features track usage in our DB and gate access; we do not report usage to Stripe Meters in v1. +- **Multi-currency UI.** Display amounts in the currency stored on the invoice (Stripe-controlled). We don't convert or offer currency selection. + +## 8. Migration and rollout + +1. Ship DB migration adding `stripe_price_id_monthly`/`annual` and `billing_invoices` table; backfill existing data. +2. Ship webhook handler updates; verify with Stripe CLI against local Edge Function. +3. Ship `packages/billing` additions (`SubscriptionStatusBadge`, `UsageIndicator` expanded variant). +4. Ship `apps/web/src/components/billing/` components. +5. Ship the four pages and routes; wire `BillingProvider` to the route group. +6. Update `UserMenu` (web + native) to add Billing item. +7. Replace dashboard's "Billing demo" link with "Manage billing" → `/billing`. +8. Verify all nine states render correctly using a combination of real Stripe test events (CLI) and `simulateUpgrade` for the demo modes that don't require Stripe traffic. +9. Remove `/billing-demo` route and component once parity is verified. +10. Mirror page structure to `apps/mobile` using existing native shell patterns; mobile parity can ship in a follow-up if web ships first. + +## 9. Testing + +- **Unit:** each new component gets a basic render test verifying state-dependent UI (e.g. PlanCard renders "Current plan" when user is on that plan, "Upgrade to Pro" when not). +- **Integration:** Stripe CLI scenarios documented in `apps/web/docs/billing-testing.md`: + - `stripe trigger checkout.session.completed` → verify subscription appears, redirect handled + - `stripe trigger customer.subscription.deleted` → verify cancellation banner appears + - `stripe trigger invoice.payment_failed` → verify red banner appears on Overview + - `stripe trigger customer.subscription.trial_will_end` → verify amber trial-ending banner + - `stripe trigger invoice.paid` → verify invoice row appears +- **Manual:** walk through all nine subscription states using `simulateUpgrade` (where applicable) and Stripe CLI; verify each page renders correctly for each state. +- **Mobile:** smoke test that all four routes navigate and render core state correctly on iOS and Android once mobile parity ships. + +## 10. Open questions for the agent to flag, not decide + +- Does the existing `Button` primitive support the destructive variant needed for downgrade confirmation? If not, propose adding it to the existing primitive rather than creating a one-off button. +- Does `packages/shared` already have a `Modal` or `Dialog` primitive? Reuse if so; otherwise propose where the new `ConfirmDowngradeModal` should live. +- Is there an existing `Skeleton` loader pattern? Match it for the loading states; if none exists, propose a minimal one. + +These are flagged so the agent surfaces them in a PR description rather than guessing. diff --git a/docs/specs/beakerstack-billing-v1.md b/docs/specs/beakerstack-billing-v1.md new file mode 100644 index 00000000..2706b8aa --- /dev/null +++ b/docs/specs/beakerstack-billing-v1.md @@ -0,0 +1,873 @@ +# BeakerStack Billing v1 + +**Shared billing and entitlements infrastructure for Artificer Innovations factory products.** + +_A module within BeakerStack, open source MIT_ + +**Version:** 1.0 Draft +**Purpose:** Technical specification for minimal v1 billing features supporting B2C SaaS projects + +--- + +## Purpose + +BeakerStack Billing is a shared module within the BeakerStack full-stack template that provides subscription billing, entitlements management, and usage tracking. It's designed to serve multiple products from a single implementation, supporting the Artificer Innovations factory's need for consistent billing infrastructure across seed products. + +V1 is scoped specifically to what simple B2C SaaS products needs for launch, plus a few forward-compatible design choices that make v2 cleaner. The goal is shippable infrastructure in 3-4 weeks, not a complete billing platform. + +## Scope + +### In scope for v1 + +- Stripe integration (checkout, webhooks, customer portal) +- Subscription lifecycle management (active, past_due, canceled, trialing) +- Multiple tiers per product (free, pro, agency) +- Feature entitlements (can user access feature X at their plan Y) +- Usage tracking and limits (30 posts/month for free tier) +- Plan upgrade and downgrade flows +- Free tier support (user has plan but no Stripe subscription) +- Trial period support +- Failed payment dunning (via Stripe's built-in logic) +- Reusable React components for pricing pages and upgrade prompts +- Multi-product support (one user can have subscriptions to multiple factory products) + +### Out of scope for v1 + +- Team-based billing (seats, multiple users on one subscription) +- Usage-based billing (posts charged per unit) +- Custom enterprise pricing +- Coupons and discount codes (use Stripe's native support if needed) +- Complex proration scenarios (accept Stripe's defaults) +- Invoice customization (Stripe defaults only) +- Multi-currency support (USD only) +- Tax handling beyond Stripe's automatic tax +- Refund logic (manual via Stripe dashboard) +- Affiliate or referral tracking +- Detailed billing analytics (MRR dashboards, cohort analysis) +- Annual plan auto-renewal handling with upgrade credits + +All of these are legitimate future additions as specific products require them. V1 ships without them. + +## Architecture + +### Design principles + +**Principle 1: Stripe is the source of truth for subscription state.** + +Never maintain subscription state that contradicts Stripe. All changes originate from Stripe webhooks or from actions that immediately write to Stripe and wait for the webhook confirmation. This prevents the classic "my database says they're paid but Stripe says they canceled" bug. + +**Principle 2: Entitlements are derived, not stored directly.** + +A user's current entitlements are computed from their active subscription plus their product-level configuration. This means changing a plan's feature list automatically updates all existing subscribers without requiring database migrations. + +**Principle 3: Multi-product from day one.** + +The schema and APIs support multiple products from the start. Adding multiple simple B2C SaaS products should require zero schema changes to BeakerStack Billing. + +**Principle 4: Usage limits enforced at the application layer.** + +BeakerStack Billing tracks usage events but does not automatically prevent actions when limits are hit. Each product calls `getRemainingUsage()` and decides how to handle limit exhaustion (typically by showing an upgrade prompt, but products can implement their own logic). + +**Principle 5: Free tier is first class.** + +Free tier users aren't a lesser class of user. They have full accounts, full entitlement tracking, and full access to features their plan includes. They just don't have a Stripe subscription. The system handles this cleanly without special cases throughout the codebase. + +**Principle 6: Demo behavior lives at the application layer, not in the module.** + +The billing module itself has exactly two modes: Stripe test mode and Stripe live mode, distinguished by configuration. Demo-mode shortcuts (simulated upgrades without a Stripe roundtrip, instant tier changes for exploration, etc.) are implemented by consuming applications, not by the billing module. This keeps the module focused on real billing while still allowing demo sites like beakerstack.com to implement smooth demo experiences at their own layer. + +### System overview + +``` +┌─────────────────────────────────────────────┐ +│ Product Application │ +│ (simple B2C SaaS product, future seeds, etc.) │ +└────────────┬────────────────────────────────┘ + │ + │ API calls + ▼ +┌─────────────────────────────────────────────┐ +│ BeakerStack Billing Module │ +│ - Entitlement checks │ +│ - Usage tracking │ +│ - Subscription management │ +│ - Stripe integration │ +└────────────┬────────────────────────────────┘ + │ + ┌────┴────┐ + ▼ ▼ + ┌─────────┐ ┌──────────────┐ + │Postgres │ │ Stripe API │ + │(Supabase)│ │ │ + └─────────┘ └──────────────┘ +``` + +The module is a combination of database schema, server-side logic (Supabase Edge Functions), and client-side React components. + +## Database schema + +```sql +-- Products registered in the system +create table billing_products ( + id text primary key, -- 'seed_1', 'seed_2', etc. + display_name text not null, + description text, + created_at timestamptz default now() +); + +-- Plans within each product +create table billing_plans ( + id text primary key, -- 'seed_1_free', 'seed_1_pro', etc. + product_id text references billing_products not null, + display_name text not null, + description text, + price_cents int not null default 0, -- 0 for free plans + billing_period text not null, -- 'monthly', 'yearly', 'one_time', 'free' + stripe_price_id text, -- null for free plans + stripe_product_id text, -- null for free plans + features jsonb not null default '{}', -- feature name → true/false or limit + usage_limits jsonb not null default '{}', -- event_type → monthly limit + trial_period_days int default 0, + is_public boolean default true, -- visible in pricing page + display_order int default 0, + created_at timestamptz default now(), + updated_at timestamptz default now() +); + +-- Users' subscriptions (one row per user per product) +create table billing_subscriptions ( + id uuid primary key default gen_random_uuid(), + user_id uuid references auth.users not null, + product_id text references billing_products not null, + plan_id text references billing_plans not null, + stripe_customer_id text, + stripe_subscription_id text, -- null for free plans + status text not null, -- 'trialing', 'active', 'past_due', 'canceled', 'unpaid', 'incomplete', 'free' + current_period_start timestamptz, + current_period_end timestamptz, + cancel_at_period_end boolean default false, + canceled_at timestamptz, + trial_start timestamptz, + trial_end timestamptz, + created_at timestamptz default now(), + updated_at timestamptz default now(), + unique(user_id, product_id) -- one active subscription per user per product +); + +-- Usage events (written by products, read for limit enforcement) +create table billing_usage_events ( + id uuid primary key default gen_random_uuid(), + user_id uuid references auth.users not null, + product_id text references billing_products not null, + event_type text not null, -- product-defined, e.g. 'post_published' + quantity int default 1, + metadata jsonb default '{}', + created_at timestamptz default now() +); + +-- Pre-aggregated usage for efficient limit checks +create table billing_usage_aggregates ( + user_id uuid not null, + product_id text not null, + event_type text not null, + period_start timestamptz not null, + period_end timestamptz not null, + count int not null default 0, + primary key (user_id, product_id, event_type, period_start) +); + +-- Webhook event log for debugging and replay +create table billing_webhook_events ( + id uuid primary key default gen_random_uuid(), + stripe_event_id text unique not null, + event_type text not null, + payload jsonb not null, + processed boolean default false, + processed_at timestamptz, + error text, + created_at timestamptz default now() +); + +-- Indexes for common query patterns +create index idx_subs_user_product on billing_subscriptions(user_id, product_id); +create index idx_usage_user_product_type_time on billing_usage_events(user_id, product_id, event_type, created_at); +create index idx_aggregates_lookup on billing_usage_aggregates(user_id, product_id, event_type, period_start); +``` + +### Row Level Security policies + +Users can see their own subscriptions and usage events but cannot modify them directly. All subscription changes happen through server-side functions that validate Stripe webhook authenticity. + +```sql +alter table billing_subscriptions enable row level security; +alter table billing_usage_events enable row level security; +alter table billing_usage_aggregates enable row level security; + +create policy "Users see own subscriptions" + on billing_subscriptions for select + using (user_id = auth.uid()); + +create policy "Users see own usage" + on billing_usage_events for select + using (user_id = auth.uid()); + +create policy "Users see own aggregates" + on billing_usage_aggregates for select + using (user_id = auth.uid()); +``` + +Products, plans, and webhook events are readable by authenticated users but only writable through server-side administrative functions. + +## Public API + +The module exposes a TypeScript API consumed by product applications. + +### Core entitlement functions + +```typescript +// Check if a user can access a specific feature in a product +async function canUserAccessFeature( + userId: string, + productId: string, + featureName: string +): Promise; + +// Get the current plan for a user + product +async function getUserPlan( + userId: string, + productId: string +): Promise; + +// Get a specific feature value (could be boolean or numeric limit) +async function getFeatureValue( + userId: string, + productId: string, + featureName: string +): Promise; + +// Get remaining usage for a specific event type in the current period +async function getRemainingUsage( + userId: string, + productId: string, + eventType: string +): Promise<{ + used: number; + limit: number | null; // null means unlimited + remaining: number | null; // null means unlimited + periodEnd: Date; +}>; + +// Check if user has exceeded their limit for an event type +async function hasExceededLimit( + userId: string, + productId: string, + eventType: string +): Promise; +``` + +### Usage tracking + +```typescript +// Record a usage event +async function recordUsageEvent( + userId: string, + productId: string, + eventType: string, + quantity?: number, + metadata?: Record +): Promise; +``` + +### Subscription management + +```typescript +// Initiate Stripe checkout for a plan upgrade +async function initiateCheckout( + userId: string, + productId: string, + planId: string, + options?: { + successUrl?: string; + cancelUrl?: string; + trialDays?: number; + } +): Promise<{ checkoutUrl: string; sessionId: string }>; + +// Get Stripe customer portal URL +async function getCustomerPortalUrl( + userId: string, + returnUrl?: string +): Promise; + +// Schedule move to free at end of billing period (Stripe cancel_at_period_end; not immediate). +// Edge function `billing-stripe` JSON body: action `schedule_cancel_to_free`. +async function scheduleCancelToFree( + userId: string, + productId: string +): Promise; + +// Cancel subscription immediately (not at period end) +async function cancelSubscriptionImmediately( + userId: string, + productId: string +): Promise; +``` + +### Plan information + +```typescript +// Get all public plans for a product +async function getPublicPlans(productId: string): Promise; + +// Get a specific plan by ID +async function getPlan(planId: string): Promise; +``` + +## Stripe webhook handler + +A single edge function handles all Stripe webhook events for billing. + +```typescript +// supabase/functions/stripe-webhook/index.ts + +Events handled: +- checkout.session.completed: create subscription record, set status +- customer.subscription.updated: update subscription status, period dates +- customer.subscription.deleted: mark as canceled +- customer.subscription.trial_will_end: trigger notification (email sent by product) +- invoice.payment_failed: update status to past_due, trigger notification +- invoice.payment_succeeded: log successful payment, reset dunning +``` + +Each webhook: + +1. Verifies Stripe signature +2. Logs event to `billing_webhook_events` +3. Processes event (updates subscription state) +4. Records result in webhook_events table +5. Returns 200 to Stripe + +Idempotent: processing the same event twice has no effect. Important because Stripe retries webhooks. + +## React components + +Reusable UI components that products import. + +### PricingTable + +Displays all public plans for a product with upgrade CTAs. + +```tsx + { + /* custom handler */ + }} +/> +``` + +Renders a pricing comparison. Shows current plan for logged-in users. Upgrade CTAs open Stripe checkout via `initiateCheckout`. + +### UpgradePrompt + +Shown when a user hits a usage limit or tries to access a gated feature. + +```tsx + { + /* after successful upgrade */ + }} +/> +``` + +### UsageIndicator + +Shows current usage vs limit for a specific event type. + +```tsx + +``` + +Renders a progress bar or similar visualization. Updates in real time as usage changes. + +### SubscriptionStatus + +Displays current subscription info with management links. + +```tsx + { + /* opens customer portal */ + }} +/> +``` + +### CustomerPortalLink + +Simple link that opens the Stripe customer portal. + +```tsx + + Manage subscription + +``` + +### FeatureGate + +Conditionally renders content based on feature entitlement. + +```tsx +} +> + + +``` + +## Configuration approach + +Each product configures its plans via a declarative config file that seeds the database. + +```typescript +// products/seed_1/billing-config.ts +export const poststackBillingConfig: ProductBillingConfig = { + productId: 'seed_1', + displayName: 'Seed 1', + description: 'Precision cross-posting for multi-brand operators', + plans: [ + { + id: 'seed_1_free', + displayName: 'Free', + priceCents: 0, + billingPeriod: 'free', + features: { + workspaces_max: 2, + accounts_per_workspace_max: 3, + analytics_basic: true, + analytics_advanced: false, + api_access: false, + }, + usageLimits: { + post_published: 30, // per month + }, + isPublic: true, + displayOrder: 1, + }, + { + id: 'seed_1_pro', + displayName: 'Pro', + priceCents: 1900, + billingPeriod: 'monthly', + stripePriceId: 'price_...', + features: { + workspaces_max: -1, // unlimited + accounts_per_workspace_max: -1, + analytics_basic: true, + analytics_advanced: true, + api_access: true, + }, + usageLimits: {}, // no limits + trialPeriodDays: 0, + isPublic: true, + displayOrder: 2, + }, + { + id: 'seed_1_agency', + displayName: 'Agency', + priceCents: 4900, + billingPeriod: 'monthly', + stripePriceId: 'price_...', + features: { + workspaces_max: -1, + accounts_per_workspace_max: -1, + analytics_basic: true, + analytics_advanced: true, + api_access: true, + team_members_max: 5, + white_label: false, // v2 + }, + usageLimits: {}, + trialPeriodDays: 5, + isPublic: true, + displayOrder: 3, + }, + ], +}; +``` + +A setup script reads this config and creates/updates the corresponding Stripe products, prices, and database records. Running the script is idempotent. + +## Free tier handling + +When a user signs up without selecting a plan: + +1. User account is created via Supabase Auth +2. On first product access, check for subscription record +3. If none exists, auto-create a free tier subscription +4. Free subscription has `status = 'free'` and no Stripe subscription ID +5. Entitlement checks work normally against the free plan's features + +When a free user upgrades: + +1. Initiate checkout via `initiateCheckout` +2. Stripe handles payment +3. Webhook fires on success +4. Free subscription is updated to paid plan, status becomes `active` or `trialing` +5. Stripe customer and subscription IDs are recorded + +When a paid user schedules cancel to free: + +1. `scheduleCancelToFree` is called (or `billing-stripe` with `action: 'schedule_cancel_to_free'`) +2. Stripe subscription is set to `cancel_at_period_end` +3. Paid plan entitlements continue until the current billing period ends +4. At period end, webhook fires; subscription is transitioned to the free plan +5. Free plan limits apply from that point forward + +## Trial handling + +Trials are managed by Stripe. When a plan has `trialPeriodDays > 0` (stored on `billing_plans.trial_period_days` and passed as `subscription_data.trial_period_days` when creating the Checkout Session): + +1. Checkout creates a subscription with a trial period +2. Stripe sets `trial_end` based on configuration +3. Webhook updates our subscription record with trial dates +4. Entitlements work normally during trial (user gets paid plan features) +5. Stripe emits `customer.subscription.trial_will_end` before trial end. **v1 default:** the billing module does not require in-app “trial ending soon” messaging; Stripe attempts conversion to paid at `trial_end` when a default payment method exists. Optional product email or analytics may subscribe to `trial_will_end` outside this module. +6. At trial end, Stripe attempts the first paid invoice; on success the subscription becomes `active`. If the customer cancels during trial, payment cannot be collected, or the subscription is otherwise removed, the user may return to Free via webhooks without going through the interactive downgrade flow (see **Involuntary downgrade to Free**). + +Users can cancel during trial via customer portal without charge. + +## Involuntary downgrade to Free (grace + remediation) + +This applies when `billing_subscriptions.plan_id` becomes the **free** plan because of Stripe lifecycle events **without** the user completing the Plans-page downgrade flow—for example: subscription deleted after trial without conversion, cancellation, or other webhook-driven transitions to Free. + +**v1 product policy** + +1. **No automatic deletion** of customer content to satisfy Free limits. +2. **Remediation:** the product should **block new** actions that would exceed Free limits (meters, boolean features, or structural caps such as collections/items) until the user reduces usage or data to within Free limits **or** upgrades again. Acceptable patterns include read-only mode, blocking creates only, or explicit “resolve overage” UI with links to delete data and/or billing. +3. **User-initiated downgrade** (Plans page, `computeDowngradeBlockers`) remains the path where the app can preflight hard blockers before the user commits; involuntary Free **does not** run that preflight. + +**Metered usage caveat** + +`billing_get_remaining_usage` scopes “used” counts to `billing_usage_period`, which switches from the Stripe subscription period to a **calendar month** when the row becomes Free. Aggregate rows keyed to the previous billing period may no longer match the new period window, so displayed “used” for a meter can appear to **reset** until new events accrue in the calendar month. Strict cross-period overage display is **follow-up** work if the product requires it. + +## Usage limit enforcement + +Products call `recordUsageEvent` when a limit-affecting action occurs (e.g. `post_published`). + +The module: + +1. Records the event in `billing_usage_events` +2. Updates the current period's `billing_usage_aggregates` record +3. Returns immediately + +Separately, products call `hasExceededLimit` before allowing limit-affected actions: + +```typescript +// In Seed 1's publish logic +const limitExceeded = await hasExceededLimit(userId, 'seed_1', 'post_published'); +if (limitExceeded) { + throw new UsageLimitExceededError('Monthly post limit reached. Upgrade to Pro for unlimited.'); +} +// Proceed with publishing +await publishPost(...); +await recordUsageEvent(userId, 'seed_1', 'post_published'); +``` + +Products are responsible for handling the limit exceeded case (typically by showing an upgrade prompt). + +### Period boundaries + +Usage periods align with subscription billing periods. For monthly plans, the period is the current billing month. For users on free tier, the period is calendar month (Jan 1 to Jan 31, Feb 1 to Feb 28, etc.). + +At period start, aggregates reset for limit-enforcement purposes. Historical events remain in `billing_usage_events` for analytics. + +## Error handling + +**Stripe API errors:** returned to caller with clear error messages. Don't leak Stripe-specific details to end users. + +**Webhook failures:** logged to `billing_webhook_events` with error details. Can be replayed manually if needed. + +**Race conditions:** use database transactions where state must be consistent. Stripe is the source of truth; if our state diverges, a reconciliation job (future work) can detect and correct. + +**Missing subscriptions:** if a user has no subscription record for a product, auto-create a free tier subscription. Fails safely toward "user has free access." + +**Expired trials with no payment method:** Stripe handles this; webhook updates subscription to `incomplete` or `canceled`. User sees prompt to add payment method. + +## Security considerations + +**Webhook verification:** all incoming Stripe webhooks validate signature. Unsigned requests rejected. + +**Customer portal:** uses Stripe's hosted customer portal. No sensitive payment data touches our servers. + +**Plan configuration:** only editable by administrators, not by end users. No UI for users to modify plans. + +**Usage events:** users can see their own usage but not modify it. Recording events happens server-side. + +**Cross-product isolation:** RLS ensures users only see data for products they have subscriptions to. + +## Stripe modes and demo handling + +The billing module supports Stripe's two standard modes (test and live) through environment variable configuration. Demo-mode behavior for showcase sites is implemented at the application layer, not in the module. + +### Environment configuration + +The module reads Stripe credentials from environment variables: + +``` +STRIPE_PUBLISHABLE_KEY=pk_test_... (or pk_live_...) +STRIPE_SECRET_KEY=sk_test_... (or sk_live_...) +STRIPE_WEBHOOK_SECRET=whsec_... +``` + +Test mode keys (prefixed `pk_test_` and `sk_test_`) hit Stripe's test infrastructure. No real charges occur. Test card numbers like 4242 4242 4242 4242 produce successful test transactions. + +Live mode keys (prefixed `pk_live_` and `sk_live_`) hit Stripe's production infrastructure. Real charges occur. Real credit cards are required. + +The module itself is agnostic to which mode is configured; Stripe handles the distinction. Applications using the module just need to ensure they've configured the correct mode for their environment. + +### Recommended usage pattern + +- **Development and staging environments:** always use test mode +- **Production deployments of real products:** use live mode +- **Demo sites showcasing BeakerStack capabilities:** always use test mode, never live mode +- **Never mix modes within an environment:** test subscriptions cannot be converted to live subscriptions + +A misconfiguration where a production environment accidentally uses test keys results in payments failing silently (Stripe rejects the test keys for live transactions or processes them as test). The inverse (test environment using live keys) is worse: real charges could occur in a demo or development context. Guard against this with environment variable management discipline. + +### Application-layer demo mode (for demo sites) + +Consuming applications that want to demonstrate billing capabilities without requiring visitors to enter test card numbers should implement demo-mode shortcuts at the application layer. The billing module does not provide demo mode itself; this is deliberate to keep the module focused on real billing. + +A typical demo-mode implementation at the application layer: + +- Application reads a `DEMO_MODE` flag from its own environment or config +- When `DEMO_MODE=true`, upgrade buttons in the pricing UI bypass Stripe checkout and directly update the user's subscription record via a server-side function +- The direct update uses a service role Supabase client to change the subscription's `plan_id` and `status` fields +- Usage limits and entitlements then work normally against the "upgraded" subscription +- An optional "see real checkout" alternate path invokes the normal Stripe test mode flow for visitors who want to see it + +This pattern lets demo sites show clean instant upgrades by default while preserving access to the real checkout flow for developers evaluating the implementation. + +### beakerstack.com specific behavior + +The BeakerStack demo site at beakerstack.com implements application-layer demo mode as described above. Specifically: + +- Stripe test mode is configured (test keys only, never live) +- Default upgrade path shortcuts Stripe entirely for faster exploration +- Alternate "see real checkout" path runs the full Stripe test mode flow with test card numbers displayed +- Persistent demo banner visible across all pages +- Demo accounts accumulate without automatic reset (deferred; manual cleanup as needed) +- README documents the demo vs production configuration clearly + +This approach serves as the reference implementation for other BeakerStack users who want to build their own demo sites. + +### Mode switching for new BeakerStack users + +When a developer clones BeakerStack to build their own product, the README walks them through: + +1. Creating a Stripe account (free) +2. Obtaining test mode API keys +3. Configuring test keys in development environment +4. Creating test products and prices in Stripe +5. Mapping those to plans in their product's billing config +6. Testing the full subscription flow with test cards +7. When ready for production, obtaining live mode keys and switching configuration + +The mode switch itself is a configuration change, not a code change. The billing module behaves identically in both modes. + +## Open source considerations + +Because BeakerStack is open source MIT, BeakerStack Billing is also open source. This means: + +**Generic enough for others to use.** Don't hardcode Artificer Innovations specifics. The module should work for any Stripe-based SaaS using Supabase. + +**Clear documentation.** README explains setup: creating Stripe account, configuring webhook, adding plans, integrating with an app. + +**Example configuration.** Include a sample product configuration (could be for a hypothetical "ExampleApp") so new users see how to structure their own products. + +**No secret configuration leaks.** Stripe keys and similar secrets live in environment variables, never in the repo. + +**Contribution posture:** accept PRs for bug fixes and additional platform support. New major features discussed via issue first. + +## Migration and compatibility + +V1 has no migration concerns because it's the first version. Future versions will handle: + +**Schema migrations:** Supabase migration scripts, forward-compatible where possible. + +**API compatibility:** TypeScript function signatures preserved; new features added through additional parameters or functions. + +**Plan changes:** existing subscriptions preserved when plan definitions change; users on legacy plans continue on those plans until they upgrade or downgrade. + +## Timeline estimate + +**Week 1: Foundation and schema.** + +- Schema design and migration scripts +- Stripe account setup and test products +- Basic module structure and types + +**Week 2: Core functions.** + +- Subscription management functions +- Entitlement check functions +- Usage tracking functions +- Webhook handler for critical events + +**Week 3: UI components and product integration.** + +- React components (PricingTable, UpgradePrompt, etc.) +- Integration with Seed 1 (first real consumer) +- Configuration system for products + +**Week 4: Polish and edge cases.** + +- Trial handling refinement +- Error handling and edge cases +- Documentation +- Testing across subscription lifecycle scenarios + +Total: 3-4 weeks of focused work. Parallelizable with some of Seed 1's non-billing work. + +## Success criteria for v1 + +V1 is successful if: + +1. Seed 1 launches with free, Pro, and Agency tiers working correctly +2. Users can sign up for free tier, upgrade to Pro, downgrade, cancel +3. Usage limits enforce correctly on free tier +4. Trial periods work as configured +5. Stripe webhooks handle standard subscription lifecycle events +6. No data loss or state inconsistency between Stripe and our database over first 30 days of operation +7. Module is documented well enough that a future factory seed can integrate it in under a day + +V1 is not trying to be a complete billing platform. It's trying to support Seed 1 reliably while being forward-compatible with future factory products. + +## What comes after v1 + +Likely v1.5 or v2 additions based on future product needs: + +- Usage-based billing (charge per unit of usage) +- Team seats and multi-user subscriptions +- Custom enterprise pricing workflows +- More sophisticated trial-to-paid conversion flows +- Advanced analytics and reporting +- Multi-currency support +- Tax handling (beyond Stripe's automatic) +- Webhooks emitted to consumer apps for subscription events +- Admin dashboard for support team use + +Each of these is a real need but not for Seed 1 v1 launch. Build them when a specific product requires them. + +### Demo infrastructure improvements (deferred from v1) + +Related to demo handling, some items deferred from v1 that will need attention as beakerstack.com and similar demo sites scale: + +- Automatic demo account data reset (nightly truncation or inactivity-based cleanup). V1 handles this manually; automation becomes important when demo data volume grows. +- Demo account lifecycle policies (time-limited accounts, session-based accounts, anonymous exploration without signup). Different demo experiences may want different behaviors. +- Reset orchestration across products (if multiple factory products share infrastructure, coordinated resets matter). +- Analytics separation for demo vs real usage (so product metrics don't include demo site noise). + +These are beakerstack.com and future-demo-site concerns rather than billing module concerns specifically, but worth tracking here because they interact with billing state. + +--- + +## Appendix: BeakerStack open-source template (billing v1 alignment) + +This section extends the billing spec for the **BeakerStack** monorepo: a production-quality `packages/billing` module plus a **template-level** B2C demo. It does not change the core architectural principles above; it constrains how the factory ships the module and how the template validates it. + +### Template goals + +1. Ship a production-quality `packages/billing` module per this document’s core architecture. +2. Build a meaningful B2C-flavored demonstration in `apps/web` (and mirror key pieces in `apps/mobile`) that exercises enough of the module surface to genuinely validate it, not just render a pricing table. +3. Draw a clean, explicit line between the reusable billing module and the BeakerStack template app layer. + +### Tier structure: Free / Pro / Max (template demo) + +The template demo ships three tiers with a consumer-SaaS aesthetic. The **structural shape** matches multi-workspace products: parent container cap, items per container cap, metered monthly action, and boolean upgrades — without prescribing a sepecifc product domains. + +| Entitlement | Free | Pro | Max | +| ----------------------- | ---- | --------- | --------- | +| Containers per account | 2 | unlimited | unlimited | +| Items per container | 3 | 25 | unlimited | +| Metered action / period | 30 | 500 | unlimited | +| Boolean feature A | off | on | on | +| Boolean feature B | off | off | on | + +Plan ids (e.g. `beakerstack_free`, `beakerstack_pro`, `beakerstack_max`) and Stripe price mappings are **owned by the template app**, not hardcoded in `packages/billing`. + +### Demo domain (neutral B2C) + +**Collections** of **saved items**, with a metered **“AI summarize”** action. + +**Rationale:** “Collection” / “saved item” maps directly to hierarchical caps (containers and items), reads as neutral B2C product surface (similar affordances to Readwise-style saving), and does not imply a specific Artificer shipping product. Alternates with the same entitlement shape: projects/tasks with “AI breakdown,” or notebooks/notes with “AI rewrite.” + +### Three entitlement surfaces on one route + +All three shapes are reachable from a single **`/billing-demo`** route (or a settings section that hosts the same component tree). + +1. **Metered action surface** — Wired to `hasExceededLimit`, `recordUsageEvent`, `UsageIndicator`, and `UpgradePrompt` at the boundary. Must visibly show **reset-date semantics**: Free = **calendar month**; paid = **billing period** (from `getRemainingUsage` / `periodEnd`). +2. **Numeric feature value surface** — Uses `getFeatureValue` for **containers per account** and **items per container**. Does **not** use the usage events table for those caps. Attempting to exceed limits shows an **inline** limit message with upgrade CTA. +3. **Boolean feature gate surface** — Uses `FeatureGate` for features A and B, with `UpgradePrompt` fallback that targets the **minimum** tier required (A → Pro, B → Max). Tier resolution is supplied by the **app** (props or app helper), not baked into the package. + +### Module vs app boundary + +| Concern | `packages/billing` | `apps/web` / `apps/mobile` (template) | +| --------------------------------------------------------------------- | ------------------ | --------------------------------------- | +| Types; Zod schema for **config shape** | Yes | Provides concrete config | +| Client API; hooks; UI (web + native) | Yes | Wires routes, copy, ids | +| Stripe: checkout, portal, schedule cancel to free, cancel immediately | Yes | URLs, env, product id | +| Tier names, marketing copy, domain vocabulary | No | Yes | +| Feature keys, metered `eventType` strings | No | Yes | +| `simulateUpgrade` / demo usage reset | **Must not** | Yes (template-only) | +| Demo banner (“not real billing”) | No | Yes | +| `/billing-demo`, collections/items demo state | No | Yes (e.g. `apps/web/src/billing-demo/`) | + +**Principle:** A consumer may delete `apps/web/src/billing-demo/` (and mobile equivalents) and retain a fully working `@beakerstack/billing`. Adapting the demo = rename domain + swap keys, **without** editing `packages/billing`. + +### `simulateUpgrade` and demo usage reset (template only) + +- **Location:** Supabase RPCs (e.g. `billing_demo_simulate_upgrade`, `billing_demo_reset_usage`) in template-scoped migrations, **or** a dedicated Edge Function — **not** in `packages/billing`. +- **Behavior:** For `auth.uid()`, updates `billing_subscriptions` plan/status for the template product without a Stripe roundtrip; reset clears usage aggregates/events for the demo meter as defined by the template. +- **Security:** `SECURITY DEFINER` (or service role from gated Edge) with a **server-side guard** (e.g. database setting, config row, or project-only flag) so the RPC **refuses** when demo mode is off. Client may use `VITE_BILLING_DEMO_MODE` for UX only — **not** sufficient alone. +- **UX:** Clear **“Demo mode — not real billing”** labeling; optional **“See real checkout”** path using normal Stripe test flow via the billing module. + +### Webhook testing (v1) + +**Primary approach:** [Stripe CLI](https://stripe.com/docs/stripe-cli) forwarding to the **local** `stripe-webhook` Edge Function, using real Stripe payload shapes and signature verification. + +Document in **`apps/web/docs/billing-testing.md`** (canonical CLI walkthrough; **`apps/web/docs/billing-demo.md`** is a short index that points here), including exact commands for: + +- `invoice.payment_failed` (past_due / dunning behavior) +- `customer.subscription.trial_will_end` +- `customer.subscription.updated` +- `invoice.payment_succeeded` +- **Retry idempotency** (same event twice / retry simulation — no duplicate side effects; `billing_webhook_events` + subscription consistency) + +**Explicit non-goal for v1:** No admin **“simulate webhook”** UI in the template — avoids payload drift and scope creep. + +### Implementation todos (BeakerStack repo) + +When executing in BeakerStack, track at least: + +- DB migration: billing tables, RLS, core RPCs (`record_usage_event`, reads, `ensure_free`), pgTAP; ship SQL only under **`supabase/migrations/` at the repo root** (same layout for web and mobile devs — run Supabase CLI migration commands from root). +- Edge Functions: `stripe-webhook`, `billing-stripe`; secrets and CI deploy. +- `packages/billing`: generic module (no product vocabulary). +- Stripe sync script driven by **app-supplied** billing config. +- App: concrete Free/Pro/Max config; `billing-demo` route with three surfaces; mobile mirror. +- Template RPCs: `simulateUpgrade` + usage reset with env gating. +- Docs: `apps/web/docs/billing-testing.md` with Stripe CLI scenario walkthrough (`billing-demo.md` cross-links for legacy appendix references). + +The Cursor implementation plan file (`beakerstack_billing_v1_*.plan.md`) may carry the same content in execution form. diff --git a/docs/specs/beakerstack-dashboard-billing-demo-v1.md b/docs/specs/beakerstack-dashboard-billing-demo-v1.md new file mode 100644 index 00000000..bdb9dc63 --- /dev/null +++ b/docs/specs/beakerstack-dashboard-billing-demo-v1.md @@ -0,0 +1,259 @@ +--- +name: BeakerStack Dashboard Billing Demo v1 +overview: Replace the empty dashboard with an annotated playground that exercises every billing primitive in a clearly-labeled, developer-facing format. Each section demonstrates one capability of @beakerstack/billing with intro copy, working controls, visible state, and a code reference. Demo controls live at the bottom in an explicit "app layer, not package" section. Deliberately reads as a sandbox, not a fake product. +todos: + - id: dashboard-shell + content: Replace the existing dashboard placeholder with the new playground layout, framing copy, and section structure + status: pending + - id: metered-section + content: Build the metered usage section with UsageIndicator, simulate button, and visible AI summarize result area + status: pending + - id: numeric-caps-section + content: Build the numeric caps section with collections list, add-collection button, per-collection items list, and add-item buttons + status: pending + - id: boolean-gates-section + content: Build the boolean feature gates section with FeatureGate examples for Feature A and Feature B + status: pending + - id: demo-controls + content: Build the demo controls section at the bottom, gated to VITE_BILLING_DEMO_MODE, with plan switcher and usage reset + status: pending + - id: ai-summarize-result + content: Wire the metered action to produce a visible result (lorem ipsum or Claude API call) into a result area + status: pending +isProject: false +--- + +# BeakerStack Dashboard Billing Demo v1 + +The empty dashboard becomes an annotated playground that exercises every primitive in `@beakerstack/billing`. The polished `/billing/*` pages already prove the stack can produce real-feeling product UI; the dashboard's job is different — it teaches developers how the building blocks compose. The format is deliberately sandbox-like: labeled sections, intro copy, obvious controls, code references. It must NOT look like a fake product. + +## 1. Page-level framing + +Replace the current dashboard placeholder content. Keep the existing `AppHeader.web` and the `min-h-screen bg-gray-50` page-shell pattern that already wraps authenticated routes. Centered container at `max-w-[800px]` matching profile page width. + +**Top of page (above all sections):** + +- Page title: "Welcome to BeakerStack" (`text-2xl font-bold`) +- Lede paragraph: 2-3 sentences explaining what this dashboard is. Suggested copy: + + > This dashboard is a sandbox for exercising the billing primitives in `@beakerstack/billing` directly. Each section below demonstrates one capability with working controls and code references. For the polished, production-style billing UI, visit [Billing](/billing). + +- Inline links row below the lede: + - "View polished billing pages →" → `/billing` + - "Read the integration guide →" → external docs link (use `#` placeholder if no doc exists yet, with a TODO comment) + +Sections render below this framing in the order specified in §3. + +## 2. Section component pattern + +Build a reusable `` component (lives in `apps/web/src/components/dashboard/`) used by every section. Props: + +```ts +type DashboardDemoSectionProps = { + title: string; // section heading + demonstrates: string; // short list of API names being shown + description: string; // 2-3 sentence intro copy + codeReference: string; // single-line monospace code reference + children: React.ReactNode; // the actual interactive controls + variant?: 'default' | 'demo-mode'; // demo-mode gets a distinct visual treatment +}; +``` + +Visual treatment: + +- White card on `bg-gray-50` page background, matching existing card pattern (`rounded-xl border border-gray-200 bg-white p-6`). +- Title: `text-lg font-semibold`. +- "Demonstrates:" label in `text-xs uppercase tracking-wide text-gray-500`, followed by the API names in `text-sm text-gray-700 font-mono`. +- Description: `text-sm text-gray-600 mt-2`. +- Children render in a content area below description with `mt-4`. +- Code reference rendered at the bottom of the card in a small `text-xs font-mono text-gray-500` line, prefixed with `// ` to make it visually distinct as a code comment. +- Sections separated by `space-y-6` in the parent container. +- `demo-mode` variant uses a dashed border (`border-dashed border-gray-300`) and adds a small "Demo mode only" badge in the top-right corner to visually distinguish it from the package-level sections. + +## 3. Sections (in render order) + +### 3.1 Metered usage + +``` +title: "Metered usage" +demonstrates: "useUsage, useRecordUsage, UsageIndicator" +description: "The AI summarize action is metered. Free tier allows 30 per + month; Pro 500; Max unlimited. Usage resets monthly for free + users and per billing period for paid users." +codeReference: 'useRecordUsage("ai_summarize")' +``` + +**Children:** + +1. `` showing current usage, cap, and reset date. +2. A primary button: "Simulate AI summarize". On click: + - Calls `useRecordUsage("ai_summarize")` callback. + - If at cap, button shows disabled state with "Limit reached" text and a small "Upgrade" link to `/billing/plans`. + - On success, appends a fake AI summary result to a result area below the button (see below). +3. **Result area:** below the button, a bordered box with `bg-gray-50` showing the result of the most recent simulate action. Initial state: empty, with placeholder text "Click 'Simulate AI summarize' to generate a result." Each click prepends a new result with a timestamp. + +**AI summarize result content:** generate a short lorem-ipsum-style fake summary string (3-5 lines) to make the action feel real without making a real API call by default. Optionally, if `VITE_DEMO_USE_REAL_AI=true` is set, call the Claude API via an Edge Function to generate a real summary. Default to fake content so the demo works without API keys configured. Keep the last 3 results visible; older ones drop off. + +### 3.2 Numeric feature caps + +``` +title: "Numeric feature caps" +demonstrates: "useFeature with numeric values, hierarchical container caps" +description: "Free tier allows 2 collections with 3 items per collection. + Pro allows unlimited collections with 25 items each. Max + allows unlimited both. Caps are enforced via useFeature + returning a numeric value rather than going through usage + events." +codeReference: 'useFeature("max_collections")' +``` + +**Children:** + +1. Header row: "Collections: N of [limit]" where [limit] is the numeric cap or "∞" for unlimited. Inline button: "Add collection" (primary). Disabled at cap with "Limit reached" tooltip. +2. List of collections (existing data model carries over). Each collection rendered as a small card with: + - Collection ID (truncated UUID or short label) + - "Items: M of [limit]" + - Inline "Add item" button (smaller, secondary). Disabled at cap. + - Inline "Delete" button (small, destructive icon-only) — needed so users can resolve constraint warnings on the Plans page by deleting collections. +3. If user has zero collections, show empty state: "No collections yet. Click 'Add collection' to start." in `text-sm text-gray-500`. + +**Behavior:** + +- Adding a collection creates a new entry in the existing `collections` table (or whatever was used in `/billing-demo`) with a generated ID. +- Adding an item creates an entry in the items table linked to that collection. +- Deleting a collection cascades to its items. +- The cap displays update reactively as the user adds/removes; the existing `useFeature` hook should already drive this if the provider re-fetches on mutation. If not, document a manual refresh in code. + +### 3.3 Boolean feature gates + +``` +title: "Boolean feature gates" +demonstrates: "FeatureGate component, useFeature for boolean features" +description: "Feature A unlocks at Pro and above. Feature B unlocks at Max + only. Each section below shows the FeatureGate behavior at + your current plan: enabled features render their content; + disabled features render the fallback." +codeReference: '' +``` + +**Children:** + +Two stacked sub-blocks, one per feature. Each sub-block contains: + +``` +Feature A (requires Pro) +[FeatureGate renders one of:] + Enabled state: "✓ Feature A is enabled for your plan." (green text, check icon) + Disabled state: A small inline upgrade prompt — "Feature A requires Pro." + with "Upgrade →" link to /billing/plans +``` + +Same pattern for Feature B (requires Max). Use the existing `FeatureGate` component from `packages/billing` with a custom `fallback` prop containing the upgrade prompt JSX. + +Below the two sub-blocks, render a single line showing the imperative-style equivalent for developers who prefer that pattern: + +> Imperative equivalent: `const { enabled } = useFeature("feature_a")` → currently `true`/`false` + +This mirrors the live state of the gate above and demonstrates that both declarative and imperative patterns are available. + +### 3.4 Demo controls (variant: `demo-mode`) + +``` +title: "Demo controls" +demonstrates: "App-layer demo affordances (NOT in @beakerstack/billing)" +description: "These controls exist only when VITE_BILLING_DEMO_MODE=true and + demo_billing_mode is enabled in the database. They live in the + app layer using a service-role RPC and are explicitly NOT part + of @beakerstack/billing. Production apps should remove this + section." +codeReference: 'await supabase.rpc("simulate_upgrade", { plan_id })' +``` + +**Render condition:** entire section hidden unless `import.meta.env.VITE_BILLING_DEMO_MODE === "true"`. When demo mode is off, the section does not render at all (not even greyed out — completely absent). + +**Children:** + +1. Current plan display: "Current plan: [Free/Pro/Max]" in `text-sm`. +2. Plan switcher: three buttons in a row — "Switch to Free", "Switch to Pro", "Switch to Max". Active plan's button is disabled. Each button calls the `simulate_upgrade` RPC. +3. Reset usage button (separate, secondary styling): "Reset all usage counters". Calls a `reset_usage` RPC that zeros `billing_usage_aggregates` for the current user. +4. Inline note in `text-xs text-gray-500`: "These actions take effect immediately and bypass Stripe. Do not deploy to production." + +**Visual distinction:** the `demo-mode` variant of `` uses dashed borders and the "Demo mode only" badge to make this section visibly different from the three above. The reader should immediately understand "this is the test panel, not part of the framework." + +## 4. Layout summary + +``` +┌─────────────────────────────────────────────────────┐ +│ AppHeader (existing) │ +├─────────────────────────────────────────────────────┤ +│ │ +│ Welcome to BeakerStack │ +│ [lede paragraph] │ +│ [view polished billing →] [integration guide →] │ +│ │ +│ ┌─ Metered usage ─────────────────────────────────┐ │ +│ │ DEMONSTRATES: useUsage, useRecordUsage, … │ │ +│ │ [description] │ │ +│ │ [UsageIndicator] │ │ +│ │ [Simulate button] [Result area] │ │ +│ │ // useRecordUsage("ai_summarize") │ │ +│ └─────────────────────────────────────────────────┘ │ +│ │ +│ ┌─ Numeric feature caps ──────────────────────────┐ │ +│ │ … (similar structure) │ │ +│ └─────────────────────────────────────────────────┘ │ +│ │ +│ ┌─ Boolean feature gates ─────────────────────────┐ │ +│ │ … (similar structure) │ │ +│ └─────────────────────────────────────────────────┘ │ +│ │ +│ ╔═ Demo controls (dashed border, demo-mode only) ═╗ │ +│ ║ … (plan switcher, reset usage) ║ │ +│ ╚═════════════════════════════════════════════════╝ │ +│ │ +└─────────────────────────────────────────────────────┘ +``` + +## 5. Component inventory + +| Component | Location | Purpose | +| ------------------------ | ------------------------------------ | ---------------------------------------------------------------------------------------------------------- | +| `` | `apps/web/src/components/dashboard/` | Reusable section wrapper with title, demonstrates, description, code reference, optional demo-mode variant | +| `` | `apps/web/src/components/dashboard/` | Section 3.1 contents | +| `` | `apps/web/src/components/dashboard/` | Section 3.2 contents | +| `` | `apps/web/src/components/dashboard/` | Section 3.3 contents | +| `` | `apps/web/src/components/dashboard/` | Section 3.4 contents, env-gated | +| `` | `apps/web/src/components/dashboard/` | The result area showing the last 3 fake/real summaries with timestamps | + +All components use existing `packages/billing` hooks and components for state and gating. No new package-level primitives required for this spec. + +## 6. Mobile parity + +Mirror the same structure on `apps/mobile/src/screens/DashboardScreen.tsx` using native equivalents. The native version uses a vertical scroll container, native `` for buttons, and the existing `.native.tsx` versions of billing components. Mobile demo controls live at the bottom of the scroll, same as web. + +If mobile parity is non-trivial because some primitives lack native variants, ship web first and document the mobile follow-up. + +## 7. Migration + +1. Implement the new dashboard playground. +2. Verify all sections render and interact correctly across Free, Pro, and Max plans (use demo controls to switch). +3. Verify demo controls section is fully absent when `VITE_BILLING_DEMO_MODE` is unset. +4. Remove the legacy `/billing-demo` route and `BillingDemoPage` component. +5. Update any in-app links that pointed to `/billing-demo` (likely just the dashboard placeholder, which is being replaced anyway). + +## 8. Non-goals + +- No analytics or tracking on the demo controls. +- No persistence of fake AI summarize results across sessions; in-memory only. +- No real Claude API integration unless `VITE_DEMO_USE_REAL_AI=true` is explicitly set; default is fake lorem ipsum. +- No additional billing primitives in `packages/billing` for this spec — the dashboard composes existing primitives. +- No styling system or design tokens introduced here; reuse existing Tailwind patterns from profile/billing pages. +- No "tour" or onboarding overlay; the section copy IS the tour. + +## 9. Open questions for the agent to flag, not decide + +- Does the existing `useFeature` hook return numeric values directly, or does it require a separate accessor? Match whatever exists; do not add new hook signatures for this spec. +- Is there an existing `simulate_upgrade` RPC from earlier work? Reuse if present; if not, propose its schema in the PR. Same for `reset_usage`. +- Where does the lorem-ipsum fake summary content come from? Suggest a small `apps/web/src/lib/fakeAi.ts` module with 5-10 canned summary strings rotated by a counter or random pick. + +These are flagged so the agent surfaces decisions in the PR rather than guessing. diff --git a/docs/stripe-billing-setup.md b/docs/stripe-billing-setup.md new file mode 100644 index 00000000..482bcd36 --- /dev/null +++ b/docs/stripe-billing-setup.md @@ -0,0 +1,387 @@ +# Stripe billing setup (BeakerStack) + +This guide walks you from **zero** to a working **test-mode** Stripe integration with BeakerStack’s billing stack: **Supabase Postgres** (`billing_*` tables), **Edge Functions** (`stripe-webhook`, `billing-stripe`), and the **web/mobile** apps using `@beakerstack/billing`. + +**Related docs (narrower topics):** + +- [supabase/functions/README.md](../supabase/functions/README.md) — Edge Function names, secrets list, local `serve` command. +- [apps/web/docs/billing-testing.md](../apps/web/docs/billing-testing.md) — Stripe CLI triggers, idempotency, demo env flags, `useBillingState` matrix. +- [guides/billing-plan-catalog.md](guides/billing-plan-catalog.md) — Features, `usage_limits`, `npm run billing:apply-plans`, and when to use SQL migrations. + +--- + +## 0. Billing model overview (what you are actually setting up) + +BeakerStack uses Stripe for **commercial billing primitives** and Supabase for **application-side entitlement state**. + +### Source of truth split + +- **Stripe is the commercial source of truth** for products, prices, subscriptions, invoices, payment state, and customer portal actions. +- **Supabase is the app source of truth** for feature gating and app queries (`billing_subscriptions`, usage records, plan checks), kept in sync from Stripe events. + +### What you configure in Stripe + +In Stripe, you are setting up: + +1. **Products and Prices** for your paid plans (for the template: `beakerstack_pro`, `beakerstack_max`). +2. **Webhook endpoints** that deliver subscription/payment lifecycle events to each Supabase project. +3. **Customer Portal settings** (what users can change/cancel/manage). +4. (Optional) Dashboard-level controls like tax, invoice branding, retry/dunning behavior. + +This repo’s `billing:sync-stripe` command can create/update Products and Prices for you from config so Stripe and `billing_plans` stay aligned. + +### How checkout + sync works + +1. App calls Edge Function `billing-stripe` to create a Checkout Session for a plan. If `billing_plans.trial_period_days` is greater than zero for that plan, the session includes `subscription_data.trial_period_days` so Stripe creates a **trialing** subscription until `trial_end`, then attempts the first paid charge. +2. Stripe completes payment and emits webhook events. +3. `stripe-webhook` verifies signatures and upserts `billing_subscriptions` (idempotent). +4. App reads Supabase billing tables/RPCs to decide what features are enabled. + +**After trial or failed paid state:** if the user lands on **Free** without using the interactive downgrade flow, see the **Involuntary downgrade to Free (grace + remediation)** section in [beakerstack-billing-v1.md](specs/beakerstack-billing-v1.md) (meter period caveat and remediation expectations). + +### Practical rule + +If a plan is paid, it must have a valid Stripe Price and the matching `billing_plans.stripe_price_id`. If that link is missing, checkout is not ready. + +### Environment model (BeakerStack default) + +BeakerStack runs four stack versions, each with its own Supabase target and billing wiring: + +| Environment | Supabase target | Stripe key mode | Webhook endpoint in Stripe | +| ----------- | ------------------------------------- | ------------------------------ | ------------------------------------------------------------------------------- | +| Local dev | Local Docker Supabase | Test (`sk_test`) | Usually Stripe CLI forward to localhost | +| PR preview | Shared preview Supabase cloud project | Test (`sk_test`) | Preview project `https://.supabase.co/functions/v1/stripe-webhook` | +| Staging | Staging Supabase cloud project | Test (`sk_test`) | Staging project `https://.supabase.co/functions/v1/stripe-webhook` | +| Production | Production Supabase cloud project | Live (`sk_live`) after go-live | Production project `https://.supabase.co/functions/v1/stripe-webhook` | + +Keep a separate Stripe webhook endpoint + `whsec` per hosted Supabase project. Do not reuse one `whsec` across environments. + +### Deploy target hardening (Checkout) + +Stripe **test mode** can deliver the same events to every registered webhook URL. To avoid `checkout.session.completed` upserts in the wrong Supabase project, Checkout Sessions include metadata **`billing_deploy_target`**, set by Edge from the project’s Supabase URL (project ref for `https://.supabase.co`) or `local` for Docker / localhost. The `stripe-webhook` function ignores completed checkouts when that metadata is **present** and does not match its own deploy target (HTTP **200** with `{ "received": true, "ignored": true }`); legacy events **without** the key are still processed. Optional secret **`BILLING_WEBHOOK_TARGET`** overrides the derived value when the URL is not `*.supabase.co` — set identically on `billing-stripe` and `stripe-webhook` for that project. + +**Deploy order:** ship **`billing-stripe` before or with `stripe-webhook`** so new sessions include `billing_deploy_target` before the webhook enforces mismatches (missing metadata remains processed until clients use the updated checkout). + +--- + +## 1. How the pieces connect + +| Piece | Role | +| ----------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | +| **Stripe Dashboard** | Products/prices (or created by sync script), **webhook endpoint** URL pointing at your project, **API keys**. | +| **Supabase Edge `billing-stripe`** | Authenticated user calls: create Checkout Session, Customer Portal, cancel/downgrade. Uses `STRIPE_SECRET_KEY`. | +| **Supabase Edge `stripe-webhook`** | Stripe → you: verifies signature with `STRIPE_WEBHOOK_SECRET`, updates `billing_subscriptions`. Uses **service role** to write Postgres. | +| **`billing_plans.stripe_price_id`** | Checkout needs a real Stripe Price ID on each paid plan row. Use `npm run billing:sync-stripe` (see §7) or set manually in Stripe + DB. | +| **Web app** | `VITE_SUPABASE_URL` + `VITE_SUPABASE_ANON_KEY` only; **never** put `STRIPE_SECRET_KEY` in the browser. | + +**Important:** Subscription rows are created/updated from **webhooks** (and Checkout metadata). The Edge checkout handler attaches metadata: `supabase_user_id`, `product_id`, `plan_id`, and `billing_deploy_target` so `checkout.session.completed` can upsert `billing_subscriptions` and filter cross-environment webhook fan-out. + +--- + +## 2. Prerequisites + +- [Stripe account](https://dashboard.stripe.com/register) (use **Test mode** until you intentionally go live). +- [Stripe CLI](https://stripe.com/docs/stripe-cli) installed (`stripe login`) for local webhook forwarding. +- [Supabase CLI](https://supabase.com/docs/guides/cli) and Docker for local Supabase, **or** a hosted Supabase project for remote-only setup. +- Repo dependencies: `npm install` from the monorepo root. + +--- + +## 3. Stripe Dashboard — API keys (test mode) + +1. Open [Stripe Dashboard](https://dashboard.stripe.com/) and turn **Test mode** on (toggle in the header). +2. Go to **Developers → API keys**. +3. Copy: + - **Publishable key** — `pk_test_…` (optional for future client-side Stripe.js; BeakerStack billing checkout is mostly server-driven via Edge). + - **Secret key** — `sk_test_…` → this value is `STRIPE_SECRET_KEY` for Edge Functions and for the sync script. + +Keep secret keys out of git; use Supabase Dashboard secrets and local `.env` files that are gitignored. + +--- + +## 4. Supabase — database and billing seed + +Billing tables and RPCs ship in migrations (e.g. `supabase/migrations/*_billing_v1.sql`). Apply them to the database you will use: + +**Local:** run these from the **repository root** so `supabase/migrations/` is applied. + +```bash +supabase start +supabase db reset # applies migrations + seed; wipes local data +``` + +**Hosted:** use `supabase link` + `supabase db push` (see [supabase-staging-production-setup.md](supabase-staging-production-setup.md)). + +Seed data (when using repo `supabase/seed.sql`) includes the template product **`beakerstack`** and plans **`beakerstack_free`**, **`beakerstack_pro`**, **`beakerstack_max`**. Paid plans start with **`stripe_price_id` null** until you run the sync script (§7) or paste Price IDs from Stripe. + +--- + +## 5. Stripe Dashboard — webhook endpoint (hosted Supabase) + +When **Stripe’s servers** must call your project (staging/production, or a stable tunnel), register the webhook in Stripe: + +1. **Developers → Webhooks → Add endpoint**. +2. **Endpoint URL** (replace placeholders): + + `https://.supabase.co/functions/v1/stripe-webhook` + - `PROJECT_REF` is in the Supabase project URL (Dashboard → Project Settings → API → Project URL). + +3. **Events to send** — at minimum select the types the template handler implements (you can also use “Receive all events” while testing): + - `checkout.session.completed` + - `customer.subscription.updated` + - `customer.subscription.deleted` + - `customer.subscription.trial_will_end` + - `invoice.payment_failed` + - `invoice.paid` + - `invoice.payment_succeeded` + +4. After saving, open the webhook details and **Reveal** the **Signing secret** (`whsec_…`). That value is **`STRIPE_WEBHOOK_SECRET`** for the **same** Stripe mode (test vs live) as your API keys. + +### 5.1 If one Stripe account serves multiple apps/projects + +Use **one webhook endpoint per deployed project/environment** (do not share one endpoint URL across all apps): + +| Stripe endpoint | Points to | Secret used in | +| ----------------------------------------------------- | ------------------------------------------------------------------- | ---------------------------------- | +| `beakerstack-web-staging` | `https://.supabase.co/functions/v1/stripe-webhook` | Web staging Supabase secrets | +| `beakerstack-web-production` | `https://.supabase.co/functions/v1/stripe-webhook` | Web production Supabase secrets | +| `beakerstack-mobile-production` (if separate backend) | `https://.supabase.co/functions/v1/stripe-webhook` | Mobile production Supabase secrets | + +Rules of thumb: + +- Each endpoint gets its **own** signing secret (`whsec_...`); store that secret only in the matching Supabase project as `STRIPE_WEBHOOK_SECRET`. +- Reuse the same Stripe account safely by separating at least by **environment** (staging vs production), and by **backend project** when apps are backed by different Supabase projects. +- Keep event subscriptions aligned with what each project handles; this avoids noisy/unneeded deliveries. +- In Dashboard, give endpoints clear names (`--stripe-webhook`) so key rotation and incident response stay simple. + +If multiple frontends (web + mobile) share the **same Supabase project**, they can share that project’s single webhook endpoint and secret. + +**Local development** usually skips the Dashboard URL and uses **Stripe CLI** instead (§8) so your laptop receives events without a public URL. + +--- + +## 6. Supabase — Edge Function secrets + +For **each** Supabase project (local secrets file for `serve`, or Dashboard for hosted): + +| Secret | Where to get it | +| ----------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `STRIPE_SECRET_KEY` | Stripe Dashboard → API keys → Secret key (`sk_test_…` or `sk_live_…`). | +| `STRIPE_WEBHOOK_SECRET` | Stripe webhook signing secret (`whsec_…`) for the endpoint that points at **this** project’s `stripe-webhook`, **or** the secret printed by `stripe listen` for local dev. Never reuse a `whsec` from a different endpoint/project. | +| `SUPABASE_URL` / `BILLING_SUPABASE_URL` | Project API URL (`https://.supabase.co`). Dashboard may use `SUPABASE_URL`; **`supabase secrets set` skips names starting with `SUPABASE_`**, so CI uses **`BILLING_SUPABASE_URL`**. Functions accept either. | +| `SUPABASE_ANON_KEY` / `BILLING_SUPABASE_ANON_KEY` | Dashboard: **anon public** key. CLI/CI: use **`BILLING_SUPABASE_ANON_KEY`** so `billing-stripe` can call `auth.getUser()` even when hosted default `SUPABASE_ANON_KEY` is missing or mismatched. | +| `SUPABASE_SERVICE_ROLE_KEY` / `BILLING_SUPABASE_SERVICE_ROLE_KEY` | Dashboard: **service_role** key. CLI/CI: use **`BILLING_SUPABASE_SERVICE_ROLE_KEY`** when setting secrets via the CLI. | + +**Supabase Dashboard:** Project Settings → **Edge Functions** → **Secrets**. + +**CLI (linked project):** + +```bash +cd supabase +supabase link --project-ref "" --password "" --yes +supabase secrets set \ + STRIPE_SECRET_KEY=sk_test_... \ + STRIPE_WEBHOOK_SECRET=whsec_... \ + BILLING_SUPABASE_URL="https://.supabase.co" \ + BILLING_SUPABASE_ANON_KEY="" \ + BILLING_SUPABASE_SERVICE_ROLE_KEY="" \ + BILLING_ALLOWED_ORIGINS="https://your-app.example" +``` + +Deploy the functions after DB is ready (CI passes `--project-ref`; locally you can use the same after `link`): + +```bash +supabase functions deploy stripe-webhook billing-stripe --project-ref "" +``` + +See [supabase/functions/README.md](../supabase/functions/README.md) for JWT behavior (`stripe-webhook` has **verify_jwt disabled** so Stripe can POST without a Supabase JWT). + +--- + +## 7. Stripe products and prices → database (`stripe_price_id`) + +Checkout fails if `billing_plans.stripe_price_id` is null for a paid plan. The repo includes: + +- **Sync config:** [apps/web/src/billing/billing-sync.json](../apps/web/src/billing/billing-sync.json) — lists paid `planId`s and monthly/annual amounts for the template product. +- **Script:** `npm run billing:sync-stripe` → runs [scripts/sync-billing-stripe.mjs](../scripts/sync-billing-stripe.mjs). + +**CI:** [`.github/workflows/pr-preview-environment.yml`](../.github/workflows/pr-preview-environment.yml), [`deploy-staging.yml`](../.github/workflows/deploy-staging.yml), and [`deploy-production.yml`](../.github/workflows/deploy-production.yml) run **`npm run billing:sync-stripe`** after the database is migrated (preview: after the preview DB prepare step; staging/production: after `supabase db push`) and **before** deploying Edge Functions, so `billing_plans` always has Stripe price IDs for checkout without a manual sync. + +From the **repo root**, with keys for the **same** Supabase project and Stripe account (manual runs when debugging or before CI existed): + +```bash +export STRIPE_SECRET_KEY=sk_test_... +export SUPABASE_URL=https://.supabase.co # or http://127.0.0.1:54321 for local +export SUPABASE_SERVICE_ROLE_KEY= + +npm run billing:sync-stripe +# equivalent: node scripts/sync-billing-stripe.mjs --config apps/web/src/billing/billing-sync.json +``` + +The script creates or reuses a Stripe Product (tagged with `billing_product_id` metadata), creates Prices as needed, and **updates** `billing_plans` rows for the listed `planId`s. + +For your own product, **copy** `billing-sync.json`, change `productId` / `planId`s to match **your** `billing_products` / `billing_plans` rows and amounts, then run the same command with `--config path/to/your-sync.json`. + +**`features` / `usage_limits` on `billing_plans`** are not touched by `billing:sync-stripe`. After editing the app billing config, run **`npm run billing:apply-plans`** (or follow a SQL migration workflow) as described in [guides/billing-plan-catalog.md](guides/billing-plan-catalog.md). + +--- + +## 8. Local end-to-end (Docker Supabase + Stripe CLI) + +### 8.1 Start Supabase and apply schema + +```bash +supabase start +supabase db reset +``` + +Note `API URL` and `anon key` and `service_role key` from `supabase status`. + +### 8.2 Local Edge secrets file + +Create **`supabase/.env.local`** (gitignored — do not commit): + +```env +STRIPE_SECRET_KEY=sk_test_... +STRIPE_WEBHOOK_SECRET=whsec_... # use value from stripe listen (next step) +SUPABASE_URL=http://127.0.0.1:54321 +SUPABASE_SERVICE_ROLE_KEY= +``` + +### 8.3 Serve Edge Functions locally + +From repo root: + +```bash +cd supabase +supabase functions serve --no-verify-jwt --env-file .env.local +``` + +Leave this running. Default functions URL is **`http://127.0.0.1:54321/functions/v1/`**. + +### 8.4 Forward Stripe webhooks to local `stripe-webhook` + +In a second terminal (after `stripe login`): + +```bash +stripe listen --forward-to http://127.0.0.1:54321/functions/v1/stripe-webhook +``` + +Copy the **`whsec_...`** the CLI prints into **`STRIPE_WEBHOOK_SECRET`** in `supabase/.env.local`, then **restart** `supabase functions serve` so the new secret is picked up. + +### 8.5 Sync Stripe prices to local DB + +```bash +export STRIPE_SECRET_KEY=sk_test_... +export SUPABASE_URL=http://127.0.0.1:54321 +export SUPABASE_SERVICE_ROLE_KEY= +npm run billing:sync-stripe +``` + +### 8.6 Point the web app at local Supabase + +In `apps/web` (or root) `.env` / `.env.local` as you already do for the template: + +```env +VITE_SUPABASE_URL=http://127.0.0.1:54321 +VITE_SUPABASE_ANON_KEY= +``` + +Optional demo UI flags (see [apps/web/docs/billing-testing.md](../apps/web/docs/billing-testing.md)): + +```env +VITE_BILLING_DEMO_MODE=true +``` + +Run the web app (`npm run web`), sign in, open **`/billing/plans`**, and use **real Checkout** (test card `4242 4242 4242 4242`, any future CVC/ZIP). After payment, **`checkout.session.completed`** should populate `billing_subscriptions` in Studio. + +**Webhook testing recipes:** [apps/web/docs/billing-testing.md](../apps/web/docs/billing-testing.md) (Stripe CLI `trigger` commands). + +--- + +## 9. Hosted Supabase (staging / production) checklist + +1. Create or select the Supabase project; run **migrations** (`supabase db push` or your pipeline). +2. Set **Edge secrets** (`STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`, `BILLING_SUPABASE_URL`, `BILLING_SUPABASE_ANON_KEY`, `BILLING_SUPABASE_SERVICE_ROLE_KEY`, `BILLING_ALLOWED_ORIGINS`; use Dashboard for `SUPABASE_*` if the platform allows it). +3. **Deploy functions:** `supabase functions deploy stripe-webhook billing-stripe --project-ref ""` (root workflows do this after `db push` when CI is configured). +4. In **Stripe Dashboard** (matching test/live mode), add the webhook URL: + + `https://.supabase.co/functions/v1/stripe-webhook` + + and paste the signing secret into **`STRIPE_WEBHOOK_SECRET`**. + +5. **Stripe price sync:** GitHub Actions runs **`billing:sync-stripe`** on each staging/production deploy (after migrations). For a one-off project or if CI failed, run **`npm run billing:sync-stripe`** manually with that project’s `SUPABASE_URL` and service role key so paid plans have `stripe_price_id_*`. +6. Set app env vars (`VITE_SUPABASE_*`, and Expo `EXPO_PUBLIC_*` if using mobile) to that project’s **anon** URL and key. + +--- + +## 9.1 PR preview billing checklist + +The PR preview workflow targets the shared preview Supabase project, runs **`billing:sync-stripe`** after preparing the preview database, then deploys billing Edge Functions. + +Required GitHub Actions **repository** secrets for the PR preview job (billing + link + DB scripts as wired in the workflow): + +- `SUPABASE_ACCESS_TOKEN` — Supabase CLI / Management API +- `SUPABASE_PREVIEW_PROJECT_REF`, `SUPABASE_PREVIEW_DB_PASSWORD` — `supabase link` / migrations +- `SUPABASE_PREVIEW_DB_URL` — preview DB reset script (if used by your workflow path) +- `PREVIEW_SUPABASE_URL`, `PREVIEW_SUPABASE_ANON_KEY` — web / native preview builds +- `PR_TESTING_SUPABASE_SERVICE_ROLE_KEY` — **service_role** key for the **preview** Supabase project (maps to Edge secret `BILLING_SUPABASE_SERVICE_ROLE_KEY` in CI). Add this secret in GitHub if it is missing. +- `PREVIEW_STRIPE_SECRET_KEY`, `PREVIEW_STRIPE_WEBHOOK_SECRET`, `PREVIEW_BILLING_ALLOWED_ORIGINS` — Stripe + redirect allowlist for preview + +Other preview jobs may require `AWS_*`, `PR_PREVIEW_CERTIFICATE_ARN`, `EXPO_TOKEN`, Google Services secrets, etc.; see [.github/workflows/pr-preview-environment.yml](../.github/workflows/pr-preview-environment.yml). + +**Webhook URL (preview):** `https://.supabase.co/functions/v1/stripe-webhook` + +CI runs [`scripts/ensure-stripe-webhook-endpoint.mjs`](../scripts/ensure-stripe-webhook-endpoint.mjs) (`npm run stripe:ensure-webhook`) before deploying Edge Functions: it **creates** this endpoint in Stripe if missing (using `PREVIEW_STRIPE_SECRET_KEY`) and passes the new signing secret into `supabase secrets set` in the same job. **Subsequent deploys** need **`PREVIEW_STRIPE_WEBHOOK_SECRET`** in GitHub (same value as Stripe → Webhooks → that endpoint → **Reveal**), because Stripe only returns the secret at create time. Until you add it, the ensure step fails with instructions if the endpoint already exists and the secret is missing. + +You can still add or inspect the endpoint manually in Stripe Dashboard (test mode) if you prefer. + +--- + +## 10. Customer Portal + +The Customer Portal requires a **Stripe Customer** on the subscription row (`stripe_customer_id`), which Checkout creates on first successful paid checkout (or webhook path). Edge function **`billing-stripe`** action `portal` reads `billing_subscriptions` for the signed-in user and opens Stripe’s hosted portal. + +In Stripe Dashboard, configure **Billing → Customer portal** (allowed products, cancellation, etc.). + +**Cancel behavior:** If the customer chooses **cancel at period end**, Stripe usually keeps subscription **`status` as `active`** until the billing period ends and sets **`cancel_at_period_end: true`**. The template persists that flag on `billing_subscriptions`, and **`SubscriptionStatus`** in `@beakerstack/billing` explains that in the UI. Only **immediate** cancel (or the period actually ending) moves `status` to **`canceled`**. + +--- + +## 11. Going live (production) + +1. Switch Stripe to **Live mode**; create live **Products/Prices** (or run sync with live `sk_live_…` and production `SUPABASE_*`). +2. Register a **live** webhook endpoint pointing at **production** Supabase `stripe-webhook`; use the **live** signing secret as `STRIPE_WEBHOOK_SECRET` on the **production** project. +3. Set **live** `STRIPE_SECRET_KEY` on production Edge secrets. +4. Never mix test and live keys on one environment. +5. Turn off template-only demo flags (`billing_system_flags.demo_billing_mode`, `VITE_BILLING_DEMO_MODE`) in production unless you explicitly want simulate-upgrade RPCs. + +--- + +## 12. Troubleshooting + +| Symptom | Things to check | +| ------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Webhook **400 invalid_signature** | `STRIPE_WEBHOOK_SECRET` must match the **same** endpoint as the request (CLI secret vs Dashboard secret); restart `functions serve` after changing `.env.local`. If logs say **SubtleCryptoProvider / constructEvent**, the template uses **`constructEventAsync`** for Deno — redeploy or restart `functions serve` with current `stripe-webhook` code. | +| Stripe shows correct tier but app still shows old plan | Webhooks were not applied (often **400** on `stripe-webhook`). Fix verification (above), restart `functions serve`, then **Resend** recent events in Stripe Dashboard (Developers → Events) or complete another test checkout. | +| Checkout finishes in Stripe but app still shows **free** | **Preview/hosted:** In Stripe **Test mode**, **Developers → Webhooks** must list an endpoint URL exactly `https://.supabase.co/functions/v1/stripe-webhook`, and GitHub secret **`PREVIEW_STRIPE_WEBHOOK_SECRET`** (Edge `STRIPE_WEBHOOK_SECRET`) must be that endpoint’s **Signing secret** — not from `stripe listen` unless webhooks go there. Check **Recent deliveries** for 4xx. In Supabase: **`billing_webhook_events`** (`processed`, `error`). The app polls subscription for ~40s after `?checkout=success` once the webhook is fixed. | +| **`customer.subscription.updated` → 500** after portal cancel | Often a thrown **`RangeError`** from `toISOString()` when Stripe omits or changes `current_period_*` / `canceled_at` timestamps. The template converts Unix fields safely; restart `functions serve` and **`stripe events resend `** for the failed event. | +| Metered usage or demo collections reset after refresh | Ensure latest migrations are applied locally (`supabase db reset` or `supabase migration up`). Metered usage is period-based and can reset at a new billing period; demo collections/items now persist via `billing_demo_*` RPCs backed by DB. | +| Checkout returns **plan_not_checkout_ready** | `stripe_price_id` is null — run **`billing:sync-stripe`** or set Price IDs manually on `billing_plans`. | +| Webhook **200** but no row update | `checkout.session.completed` requires metadata `supabase_user_id`, `product_id`, `plan_id` — the template Edge checkout sets these; custom clients must do the same. | +| **401** on `billing-stripe` | Call `supabase.functions.invoke` with a **logged-in** user session (Authorization header). | +| Duplicate webhook deliveries | Expected; handler is **idempotent** via `billing_webhook_events.stripe_event_id` unique constraint. | + +--- + +## 13. Quick reference — URLs and commands + +| Item | Value | +| ----------------------- | ------------------------------------------------------------------------------- | +| Local webhook URL | `http://127.0.0.1:54321/functions/v1/stripe-webhook` | +| Hosted webhook URL | `https://.supabase.co/functions/v1/stripe-webhook` | +| Stripe CLI forward | `stripe listen --forward-to ` | +| Sync prices | `npm run billing:sync-stripe` (with env vars set) | +| Serve functions locally | `cd supabase && supabase functions serve --no-verify-jwt --env-file .env.local` | + +You now have a single path from Stripe account → secrets → webhooks → DB price IDs → app env → working Checkout and subscription updates. diff --git a/env.example b/env.example index 4ef21deb..a5542cf2 100644 --- a/env.example +++ b/env.example @@ -39,6 +39,44 @@ PR_TESTING_SUPABASE_ANON_KEY=your-pr-testing-anon-key PR_TESTING_SUPABASE_SERVICE_ROLE_KEY=your-pr-testing-service-role-key PR_TESTING_SUPABASE_PROJECT_REF=your-pr-testing-project-ref +# Stripe Billing - Environment-specific Edge secrets +# Use test keys for local/preview/staging; use live keys only for production go-live. +# Each hosted Supabase project needs its own webhook signing secret (whsec_...). +LOCAL_STRIPE_SECRET_KEY=sk_test_local_or_shared +LOCAL_STRIPE_WEBHOOK_SECRET=whsec_local_from_stripe_listen +PREVIEW_STRIPE_SECRET_KEY=sk_test_preview +PREVIEW_STRIPE_WEBHOOK_SECRET=whsec_preview_endpoint +STAGING_STRIPE_SECRET_KEY=sk_test_staging +STAGING_STRIPE_WEBHOOK_SECRET=whsec_staging_endpoint +PRODUCTION_STRIPE_SECRET_KEY=sk_live_production +PRODUCTION_STRIPE_WEBHOOK_SECRET=whsec_production_endpoint + +# Billing Edge Functions — CORS and checkout/portal redirect allowlist (comma-separated origins only: scheme + host + port, no paths) +# Set on each Supabase project (Edge Function secret) as BILLING_ALLOWED_ORIGINS; CI maps the per-env values below on deploy. +# +# Local development (BILLING_ALLOWED_ORIGINS): +# - Usually leave empty. When SUPABASE_URL is local (e.g. http://127.0.0.1:54321 from supabase start), Edge code auto-merges common +# dev origins: http://localhost:5173, http://127.0.0.1:5173, http://[::1]:5173 (IPv6 loopback), :8081 (Expo), :3000 — so Vite/Expo on those ports work with no secret. +# - Add an origin if your app is not covered: another port (e.g. http://localhost:4173), a LAN IP for a device +# (e.g. http://192.168.0.12:8081), a nip.io / custom dev host, or a custom URL scheme origin for deep links +# (e.g. myapp://billing). +# - Use sk_test_ keys locally. sk_live_ rejects plain http: redirect URLs (https: or non-http schemes only, as allowed). +# +# PREVIEW_/STAGING_/PRODUCTION_BILLING_ALLOWED_ORIGINS are for GitHub Actions → Supabase deploy secrets (deployed web/preview origins); +# you do not need them in a local .env for day-to-day coding. +# If PREVIEW_BILLING_ALLOWED_ORIGINS is unset/empty, pr-preview-environment.yml defaults to https://deploy.beakerstack.com for path-based PR previews. +BILLING_ALLOWED_ORIGINS= +PREVIEW_BILLING_ALLOWED_ORIGINS= +STAGING_BILLING_ALLOWED_ORIGINS= +PRODUCTION_BILLING_ALLOWED_ORIGINS= + +# Dashboard billing playground — demo RPC panel (must match server billing_system_flags.demo_billing_mode) +VITE_BILLING_DEMO_MODE= +# Optional: call Edge Function demo-ai-summarize when set (falls back to canned text on failure) +VITE_DEMO_USE_REAL_AI= +EXPO_PUBLIC_BILLING_DEMO_MODE= +EXPO_PUBLIC_DEMO_USE_REAL_AI= + # Supabase Access Token (for CLI operations) SUPABASE_ACCESS_TOKEN=your-supabase-access-token diff --git a/package-lock.json b/package-lock.json index 20469adc..c7eee773 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "@babel/plugin-transform-runtime": "^7.28.3", "@changesets/changelog-github": "0.6.0", "@changesets/cli": "2.31.0", + "@supabase/supabase-js": "^2.38.0", "@types/jest": "^29.5.8", "@types/node": "^20.10.0", "@typescript-eslint/eslint-plugin": "^7.18.0", @@ -29,7 +30,9 @@ "jest": "^29.7.0", "nyc": "^17.1.0", "prettier": "^3.1.0", + "stripe": "^14.21.0", "ts-jest": "^29.1.1", + "tsx": "^4.19.4", "typescript": "^5.3.0" }, "engines": { @@ -46,6 +49,7 @@ "version": "1.0.0", "hasInstallScript": true, "dependencies": { + "@beakerstack/billing": "^1.0.0", "@react-native-async-storage/async-storage": "1.21.0", "@react-native-google-signin/google-signin": "13.3.1", "@react-navigation/native": "^6.1.18", @@ -91,8 +95,10 @@ "apps/web": { "version": "1.0.0", "dependencies": { + "@beakerstack/billing": "^1.0.0", "@beakerstack/shared": "^1.0.0", "@supabase/supabase-js": "^2.38.0", + "lucide-react": "^0.460.0", "react": "18.2.0", "react-dom": "18.2.0", "react-router-dom": "^6.30.3", @@ -2515,6 +2521,10 @@ "dev": true, "license": "MIT" }, + "node_modules/@beakerstack/billing": { + "resolved": "packages/billing", + "link": true + }, "node_modules/@beakerstack/shared": { "resolved": "packages/shared", "link": true @@ -11889,6 +11899,16 @@ "@babel/core": "^7.8.0" } }, + "node_modules/babel-plugin-dynamic-import-node": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/babel-plugin-dynamic-import-node/-/babel-plugin-dynamic-import-node-2.3.3.tgz", + "integrity": "sha512-jZVI+s9Zg3IqA/kdi0i6UDCybUI3aSBLnglhYbSSjKlV7yF1F/5LWv8MakQmvYpnbJDS6fcBL2KzHSxNCMtWSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "object.assign": "^4.1.0" + } + }, "node_modules/babel-plugin-istanbul": { "version": "6.1.1", "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz", @@ -17165,6 +17185,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-tsconfig": { + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", + "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, "node_modules/getenv": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/getenv/-/getenv-1.0.0.tgz", @@ -20624,6 +20657,15 @@ "yallist": "^3.0.2" } }, + "node_modules/lucide-react": { + "version": "0.460.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.460.0.tgz", + "integrity": "sha512-BVtq/DykVeIvRTJvRAgCsOwaGL8Un3Bxh8MbDxMhEWlZay3T4IpEKDEpwt5KZ0KJMHzgm6jrltxlT5eXOWXDHg==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" + } + }, "node_modules/lz-string": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", @@ -23358,6 +23400,22 @@ "qrcode-terminal": "bin/qrcode-terminal.js" } }, + "node_modules/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/quansync": { "version": "0.2.11", "resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz", @@ -24297,6 +24355,16 @@ "node": ">=4" } }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, "node_modules/resolve-workspace-root": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/resolve-workspace-root/-/resolve-workspace-root-2.0.1.tgz", @@ -25603,6 +25671,20 @@ "dev": true, "license": "MIT" }, + "node_modules/stripe": { + "version": "14.25.0", + "resolved": "https://registry.npmjs.org/stripe/-/stripe-14.25.0.tgz", + "integrity": "sha512-wQS3GNMofCXwH8TSje8E1SE8zr6ODiGtHQgPtO95p9Mb4FhKC9jvXR2NUTpZ9ZINlckJcFidCmaTFV4P6vsb9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": ">=8.1.0", + "qs": "^6.11.0" + }, + "engines": { + "node": ">=12.*" + } + }, "node_modules/strnum": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.3.tgz", @@ -27100,150 +27182,654 @@ "node": ">= 12" } }, - "node_modules/tunnel-agent": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", - "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "dev": true, - "license": "Apache-2.0", + "license": "MIT", "dependencies": { - "safe-buffer": "^5.0.1" + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" }, "engines": { - "node": "*" + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" } }, - "node_modules/turndown": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/turndown/-/turndown-7.1.2.tgz", - "integrity": "sha512-ntI9R7fcUKjqBP6QU8rBK2Ehyt8LAzt3UBT9JR9tgo6GtuKvyUzpayWmeMKJw1DPdXzktvtIT8m2mVXz+bL/Qg==", + "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], "dev": true, "license": "MIT", - "dependencies": { - "domino": "^2.1.6" + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" } }, - "node_modules/type-check": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", - "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "node_modules/tsx/node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], "dev": true, "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1" - }, + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">= 0.8.0" + "node": ">=18" } }, - "node_modules/type-detect": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", - "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "node_modules/tsx/node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, "license": "MIT", + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">=4" + "node": ">=18" } }, - "node_modules/type-fest": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", - "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "node_modules/tsx/node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], "dev": true, - "license": "(MIT OR CC0-1.0)", + "license": "MIT", + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=18" } }, - "node_modules/typed-array-buffer": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", - "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "is-typed-array": "^1.1.14" - }, + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">= 0.4" + "node": ">=18" } }, - "node_modules/typed-array-byte-length": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", - "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "node_modules/tsx/node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "for-each": "^0.3.3", - "gopd": "^1.2.0", - "has-proto": "^1.2.0", - "is-typed-array": "^1.1.14" - }, + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=18" } }, - "node_modules/typed-array-byte-offset": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", - "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.8", - "for-each": "^0.3.3", - "gopd": "^1.2.0", - "has-proto": "^1.2.0", - "is-typed-array": "^1.1.15", - "reflect.getprototypeof": "^1.0.9" - }, + "optional": true, + "os": [ + "freebsd" + ], "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=18" } }, - "node_modules/typed-array-length": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", - "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", + "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7", - "for-each": "^0.3.3", - "gopd": "^1.0.1", - "is-typed-array": "^1.1.13", - "possible-typed-array-names": "^1.0.0", - "reflect.getprototypeof": "^1.0.6" - }, + "optional": true, + "os": [ + "freebsd" + ], "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=18" } }, - "node_modules/typedarray-to-buffer": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz", - "integrity": "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==", + "node_modules/tsx/node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], "dev": true, "license": "MIT", - "dependencies": { - "is-typedarray": "^1.0.0" - } - }, - "node_modules/typedarray.prototype.slice": { - "version": "1.0.5", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/turndown": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/turndown/-/turndown-7.1.2.tgz", + "integrity": "sha512-ntI9R7fcUKjqBP6QU8rBK2Ehyt8LAzt3UBT9JR9tgo6GtuKvyUzpayWmeMKJw1DPdXzktvtIT8m2mVXz+bL/Qg==", + "dev": true, + "license": "MIT", + "dependencies": { + "domino": "^2.1.6" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", + "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "is-typed-array": "^1.1.13", + "possible-typed-array-names": "^1.0.0", + "reflect.getprototypeof": "^1.0.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typedarray-to-buffer": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz", + "integrity": "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-typedarray": "^1.0.0" + } + }, + "node_modules/typedarray.prototype.slice": { + "version": "1.0.5", "resolved": "https://registry.npmjs.org/typedarray.prototype.slice/-/typedarray.prototype.slice-1.0.5.tgz", "integrity": "sha512-q7QNVDGTdl702bVFiI5eY4l/HkgCM6at9KhcFbgUAzezHFbOVy4+0O/lCjsABEQwbZPravVfBIiBVGo89yzHFg==", "license": "MIT", @@ -28309,19 +28895,56 @@ "url": "https://github.com/sponsors/colinhacks" } }, + "packages/billing": { + "name": "@beakerstack/billing", + "version": "1.0.0", + "dependencies": { + "@supabase/supabase-js": "^2.38.0", + "zod": "^3.22.0" + }, + "devDependencies": { + "@testing-library/jest-dom": "^6.4.2", + "@testing-library/react": "^14.1.2", + "@types/react": "18.2.0", + "@typescript-eslint/eslint-plugin": "^7.18.0", + "@typescript-eslint/parser": "^7.18.0", + "@vitest/coverage-v8": "^3.2.4", + "eslint": "^8.57.0", + "eslint-config-prettier": "^9.1.0", + "jsdom": "^26.1.0", + "react": "18.2.0", + "react-dom": "18.2.0", + "react-native-web": "^0.21.2", + "typescript": "^5.3.0", + "vitest": "^3.2.4" + }, + "peerDependencies": { + "react": "18.2.0", + "react-native": ">=0.73.0" + }, + "peerDependenciesMeta": { + "react-native": { + "optional": true + } + } + }, "packages/shared": { "name": "@beakerstack/shared", "version": "1.0.0", "dependencies": { "@supabase/supabase-js": "^2.38.0", + "buffer": "^6.0.3", + "lucide-react": "^0.460.0", "zod": "^3.22.0" }, "devDependencies": { "@types/react": "18.2.0", + "@types/react-dom": "18.2.0", "@typescript-eslint/eslint-plugin": "^7.18.0", "@typescript-eslint/parser": "^7.18.0", "eslint": "^8.57.0", "eslint-config-prettier": "^9.1.0", + "react-dom": "18.2.0", "typescript": "^5.3.0" }, "peerDependencies": { @@ -28352,6 +28975,7 @@ "@testing-library/react": "^14.1.2", "@testing-library/react-native": "^12.9.0", "babel-jest": "^29.7.0", + "babel-plugin-dynamic-import-node": "^2.3.3", "babel-preset-expo": "^54.0.6", "expo": "~50.0.0", "jest": "^29.7.0", @@ -28367,6 +28991,30 @@ "zod": "^3.22.0" } }, + "packages/shared/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, "packages/test-utils": { "name": "@beakerstack/test-utils", "version": "0.0.0", diff --git a/package.json b/package.json index d6f70a95..460df867 100644 --- a/package.json +++ b/package.json @@ -77,34 +77,38 @@ "gen:types:staging": "supabase gen types typescript --project-id $STAGING_PROJECT_REF > apps/mobile/src/types/database.ts && cp apps/mobile/src/types/database.ts apps/web/src/types/database.ts", "test": "npm run test:unit && npm run test:integration && npm run test:db", "test:all": "npm run test && npm run test:e2e", - "test:unit": "npm run test:unit:mobile && npm run test:unit:web && npm run test:unit:shared && npm run test:unit:scripts", + "test:unit": "npm run test:unit:mobile && npm run test:unit:web && npm run test:unit:shared && npm run test:unit:billing && npm run test:unit:scripts", "test:unit:mobile": "cd apps/mobile && npm test", "test:unit:web": "cd apps/web && npm test", "test:unit:shared": "cd packages/shared-tests && npm run test", + "test:unit:billing": "cd packages/billing && npm test", "test:integration": "jest --config tests/jest.config.js --testMatch='**/tests/integration/**/*.test.ts'", - "test:db": "supabase test db", + "test:db": "node --test scripts/__tests__/db-migration-filenames.test.mjs && supabase test db", "test:e2e": "npm run test:e2e:web && npm run test:e2e:mobile", "test:e2e:web": "export PATH=\"$PATH:$HOME/.maestro/bin\" && E2E_PW=\"${TEST_PASSWORD:-E2e_$(openssl rand -hex 16)_Aa1}\" && maestro test tests/e2e/web/flows/home.yaml --env WEB_URL=\"${WEB_URL:-http://localhost:5173}\" --env TEST_EMAIL=\"${TEST_EMAIL:-e2e-test-$(date +%s)@example.com}\" --env TEST_PASSWORD=\"$E2E_PW\"", "test:e2e:mobile": "export PATH=\"$PATH:$HOME/.maestro/bin\" && E2E_PW=\"${TEST_PASSWORD:-E2e_$(openssl rand -hex 16)_Aa1}\" && maestro test tests/e2e/mobile/flows/home.yaml --env MOBILE_APP_ID=\"${MOBILE_APP_ID:-com.anonymous.beakerstack}\" --env TEST_EMAIL=\"${TEST_EMAIL:-e2e-test-$(date +%s)@example.com}\" --env TEST_PASSWORD=\"$E2E_PW\"", "test:watch": "concurrently \"cd apps/mobile && npm run test:watch\" \"cd apps/web && npm run test:watch\" \"cd packages/shared-tests && npm run test:web -- --watch\" \"cd packages/shared-tests && npm run test:native -- --watch\"", - "test:coverage": "npm run test:coverage:mobile && npm run test:coverage:web && npm run test:coverage:shared && npm run test:coverage:merge", + "test:coverage": "npm run test:coverage:mobile && npm run test:coverage:web && npm run test:coverage:shared && npm run test:coverage:billing && npm run test:coverage:merge", "test:coverage:mobile": "cd apps/mobile && npm test -- --coverage --passWithNoTests", "test:coverage:web": "cd apps/web && npm run test:coverage", "test:coverage:shared": "cd packages/shared-tests && npm run test:coverage", + "test:coverage:billing": "cd packages/billing && npm run test:coverage", "test:coverage:merge": "node scripts/merge-coverage.js", - "test:unit:scripts": "node --test scripts/__tests__/rename-project.test.mjs scripts/__tests__/detect-repo-identity.test.mjs scripts/__tests__/setup-aws-discover.test.mjs scripts/__tests__/setup-dotenv.test.mjs scripts/__tests__/setup-full-logging.test.mjs scripts/__tests__/setup-manifest-github-sync.test.mjs scripts/__tests__/setup-secret-input.test.mjs scripts/__tests__/setup-supabase-pick-recommend.test.mjs && ./scripts/pr-preview/check-preview-deploy-needed.sh --self-test", - "lint": "npm run lint:mobile; npm run lint:web; npm run lint:shared; npm run lint:repo", + "test:unit:scripts": "node --test scripts/__tests__/rename-project.test.mjs scripts/__tests__/detect-repo-identity.test.mjs scripts/__tests__/setup-aws-discover.test.mjs scripts/__tests__/setup-dotenv.test.mjs scripts/__tests__/setup-manifest-github-sync.test.mjs scripts/__tests__/setup-secret-input.test.mjs scripts/__tests__/setup-supabase-pick-recommend.test.mjs && ./scripts/pr-preview/check-preview-deploy-needed.sh --self-test", + "lint": "npm run lint:mobile; npm run lint:web; npm run lint:shared; npm run lint:billing; npm run lint:repo", "lint:mobile": "eslint apps/mobile/src --ext ts,tsx --report-unused-disable-directives", "lint:web": "eslint apps/web/src --ext ts,tsx --report-unused-disable-directives", "lint:shared": "eslint packages/shared/src --ext ts,tsx --report-unused-disable-directives", "lint:repo": "eslint scripts tests packages/shared-tests packages/test-utils/src apps/mobile/__tests__ --ext ts,tsx,js,mjs,cjs --report-unused-disable-directives", - "lint:strict": "npm run lint:mobile -- --max-warnings 0 && npm run lint:web -- --max-warnings 0 && npm run lint:shared -- --max-warnings 0 && npm run lint:repo -- --max-warnings 0", - "lint:fix": "eslint apps/mobile/src apps/web/src packages/shared/src scripts tests packages/shared-tests packages/test-utils/src apps/mobile/__tests__ --ext ts,tsx,js,mjs,cjs --fix", + "lint:billing": "eslint packages/billing/src --ext ts,tsx --report-unused-disable-directives", + "lint:strict": "npm run lint:mobile -- --max-warnings 0 && npm run lint:web -- --max-warnings 0 && npm run lint:shared -- --max-warnings 0 && npm run lint:billing -- --max-warnings 0 && npm run lint:repo -- --max-warnings 0", + "lint:fix": "eslint apps/mobile/src apps/web/src packages/shared/src packages/billing/src scripts tests packages/shared-tests packages/test-utils/src apps/mobile/__tests__ --ext ts,tsx,js,mjs,cjs --fix", "pretype-check": "cd packages/shared && npm run build", - "type-check": "npm run type-check:mobile; npm run type-check:web; npm run type-check:shared", + "type-check": "npm run type-check:mobile; npm run type-check:web; npm run type-check:shared; npm run type-check:billing", "type-check:mobile": "cd apps/mobile && npx tsc --noEmit", "type-check:web": "cd apps/web && npx tsc --noEmit", "type-check:shared": "cd packages/shared && npx tsc --noEmit", + "type-check:billing": "cd packages/billing && npx tsc --noEmit", "format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"", "format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,json,md}\"", "db:link:local": "supabase link --project-ref local", @@ -129,13 +133,17 @@ "postinstall": "patch-package || true", "rename": "node ./scripts/rename-project.mjs", "docs:actions-secrets": "node ./scripts/generate-actions-secrets-doc.mjs", - "docs:linkcheck": "npx --yes markdown-link-check@3.12.2 README.md QUICKSTART.md -c .markdown-link-check.json" + "docs:linkcheck": "npx --yes markdown-link-check@3.12.2 README.md QUICKSTART.md -c .markdown-link-check.json", + "billing:sync-stripe": "node scripts/sync-billing-stripe.mjs --config apps/web/src/billing/billing-sync.json", + "stripe:ensure-webhook": "node scripts/ensure-stripe-webhook-endpoint.mjs", + "billing:apply-plans": "tsx scripts/apply-billing-plans-from-config.ts" }, "devDependencies": { "@babel/plugin-transform-runtime": "^7.28.3", "@changesets/changelog-github": "0.6.0", "@changesets/cli": "2.31.0", "@types/jest": "^29.5.8", + "@supabase/supabase-js": "^2.38.0", "@types/node": "^20.10.0", "@typescript-eslint/eslint-plugin": "^7.18.0", "@typescript-eslint/parser": "^7.18.0", @@ -147,7 +155,9 @@ "jest": "^29.7.0", "nyc": "^17.1.0", "prettier": "^3.1.0", + "stripe": "^14.21.0", "ts-jest": "^29.1.1", + "tsx": "^4.19.4", "typescript": "^5.3.0" }, "overrides": { diff --git a/packages/billing/README.md b/packages/billing/README.md new file mode 100644 index 00000000..d7016071 --- /dev/null +++ b/packages/billing/README.md @@ -0,0 +1,44 @@ +# @beakerstack/billing + +Reusable **Supabase + Stripe** billing: entitlements (plan `features` / `usage_limits`), usage RPCs, hooks, and platform UI. + +## Imports + +- **Shared (types, provider, hooks):** `@beakerstack/billing` +- **Web components:** `@beakerstack/billing/web` +- **React Native components:** `@beakerstack/billing/native` +- **Next.js App Router (client + web UI):** `@beakerstack/billing/client` + +This package does **not** embed product tier names, demo RPCs, or domain vocabulary — those live in the app (see `apps/web/src/billing/`). + +## Config typing + +```ts +import { + defineBillingConfig, + type InferFeatureKeys, +} from '@beakerstack/billing'; + +export const myConfig = defineBillingConfig({ + productId: 'my_product', + displayName: 'My product', + plans: [ + { + id: 'my_free', + displayName: 'Free', + priceCents: 0, + billingPeriod: 'free', + features: { fancy: false }, + usageLimits: { api_calls: 100 }, + }, + ], +}); + +export type MyFeature = InferFeatureKeys; +``` + +Invalid feature keys passed to `useFeature` should fail **TypeScript** when hooks are used with `typeof myConfig`. + +## Next.js note + +`@beakerstack/billing/client` begins with `'use client'` and re-exports hooks plus web components. Tree-shaking and server boundaries should be validated when adding App Router pages. diff --git a/packages/billing/package.json b/packages/billing/package.json new file mode 100644 index 00000000..d9edb7a9 --- /dev/null +++ b/packages/billing/package.json @@ -0,0 +1,50 @@ +{ + "name": "@beakerstack/billing", + "version": "1.0.0", + "description": "Supabase + Stripe billing, entitlements, and React/RN UI for BeakerStack", + "private": true, + "type": "module", + "main": "src/index.ts", + "types": "src/index.ts", + "exports": { + ".": "./src/index.ts", + "./client": "./src/client.ts", + "./web": "./src/web.ts", + "./native": "./src/native.ts" + }, + "scripts": { + "lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0", + "type-check": "tsc --noEmit", + "test": "NODE_OPTIONS=--max-old-space-size=8192 vitest --run", + "test:coverage": "vitest --coverage --run" + }, + "dependencies": { + "@supabase/supabase-js": "^2.38.0", + "zod": "^3.22.0" + }, + "peerDependencies": { + "react": "18.2.0", + "react-native": ">=0.73.0" + }, + "peerDependenciesMeta": { + "react-native": { + "optional": true + } + }, + "devDependencies": { + "@testing-library/jest-dom": "^6.4.2", + "@testing-library/react": "^14.1.2", + "@types/react": "18.2.0", + "@typescript-eslint/eslint-plugin": "^7.18.0", + "@typescript-eslint/parser": "^7.18.0", + "eslint": "^8.57.0", + "eslint-config-prettier": "^9.1.0", + "jsdom": "^26.1.0", + "react": "18.2.0", + "react-dom": "18.2.0", + "react-native-web": "^0.21.2", + "typescript": "^5.3.0", + "@vitest/coverage-v8": "^3.2.4", + "vitest": "^3.2.4" + } +} diff --git a/packages/billing/src/BillingErrorBoundary.test.tsx b/packages/billing/src/BillingErrorBoundary.test.tsx new file mode 100644 index 00000000..fdf14f79 --- /dev/null +++ b/packages/billing/src/BillingErrorBoundary.test.tsx @@ -0,0 +1,58 @@ +import { describe, expect, it, vi, afterEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import React from 'react'; +import { BillingErrorBoundary } from './BillingErrorBoundary.js'; + +function Boom(): React.ReactElement { + throw new Error('billing boom'); +} + +describe('BillingErrorBoundary', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('renders fallback when a child throws', () => { + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + render( + ( +
+

{err.message}

+ +
+ )} + > + +
+ ); + expect(screen.getByText('billing boom')).toBeInTheDocument(); + errSpy.mockRestore(); + }); + + it('reset restores children', () => { + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + let shouldThrow = true; + function MaybeBoom(): React.ReactElement { + if (shouldThrow) throw new Error('once'); + return ok; + } + render( + ( + + )} + > + + + ); + shouldThrow = false; + fireEvent.click(screen.getByRole('button', { name: 'Reset' })); + expect(screen.getByText('ok')).toBeInTheDocument(); + errSpy.mockRestore(); + }); +}); diff --git a/packages/billing/src/BillingErrorBoundary.tsx b/packages/billing/src/BillingErrorBoundary.tsx new file mode 100644 index 00000000..ea8f82a3 --- /dev/null +++ b/packages/billing/src/BillingErrorBoundary.tsx @@ -0,0 +1,32 @@ +import React, { type ErrorInfo, type ReactNode } from 'react'; + +type Props = { + children: ReactNode; + fallback: (error: Error, reset: () => void) => ReactNode; +}; + +type State = { error: Error | null }; + +export class BillingErrorBoundary extends React.Component { + override state: State = { error: null }; + + static getDerivedStateFromError(error: Error): State { + return { error }; + } + + override componentDidCatch(error: Error, info: ErrorInfo): void { + // eslint-disable-next-line no-console -- error boundary diagnostics + console.error('BillingErrorBoundary', error, info.componentStack); + } + + reset = (): void => { + this.setState({ error: null }); + }; + + override render(): ReactNode { + if (this.state.error) { + return this.props.fallback(this.state.error, this.reset); + } + return this.props.children; + } +} diff --git a/packages/billing/src/BillingProvider.test.tsx b/packages/billing/src/BillingProvider.test.tsx new file mode 100644 index 00000000..f64d0b28 --- /dev/null +++ b/packages/billing/src/BillingProvider.test.tsx @@ -0,0 +1,309 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import React, { useEffect } from 'react'; +import { ZodError } from 'zod'; +import { BillingProvider } from './BillingProvider.js'; +import { useBillingContext } from './hooks/useBillingContext.js'; +import { productBillingConfigSchema } from './schema.js'; +import { testBillingConfig, testSubscription } from './test/billingFixtures.js'; + +function Reader() { + const { userId, subscription } = useBillingContext(); + return ( +
+ {userId ?? 'null'} + {subscription?.id ?? 'none'} +
+ ); +} + +function SubscriptionErrorReader() { + const { subscriptionError } = useBillingContext(); + return ( + + {subscriptionError ? subscriptionError.kind : 'none'} + + ); +} + +const auth = vi.hoisted(() => { + const state = { session: null as { user?: { id: string } } | null }; + const unsubscribe = vi.fn(); + const getSession = vi.fn(() => + Promise.resolve({ data: { session: state.session } }) + ); + const authListener = { + callback: null as + | ((_event: string, session: { user?: { id: string } } | null) => void) + | null, + }; + const onAuthStateChange = vi.fn( + ( + cb: (_event: string, session: { user?: { id: string } } | null) => void + ) => { + authListener.callback = cb; + return { data: { subscription: { unsubscribe } } }; + } + ); + return { state, getSession, onAuthStateChange, unsubscribe, authListener }; +}); + +const db = vi.hoisted(() => { + const maybeSingle = vi.fn(); + const subChain = { + select: vi.fn(() => subChain), + eq: vi.fn(() => subChain), + maybeSingle, + }; + const rpc = vi.fn(); + const subscriptionRealtime = { + handler: null as + | ((payload: { new?: unknown; old?: unknown }) => void) + | null, + }; + const ch = { + on: vi.fn( + ( + type: string, + _cfg: unknown, + cb: (payload: { new?: unknown; old?: unknown }) => void + ) => { + if (type === 'postgres_changes') subscriptionRealtime.handler = cb; + return ch; + } + ), + subscribe: vi.fn(), + }; + const channel = vi.fn(() => ch); + const removeChannel = vi.fn(); + const from = vi.fn((table: string) => { + if (table === 'billing_subscriptions') return subChain; + throw new Error(`unexpected table ${table}`); + }); + return { + maybeSingle, + rpc, + from, + channel, + ch, + removeChannel, + subscriptionRealtime, + }; +}); + +const supabase = { + auth: { + getSession: auth.getSession, + onAuthStateChange: auth.onAuthStateChange, + }, + from: db.from, + rpc: db.rpc, + channel: db.channel, + removeChannel: db.removeChannel, +} as unknown as import('@supabase/supabase-js').SupabaseClient; + +const providerProps = { + supabase, + checkoutSuccessUrl: 'https://ok', + checkoutCancelUrl: 'https://cancel', + portalReturnUrl: 'https://portal', +}; + +describe('BillingProvider', () => { + beforeEach(() => { + vi.clearAllMocks(); + auth.state.session = null; + db.maybeSingle.mockResolvedValue({ data: null, error: null }); + db.rpc.mockResolvedValue({ error: null }); + }); + + it('renders children with null user when no session', async () => { + render( + + + + ); + await waitFor(() => { + expect(screen.getByTestId('uid').textContent).toBe('null'); + }); + }); + + it('loads subscription after auth session and RPC', async () => { + auth.state.session = { user: { id: 'u-99' } }; + const row = { ...testSubscription(), user_id: 'u-99' }; + db.maybeSingle.mockResolvedValue({ data: row, error: null }); + render( + + + + ); + await waitFor(() => { + expect(screen.getByTestId('uid').textContent).toBe('u-99'); + }); + await waitFor(() => { + expect(screen.getByTestId('subid').textContent).toBe('sub_1'); + }); + expect(db.rpc).toHaveBeenCalledWith('ensure_billing_subscription', { + p_product_id: 'test_product', + }); + expect(db.from).toHaveBeenCalledWith('billing_subscriptions'); + expect(db.channel).toHaveBeenCalled(); + }); + + it('rejects invalid config with the same schema BillingProvider uses', () => { + expect(() => + productBillingConfigSchema.parse({ + productId: '', + displayName: 'x', + plans: [], + }) + ).toThrow(ZodError); + }); + + it('surfaces subscription query errors', async () => { + auth.state.session = { user: { id: 'u-err' } }; + db.maybeSingle.mockResolvedValue({ + data: null, + error: new Error('select failed'), + }); + render( + + + + ); + await waitFor(() => { + expect(screen.getByTestId('suberr').textContent).toBe('unknown'); + }); + }); + + it('surfaces ensure_billing_subscription RPC errors', async () => { + auth.state.session = { user: { id: 'u-rpc' } }; + db.rpc.mockResolvedValue({ error: new Error('rpc failed') }); + render( + + + + ); + await waitFor(() => { + expect(screen.getByTestId('suberr').textContent).toBe('unknown'); + }); + }); + + it('reloads subscription when realtime payload matches product', async () => { + auth.state.session = { user: { id: 'u-99' } }; + const row = { ...testSubscription(), user_id: 'u-99' }; + db.maybeSingle.mockResolvedValue({ data: row, error: null }); + render( + + + + ); + await waitFor(() => { + expect(screen.getByTestId('subid').textContent).toBe('sub_1'); + }); + db.maybeSingle.mockClear(); + db.subscriptionRealtime.handler?.({ + new: { ...row, product_id: 'test_product' }, + old: null, + }); + await waitFor(() => { + expect(db.maybeSingle).toHaveBeenCalled(); + }); + }); + + it('updates user id from auth state changes', async () => { + auth.state.session = { user: { id: 'u-a' } }; + db.maybeSingle.mockResolvedValue({ + data: { ...testSubscription(), user_id: 'u-a' }, + error: null, + }); + render( + + + + ); + await waitFor(() => { + expect(screen.getByTestId('uid').textContent).toBe('u-a'); + }); + auth.authListener.callback?.('SIGNED_IN', { user: { id: 'u-b' } }); + await waitFor(() => { + expect(screen.getByTestId('uid').textContent).toBe('u-b'); + }); + }); + + it('removes realtime channel when user signs out', async () => { + auth.state.session = { user: { id: 'u-out' } }; + db.maybeSingle.mockResolvedValue({ + data: { ...testSubscription(), user_id: 'u-out' }, + error: null, + }); + render( + + + + ); + await waitFor(() => { + expect(db.channel).toHaveBeenCalled(); + }); + db.removeChannel.mockClear(); + auth.authListener.callback?.('SIGNED_OUT', null); + await waitFor(() => { + expect(screen.getByTestId('uid').textContent).toBe('null'); + }); + expect(db.removeChannel).toHaveBeenCalled(); + }); + + function EarlyRefreshCaller() { + const { refreshSubscription } = useBillingContext(); + useEffect(() => { + void refreshSubscription(); + }, [refreshSubscription]); + return ok; + } + + it('refreshSubscription no-ops when there is no user', async () => { + auth.state.session = null; + render( + + + + ); + await waitFor(() => { + expect(screen.getByTestId('early').textContent).toBe('ok'); + }); + expect(db.from).not.toHaveBeenCalledWith('billing_subscriptions'); + }); + + function ManualRefreshButton() { + const { refreshSubscription } = useBillingContext(); + return ( + + ); + } + + it('refreshSubscription reloads subscription when user is signed in', async () => { + auth.state.session = { user: { id: 'u-refresh' } }; + const row = { ...testSubscription(), user_id: 'u-refresh' }; + db.maybeSingle.mockResolvedValue({ data: row, error: null }); + render( + + + + + ); + await waitFor(() => { + expect(screen.getByTestId('subid').textContent).toBe('sub_1'); + }); + db.maybeSingle.mockClear(); + screen.getByTestId('refresh-sub').click(); + await waitFor(() => { + expect(db.maybeSingle).toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/billing/src/BillingProvider.tsx b/packages/billing/src/BillingProvider.tsx new file mode 100644 index 00000000..4b902c6e --- /dev/null +++ b/packages/billing/src/BillingProvider.tsx @@ -0,0 +1,210 @@ +import type { SupabaseClient } from '@supabase/supabase-js'; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { BillingConfigReactContext, BillingReactContext } from './context.js'; +import { billingError, mapUnknownError } from './errors.js'; +import type { ProductBillingConfig } from './schema.js'; +import { productBillingConfigSchema } from './schema.js'; +import type { BillingContextValue, SubscriptionRow } from './types.js'; + +export type BillingProviderProps

= { + supabase: SupabaseClient; + config: P; + children: React.ReactNode; + checkoutSuccessUrl: string; + checkoutCancelUrl: string; + portalReturnUrl: string; + stripeFunctionName?: string; +}; + +export function BillingProvider

({ + supabase, + config: rawConfig, + children, + checkoutSuccessUrl, + checkoutCancelUrl, + portalReturnUrl, + stripeFunctionName = 'billing-stripe', +}: BillingProviderProps

): React.ReactElement { + const config = useMemo( + () => productBillingConfigSchema.parse(rawConfig) as P, + [rawConfig] + ); + const [userId, setUserId] = useState(null); + const [subscription, setSubscription] = useState( + null + ); + const [subscriptionLoading, setSubscriptionLoading] = useState(true); + const [subscriptionError, setSubscriptionError] = useState | null>(null); + const channelRef = useRef | null>(null); + + const loadSubscription = useCallback( + async (uid: string) => { + setSubscriptionLoading(true); + setSubscriptionError(null); + try { + const { data, error } = await supabase + .from('billing_subscriptions') + .select('*') + .eq('user_id', uid) + .eq('product_id', config.productId) + .maybeSingle(); + if (error) throw error; + setSubscription((data as SubscriptionRow | null) ?? null); + } catch (e) { + setSubscriptionError(mapUnknownError(e)); + setSubscription(null); + } finally { + setSubscriptionLoading(false); + } + }, + [supabase, config.productId] + ); + + const refreshSubscription = useCallback(async () => { + if (!userId) return; + await loadSubscription(userId); + }, [userId, loadSubscription]); + + useEffect(() => { + let cancelled = false; + void (async () => { + const { + data: { session }, + } = await supabase.auth.getSession(); + if (cancelled) return; + setUserId(session?.user?.id ?? null); + })(); + const { + data: { subscription: authSub }, + } = supabase.auth.onAuthStateChange((_event, session) => { + setUserId(session?.user?.id ?? null); + }); + return () => { + cancelled = true; + authSub.unsubscribe(); + }; + }, [supabase]); + + useEffect(() => { + if (!userId) { + setSubscription(null); + setSubscriptionLoading(false); + return; + } + let cancelled = false; + void (async () => { + try { + const { error } = await supabase.rpc('ensure_billing_subscription', { + p_product_id: config.productId, + }); + if (error) throw error; + if (!cancelled) await loadSubscription(userId); + } catch (e) { + if (!cancelled) setSubscriptionError(mapUnknownError(e)); + } + })(); + return () => { + cancelled = true; + }; + }, [userId, supabase, config.productId, loadSubscription]); + + useEffect(() => { + if (!userId) { + if (channelRef.current) { + void supabase.removeChannel(channelRef.current); + channelRef.current = null; + } + return; + } + const filter = `user_id=eq.${userId}`; + const ch = supabase + .channel(`billing_subscriptions:${config.productId}:${userId}`) + .on( + 'postgres_changes', + { + event: '*', + schema: 'public', + table: 'billing_subscriptions', + filter, + }, + payload => { + const row = (payload.new ?? payload.old) as + | SubscriptionRow + | undefined; + if (row && row.product_id === config.productId) { + void loadSubscription(userId); + } + } + ) + .subscribe(); + channelRef.current = ch; + return () => { + void supabase.removeChannel(ch); + channelRef.current = null; + }; + }, [userId, supabase, config.productId, loadSubscription]); + + /** After Stripe Checkout, the row is written by `stripe-webhook` (async). Poll briefly so the UI updates even if Realtime lags or the user lands before the webhook finishes. */ + useEffect(() => { + if (typeof window === 'undefined' || !userId) return; + const sp = new URLSearchParams(window.location.search); + if (sp.get('checkout') !== 'success') return; + + let attempts = 0; + const maxAttempts = 20; + void refreshSubscription(); + const id = window.setInterval(() => { + attempts += 1; + void refreshSubscription(); + if (attempts >= maxAttempts) window.clearInterval(id); + }, 2000); + return () => window.clearInterval(id); + }, [userId, refreshSubscription]); + + const value = useMemo>( + () => ({ + supabase, + config, + userId, + subscription, + subscriptionLoading, + subscriptionError, + refreshSubscription, + checkoutSuccessUrl, + checkoutCancelUrl, + portalReturnUrl, + stripeFunctionName, + }), + [ + supabase, + config, + userId, + subscription, + subscriptionLoading, + subscriptionError, + refreshSubscription, + checkoutSuccessUrl, + checkoutCancelUrl, + portalReturnUrl, + stripeFunctionName, + ] + ); + + return ( + + } + > + {children} + + + ); +} diff --git a/packages/billing/src/billingClient.test.ts b/packages/billing/src/billingClient.test.ts new file mode 100644 index 00000000..de198be4 --- /dev/null +++ b/packages/billing/src/billingClient.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + canUserAccessFeature, + getPlanById, + getRemainingUsage, + hasExceededLimit, +} from './billingClient.js'; + +function mockSupabase(chain: { + sub?: { plan_id: string } | null; + plan?: Record | null; + rpcRemaining?: Record | null; + rpcExceeded?: boolean; +}) { + const from = vi.fn((table: string) => { + if (table === 'billing_subscriptions') { + return { + select: () => ({ + eq: () => ({ + eq: () => ({ + maybeSingle: async () => ({ + data: chain.sub ?? null, + error: null, + }), + }), + }), + }), + }; + } + if (table === 'billing_plans') { + return { + select: () => ({ + eq: () => ({ + eq: () => ({ + maybeSingle: async () => ({ + data: chain.plan ?? null, + error: null, + }), + }), + }), + }), + }; + } + throw new Error(`unexpected table ${table}`); + }); + + const rpc = vi.fn(async (name: string) => { + if (name === 'billing_get_remaining_usage') { + return { data: chain.rpcRemaining ?? null, error: null }; + } + if (name === 'billing_has_exceeded_limit') { + return { data: chain.rpcExceeded ?? false, error: null }; + } + return { data: null, error: null }; + }); + + return { + from, + rpc, + } as unknown as import('@supabase/supabase-js').SupabaseClient; +} + +describe('billingClient', () => { + it('getRemainingUsage maps RPC payload', async () => { + const supabase = mockSupabase({ + rpcRemaining: { + used: 2, + limit: 10, + remaining: 8, + periodEnd: '2026-01-31', + periodStart: '2026-01-01', + }, + }); + const r = await getRemainingUsage(supabase, 'beakerstack', 'ai_summarize'); + expect(r).toEqual({ + used: 2, + limit: 10, + remaining: 8, + periodEnd: '2026-01-31', + periodStart: '2026-01-01', + }); + }); + + it('hasExceededLimit returns boolean from RPC', async () => { + const supabase = mockSupabase({ rpcExceeded: true }); + await expect( + hasExceededLimit(supabase, 'beakerstack', 'ai_summarize') + ).resolves.toBe(true); + }); + + it('getPlanById returns plan row', async () => { + const supabase = mockSupabase({ + plan: { + id: 'beakerstack_free', + product_id: 'beakerstack', + display_name: 'Free', + price_cents: 0, + }, + }); + const p = await getPlanById(supabase, 'beakerstack', 'beakerstack_free'); + expect(p?.display_name).toBe('Free'); + }); + + it('canUserAccessFeature reads subscription plan features', async () => { + const supabase = mockSupabase({ + sub: { plan_id: 'beakerstack_pro' }, + plan: { + id: 'beakerstack_pro', + product_id: 'beakerstack', + features: { feature_a: true }, + }, + }); + await expect( + canUserAccessFeature(supabase, 'user-1', 'beakerstack', 'feature_a') + ).resolves.toBe(true); + }); + + it('getRemainingUsage maps omitted limit and remaining to null', async () => { + const supabase = mockSupabase({ + rpcRemaining: { + used: 1, + periodEnd: '2026-01-31', + periodStart: '2026-01-01', + }, + }); + const r = await getRemainingUsage(supabase, 'beakerstack', 'ai_summarize'); + expect(r).toEqual({ + used: 1, + limit: null, + remaining: null, + periodEnd: '2026-01-31', + periodStart: '2026-01-01', + }); + }); + + it('getRemainingUsage returns null when unauthenticated', async () => { + const supabase = mockSupabase({ + rpcRemaining: { error: 'unauthenticated' }, + }); + await expect( + getRemainingUsage(supabase, 'beakerstack', 'ai_summarize') + ).resolves.toBeNull(); + }); +}); diff --git a/packages/billing/src/billingClient.ts b/packages/billing/src/billingClient.ts new file mode 100644 index 00000000..777baa38 --- /dev/null +++ b/packages/billing/src/billingClient.ts @@ -0,0 +1,95 @@ +import type { SupabaseClient } from '@supabase/supabase-js'; +import { isFeatureAccessible, readPlanFeatureValue } from './featureAccess.js'; +import type { Plan } from './types.js'; + +export type RemainingUsageResult = { + used: number; + limit: number | null; + remaining: number | null; + periodEnd: string; + periodStart: string; +}; + +/** + * Spec-aligned name for remaining usage (wraps `billing_get_remaining_usage` RPC). + */ +export async function getRemainingUsage( + supabase: SupabaseClient, + productId: string, + eventType: string +): Promise { + const { data, error } = await supabase.rpc('billing_get_remaining_usage', { + p_product_id: productId, + p_event_type: eventType, + }); + if (error) throw error; + const j = data as Record | null; + if (!j || j['error'] === 'unauthenticated') return null; + return { + used: Number(j['used'] ?? 0), + limit: + j['limit'] === null || typeof j['limit'] === 'undefined' + ? null + : Number(j['limit']), + remaining: + j['remaining'] === null || typeof j['remaining'] === 'undefined' + ? null + : Number(j['remaining']), + periodEnd: String(j['periodEnd'] ?? ''), + periodStart: String(j['periodStart'] ?? ''), + }; +} + +/** Spec-aligned name (wraps `billing_has_exceeded_limit` RPC). */ +export async function hasExceededLimit( + supabase: SupabaseClient, + productId: string, + eventType: string +): Promise { + const { data, error } = await supabase.rpc('billing_has_exceeded_limit', { + p_product_id: productId, + p_event_type: eventType, + }); + if (error) throw error; + return Boolean(data); +} + +/** Spec `getPlan(planId)` — loads one plan row for a product. */ +export async function getPlanById( + supabase: SupabaseClient, + productId: string, + planId: string +): Promise { + const { data, error } = await supabase + .from('billing_plans') + .select('*') + .eq('product_id', productId) + .eq('id', planId) + .maybeSingle(); + if (error) throw error; + return (data as Plan | null) ?? null; +} + +/** + * Resolves current subscription plan features and applies boolean accessibility. + * For **numeric** feature caps, combine with `getRemainingUsage` / `hasExceededLimit`. + */ +export async function canUserAccessFeature( + supabase: SupabaseClient, + userId: string, + productId: string, + featureName: string +): Promise { + const { data: sub, error } = await supabase + .from('billing_subscriptions') + .select('plan_id') + .eq('user_id', userId) + .eq('product_id', productId) + .maybeSingle(); + if (error) throw error; + if (!sub?.plan_id) return false; + const plan = await getPlanById(supabase, productId, sub.plan_id); + return isFeatureAccessible(plan?.features, featureName); +} + +export { readPlanFeatureValue, isFeatureAccessible }; diff --git a/packages/billing/src/client.test.ts b/packages/billing/src/client.test.ts new file mode 100644 index 00000000..da314de8 --- /dev/null +++ b/packages/billing/src/client.test.ts @@ -0,0 +1,9 @@ +import { describe, expect, it } from 'vitest'; + +describe('@beakerstack/billing/client', () => { + it('re-exports provider and web components', async () => { + const mod = await import('./client.js'); + expect(mod.BillingProvider).toBeTypeOf('function'); + expect(mod.FeatureGate).toBeTypeOf('function'); + }); +}); diff --git a/packages/billing/src/client.ts b/packages/billing/src/client.ts new file mode 100644 index 00000000..c6a2eeec --- /dev/null +++ b/packages/billing/src/client.ts @@ -0,0 +1,4 @@ +'use client'; + +export * from './index.js'; +export * from './web.js'; diff --git a/packages/billing/src/components/CustomerPortalLink.native.test.tsx b/packages/billing/src/components/CustomerPortalLink.native.test.tsx new file mode 100644 index 00000000..646cfa86 --- /dev/null +++ b/packages/billing/src/components/CustomerPortalLink.native.test.tsx @@ -0,0 +1,31 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import React from 'react'; +import { CustomerPortalLink } from './CustomerPortalLink.native.js'; +import { useCustomerPortal } from '../hooks/useCustomerPortal.js'; + +vi.mock('../hooks/useCustomerPortal.js', () => ({ + useCustomerPortal: vi.fn(), +})); + +describe('CustomerPortalLink (native)', () => { + beforeEach(() => { + vi.mocked(useCustomerPortal).mockReturnValue({ + openPortal: vi.fn().mockResolvedValue('https://portal'), + pending: false, + error: null, + }); + }); + + it('wraps string children in Text and calls openPortal on press', () => { + const openPortal = vi.fn().mockResolvedValue('https://portal'); + vi.mocked(useCustomerPortal).mockReturnValue({ + openPortal, + pending: false, + error: null, + }); + render(Portal); + fireEvent.click(screen.getByText('Portal')); + expect(openPortal).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/billing/src/components/CustomerPortalLink.native.tsx b/packages/billing/src/components/CustomerPortalLink.native.tsx new file mode 100644 index 00000000..cbc2b67e --- /dev/null +++ b/packages/billing/src/components/CustomerPortalLink.native.tsx @@ -0,0 +1,21 @@ +import type { ReactElement } from 'react'; +import { Pressable, Text } from 'react-native'; +import { useCustomerPortal } from '../hooks/useCustomerPortal.js'; +import type { ProductBillingConfig } from '../schema.js'; +import type { CustomerPortalLinkProps } from './CustomerPortalLink.types.js'; + +export function CustomerPortalLink

({ + children, + style, +}: CustomerPortalLinkProps): ReactElement { + const { openPortal, pending } = useCustomerPortal

(); + return ( + void openPortal()} + > + {typeof children === 'string' ? {children} : children} + + ); +} diff --git a/packages/billing/src/components/CustomerPortalLink.types.ts b/packages/billing/src/components/CustomerPortalLink.types.ts new file mode 100644 index 00000000..268cfb2a --- /dev/null +++ b/packages/billing/src/components/CustomerPortalLink.types.ts @@ -0,0 +1,7 @@ +import type { ReactNode } from 'react'; + +export type CustomerPortalLinkProps = { + children: ReactNode; + className?: string; + style?: object; +}; diff --git a/packages/billing/src/components/CustomerPortalLink.web.test.tsx b/packages/billing/src/components/CustomerPortalLink.web.test.tsx new file mode 100644 index 00000000..4af85b03 --- /dev/null +++ b/packages/billing/src/components/CustomerPortalLink.web.test.tsx @@ -0,0 +1,41 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import React from 'react'; +import { CustomerPortalLink } from './CustomerPortalLink.web.js'; +import { useCustomerPortal } from '../hooks/useCustomerPortal.js'; + +vi.mock('../hooks/useCustomerPortal.js', () => ({ + useCustomerPortal: vi.fn(), +})); + +describe('CustomerPortalLink (web)', () => { + beforeEach(() => { + vi.mocked(useCustomerPortal).mockReturnValue({ + openPortal: vi.fn().mockResolvedValue('https://portal'), + pending: false, + error: null, + }); + }); + + it('invokes openPortal on click', () => { + const openPortal = vi.fn().mockResolvedValue('https://portal'); + vi.mocked(useCustomerPortal).mockReturnValue({ + openPortal, + pending: false, + error: null, + }); + render(Manage billing); + fireEvent.click(screen.getByRole('button', { name: 'Manage billing' })); + expect(openPortal).toHaveBeenCalledTimes(1); + }); + + it('disables while pending', () => { + vi.mocked(useCustomerPortal).mockReturnValue({ + openPortal: vi.fn(), + pending: true, + error: null, + }); + render(Wait); + expect(screen.getByRole('button', { name: 'Wait' })).toBeDisabled(); + }); +}); diff --git a/packages/billing/src/components/CustomerPortalLink.web.tsx b/packages/billing/src/components/CustomerPortalLink.web.tsx new file mode 100644 index 00000000..10d519ff --- /dev/null +++ b/packages/billing/src/components/CustomerPortalLink.web.tsx @@ -0,0 +1,23 @@ +import type { ReactElement } from 'react'; +import { useCustomerPortal } from '../hooks/useCustomerPortal.js'; +import type { ProductBillingConfig } from '../schema.js'; +import type { CustomerPortalLinkProps } from './CustomerPortalLink.types.js'; + +export function CustomerPortalLink

({ + children, + className, + style, +}: CustomerPortalLinkProps): ReactElement { + const { openPortal, pending } = useCustomerPortal

(); + return ( + + ); +} diff --git a/packages/billing/src/components/FeatureGate.native.test.tsx b/packages/billing/src/components/FeatureGate.native.test.tsx new file mode 100644 index 00000000..6016b261 --- /dev/null +++ b/packages/billing/src/components/FeatureGate.native.test.tsx @@ -0,0 +1,73 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import { FeatureGate } from './FeatureGate.native.js'; +import { useFeature } from '../hooks/useFeature.js'; + +vi.mock('../hooks/useFeature.js', () => ({ useFeature: vi.fn() })); + +describe('FeatureGate (native)', () => { + beforeEach(() => { + vi.mocked(useFeature).mockReset(); + }); + + it('renders children when enabled', () => { + vi.mocked(useFeature).mockReturnValue({ + enabled: true, + value: true, + loading: false, + error: null, + }); + render( + no}> + inside + + ); + expect(screen.getByText('inside')).toBeInTheDocument(); + }); + + it('renders fallback when feature key is missing', () => { + vi.mocked(useFeature).mockReturnValue({ + enabled: true, + value: true, + loading: false, + error: null, + }); + render( + blocked}> + inside + + ); + expect(screen.getByText('blocked')).toBeInTheDocument(); + }); + + it('renders empty view while loading', () => { + vi.mocked(useFeature).mockReturnValue({ + enabled: false, + value: false, + loading: true, + error: null, + }); + const { container } = render( + no}> + inside + + ); + expect(container.querySelector('span')).toBeNull(); + }); + + it('renders fallback when feature disabled', () => { + vi.mocked(useFeature).mockReturnValue({ + enabled: false, + value: false, + loading: false, + error: null, + }); + render( + no}> + inside + + ); + expect(screen.getByText('no')).toBeInTheDocument(); + }); +}); diff --git a/packages/billing/src/components/FeatureGate.native.tsx b/packages/billing/src/components/FeatureGate.native.tsx new file mode 100644 index 00000000..0f623bd7 --- /dev/null +++ b/packages/billing/src/components/FeatureGate.native.tsx @@ -0,0 +1,24 @@ +import type { ReactElement } from 'react'; +import { View } from 'react-native'; +import { useFeature } from '../hooks/useFeature.js'; +import type { InferFeatureKeys, ProductBillingConfig } from '../schema.js'; +import type { FeatureGateProps } from './FeatureGate.types.js'; + +export function FeatureGate

( + props: FeatureGateProps

+): ReactElement { + const { feature, featureName, fallback, children, style } = props; + const rawKey = feature ?? featureName; + if (rawKey == null || rawKey === '') { + return <>{fallback}; + } + const key = rawKey as InferFeatureKeys

& string; + const { enabled, loading } = useFeature(key); + if (loading) { + return ; + } + if (!enabled) { + return <>{fallback}; + } + return <>{children}; +} diff --git a/packages/billing/src/components/FeatureGate.types.ts b/packages/billing/src/components/FeatureGate.types.ts new file mode 100644 index 00000000..f5005634 --- /dev/null +++ b/packages/billing/src/components/FeatureGate.types.ts @@ -0,0 +1,14 @@ +import type { ReactNode } from 'react'; +import type { InferFeatureKeys } from '../schema.js'; +import type { ProductBillingConfig } from '../schema.js'; + +export type FeatureGateProps

= { + /** @deprecated Prefer {@link featureName} (core spec name). */ + feature?: InferFeatureKeys

& string; + /** Core spec: entitlement key on the current plan. */ + featureName?: InferFeatureKeys

& string; + fallback: ReactNode; + children: ReactNode; + className?: string; + style?: object; +}; diff --git a/packages/billing/src/components/FeatureGate.web.test.tsx b/packages/billing/src/components/FeatureGate.web.test.tsx new file mode 100644 index 00000000..50b4a40b --- /dev/null +++ b/packages/billing/src/components/FeatureGate.web.test.tsx @@ -0,0 +1,75 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import { FeatureGate } from './FeatureGate.web.js'; +import { useFeature } from '../hooks/useFeature.js'; + +vi.mock('../hooks/useFeature.js', () => ({ useFeature: vi.fn() })); + +describe('FeatureGate (web)', () => { + beforeEach(() => { + vi.mocked(useFeature).mockReset(); + }); + + it('renders loading placeholder', () => { + vi.mocked(useFeature).mockReturnValue({ + enabled: false, + value: null, + loading: true, + error: null, + }); + const { container } = render( + no}> + yes + + ); + expect(container.querySelector('span')).toBeInTheDocument(); + expect(screen.queryByText('yes')).not.toBeInTheDocument(); + }); + + it('renders fallback when disabled', () => { + vi.mocked(useFeature).mockReturnValue({ + enabled: false, + value: false, + loading: false, + error: null, + }); + render( + blocked}> + inside + + ); + expect(screen.getByText('blocked')).toBeInTheDocument(); + }); + + it('renders children when enabled', () => { + vi.mocked(useFeature).mockReturnValue({ + enabled: true, + value: true, + loading: false, + error: null, + }); + render( + no}> + inside + + ); + expect(screen.getByText('inside')).toBeInTheDocument(); + }); + + it('accepts featureName as spec alias', () => { + vi.mocked(useFeature).mockReturnValue({ + enabled: true, + value: true, + loading: false, + error: null, + }); + render( + no}> + inside + + ); + expect(useFeature).toHaveBeenCalled(); + expect(screen.getByText('inside')).toBeInTheDocument(); + }); +}); diff --git a/packages/billing/src/components/FeatureGate.web.tsx b/packages/billing/src/components/FeatureGate.web.tsx new file mode 100644 index 00000000..8e70dc68 --- /dev/null +++ b/packages/billing/src/components/FeatureGate.web.tsx @@ -0,0 +1,23 @@ +import type { ReactElement } from 'react'; +import { useFeature } from '../hooks/useFeature.js'; +import type { InferFeatureKeys, ProductBillingConfig } from '../schema.js'; +import type { FeatureGateProps } from './FeatureGate.types.js'; + +export function FeatureGate

( + props: FeatureGateProps

+): ReactElement { + const { feature, featureName, fallback, children, className, style } = props; + const rawKey = feature ?? featureName; + if (rawKey == null || rawKey === '') { + return <>{fallback}; + } + const key = rawKey as InferFeatureKeys

& string; + const { enabled, loading } = useFeature(key); + if (loading) { + return ; + } + if (!enabled) { + return <>{fallback}; + } + return <>{children}; +} diff --git a/packages/billing/src/components/PricingTable.native.test.tsx b/packages/billing/src/components/PricingTable.native.test.tsx new file mode 100644 index 00000000..42c2a2bb --- /dev/null +++ b/packages/billing/src/components/PricingTable.native.test.tsx @@ -0,0 +1,91 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import React from 'react'; +import { PricingTable } from './PricingTable.native.js'; +import { usePlanCatalog } from '../hooks/usePlanCatalog.js'; +import { usePlan } from '../hooks/usePlan.js'; +import { testPlan } from '../test/billingFixtures.js'; + +vi.mock('../hooks/usePlanCatalog.js', () => ({ usePlanCatalog: vi.fn() })); +vi.mock('../hooks/usePlan.js', () => ({ usePlan: vi.fn() })); + +describe('PricingTable (native)', () => { + const p1 = testPlan({ id: 'p1', display_name: 'Pro', price_cents: 999 }); + + beforeEach(() => { + vi.mocked(usePlanCatalog).mockReturnValue({ + plans: [p1], + loading: false, + error: null, + refresh: vi.fn(), + }); + vi.mocked(usePlan).mockReturnValue({ + data: null, + loading: false, + error: null, + }); + }); + + it('renders plan row', () => { + render(); + expect(screen.getByText('Pro')).toBeInTheDocument(); + }); + + it('invokes onSelectPlan from pressable', () => { + const onSelectPlan = vi.fn(); + render(); + fireEvent.click(screen.getByText('Select')); + expect(onSelectPlan).toHaveBeenCalledWith('p1'); + }); + + it('shows loading state', () => { + vi.mocked(usePlanCatalog).mockReturnValue({ + plans: [], + loading: true, + error: null, + refresh: vi.fn(), + }); + render(); + expect(screen.getByText('Loading…')).toBeInTheDocument(); + }); + + it('prefers onCheckout over onSelectPlan', () => { + const onCheckout = vi.fn(); + const onSelectPlan = vi.fn(); + render( + + ); + fireEvent.click(screen.getByText('Select')); + expect(onCheckout).toHaveBeenCalledWith('p1'); + expect(onSelectPlan).not.toHaveBeenCalled(); + }); + + it('shows trial note for paid plans with trial days', () => { + const paid = testPlan({ + id: 'p2', + display_name: 'Pro', + price_cents: 999, + trial_period_days: 14, + }); + vi.mocked(usePlanCatalog).mockReturnValue({ + plans: [paid], + loading: false, + error: null, + refresh: vi.fn(), + }); + render(); + expect(screen.getByText(/14-day trial/)).toBeInTheDocument(); + }); + + it('highlights plan when highlightPlanId matches', () => { + vi.mocked(usePlan).mockReturnValue({ + data: null, + loading: false, + error: null, + }); + render( + + ); + expect(screen.getByText('Pro')).toBeInTheDocument(); + }); +}); diff --git a/packages/billing/src/components/PricingTable.native.tsx b/packages/billing/src/components/PricingTable.native.tsx new file mode 100644 index 00000000..fb10eaad --- /dev/null +++ b/packages/billing/src/components/PricingTable.native.tsx @@ -0,0 +1,77 @@ +import type { ReactElement } from 'react'; +import { FlatList, Pressable, StyleSheet, Text, View } from 'react-native'; +import { usePlan } from '../hooks/usePlan.js'; +import { usePlanCatalog } from '../hooks/usePlanCatalog.js'; +import type { ProductBillingConfig } from '../schema.js'; +import type { PricingTableProps } from './PricingTable.types.js'; + +export function PricingTable

({ + onSelectPlan, + onCheckout, + highlightCurrent, + highlightPlanId, + productId: _productId, + currentUserId: _currentUserId, + style, +}: PricingTableProps): ReactElement { + void _productId; + void _currentUserId; + const onPlanChosen = onCheckout ?? onSelectPlan; + const { plans, loading } = usePlanCatalog

(); + const { data: currentPlan } = usePlan

(); + + if (loading) { + return ( + + Loading… + + ); + } + + return ( + item.id} + renderItem={({ item: p }) => { + const isCurrent = + (highlightCurrent && currentPlan?.id === p.id) || + (highlightPlanId != null && + highlightPlanId !== '' && + p.id === highlightPlanId); + return ( + + {p.display_name} + + {(p.price_cents / 100).toFixed(2)} USD / {p.billing_period} + + {p.price_cents > 0 && p.trial_period_days > 0 ? ( + + {p.trial_period_days}-day trial on checkout + + ) : null} + {onPlanChosen ? ( + onPlanChosen(p.id)} style={styles.btn}> + Select + + ) : null} + + ); + }} + /> + ); +} + +const styles = StyleSheet.create({ + card: { + borderWidth: 1, + borderColor: '#e5e7eb', + padding: 12, + marginBottom: 8, + borderRadius: 8, + }, + cardHighlight: { borderColor: '#3b82f6', borderWidth: 2 }, + title: { fontWeight: '700' }, + btn: { marginTop: 8 }, + trialNote: { fontSize: 12, color: '#4b5563', marginTop: 4 }, +}); diff --git a/packages/billing/src/components/PricingTable.types.ts b/packages/billing/src/components/PricingTable.types.ts new file mode 100644 index 00000000..e053c458 --- /dev/null +++ b/packages/billing/src/components/PricingTable.types.ts @@ -0,0 +1,18 @@ +export type PricingTableProps = { + /** @deprecated Prefer {@link onCheckout} (core spec name). */ + onSelectPlan?: (planId: string) => void; + /** Core spec: invoked when user chooses a paid plan for checkout. */ + onCheckout?: (planId: string) => void; + /** @deprecated Prefer {@link highlightPlanId} or keep for “highlight current user’s plan”. */ + highlightCurrent?: boolean; + /** Core spec: highlight this plan id in the list when it matches a row. */ + highlightPlanId?: string | null; + /** + * Core spec surface; optional for analytics. Catalog is scoped by `BillingProvider` config. + */ + productId?: string; + /** Core spec surface; reserved for future use (e.g. admin viewing another user). */ + currentUserId?: string | null; + className?: string; + style?: object; +}; diff --git a/packages/billing/src/components/PricingTable.web.test.tsx b/packages/billing/src/components/PricingTable.web.test.tsx new file mode 100644 index 00000000..b859e5f9 --- /dev/null +++ b/packages/billing/src/components/PricingTable.web.test.tsx @@ -0,0 +1,55 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import React from 'react'; +import { PricingTable } from './PricingTable.web.js'; +import { usePlanCatalog } from '../hooks/usePlanCatalog.js'; +import { usePlan } from '../hooks/usePlan.js'; +import { testPlan } from '../test/billingFixtures.js'; + +vi.mock('../hooks/usePlanCatalog.js', () => ({ usePlanCatalog: vi.fn() })); +vi.mock('../hooks/usePlan.js', () => ({ usePlan: vi.fn() })); + +describe('PricingTable (web)', () => { + const p1 = testPlan({ id: 'p1', display_name: 'Pro', price_cents: 999 }); + const p2 = testPlan({ id: 'p2', display_name: 'Team', price_cents: 1999 }); + + beforeEach(() => { + vi.mocked(usePlanCatalog).mockReturnValue({ + plans: [p1, p2], + loading: false, + error: null, + refresh: vi.fn(), + }); + vi.mocked(usePlan).mockReturnValue({ + data: p1, + loading: false, + error: null, + }); + }); + + it('lists plans when loaded', () => { + render(); + expect(screen.getByText('Pro')).toBeInTheDocument(); + expect(screen.getByText('Team')).toBeInTheDocument(); + }); + + it('shows loading state', () => { + vi.mocked(usePlanCatalog).mockReturnValue({ + plans: [], + loading: true, + error: null, + refresh: vi.fn(), + }); + render(); + expect(screen.getByText('Loading…')).toBeInTheDocument(); + }); + + it('calls onSelectPlan', () => { + const onSelectPlan = vi.fn(); + render(); + const [, secondSelect] = screen.getAllByRole('button', { name: 'Select' }); + expect(secondSelect).toBeDefined(); + fireEvent.click(secondSelect); + expect(onSelectPlan).toHaveBeenCalledWith('p2'); + }); +}); diff --git a/packages/billing/src/components/PricingTable.web.tsx b/packages/billing/src/components/PricingTable.web.tsx new file mode 100644 index 00000000..3b40f747 --- /dev/null +++ b/packages/billing/src/components/PricingTable.web.tsx @@ -0,0 +1,61 @@ +import type { ReactElement } from 'react'; +import { usePlan } from '../hooks/usePlan.js'; +import { usePlanCatalog } from '../hooks/usePlanCatalog.js'; +import type { ProductBillingConfig } from '../schema.js'; +import type { PricingTableProps } from './PricingTable.types.js'; + +export function PricingTable

({ + onSelectPlan, + onCheckout, + highlightCurrent, + highlightPlanId, + productId: _productId, + currentUserId: _currentUserId, + className, + style, +}: PricingTableProps): ReactElement { + void _productId; + void _currentUserId; + const onPlanChosen = onCheckout ?? onSelectPlan; + const { plans, loading } = usePlanCatalog

(); + const { data: currentPlan } = usePlan

(); + + return ( +

+ {loading ? ( +

Loading…

+ ) : ( +
    + {plans.map(p => { + const isCurrent = + (highlightCurrent && currentPlan?.id === p.id) || + (highlightPlanId != null && + highlightPlanId !== '' && + p.id === highlightPlanId); + return ( +
  • + {p.display_name} —{' '} + {(p.price_cents / 100).toFixed(2)} USD / {p.billing_period} + {onPlanChosen ? ( +
    + +
    + ) : null} +
  • + ); + })} +
+ )} +
+ ); +} diff --git a/packages/billing/src/components/SubscriptionStatus.native.test.tsx b/packages/billing/src/components/SubscriptionStatus.native.test.tsx new file mode 100644 index 00000000..5b32c217 --- /dev/null +++ b/packages/billing/src/components/SubscriptionStatus.native.test.tsx @@ -0,0 +1,63 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import { SubscriptionStatus } from './SubscriptionStatus.native.js'; +import { useSubscription } from '../hooks/useSubscription.js'; +import { usePlan } from '../hooks/usePlan.js'; +import { testPlan, testSubscription } from '../test/billingFixtures.js'; + +vi.mock('../hooks/useSubscription.js', () => ({ useSubscription: vi.fn() })); +vi.mock('../hooks/usePlan.js', () => ({ usePlan: vi.fn() })); + +describe('SubscriptionStatus (native)', () => { + beforeEach(() => { + vi.mocked(useSubscription).mockReturnValue({ + data: testSubscription(), + loading: false, + error: null, + refresh: vi.fn(), + }); + vi.mocked(usePlan).mockReturnValue({ + data: testPlan(), + loading: false, + error: null, + }); + }); + + it('renders status line', () => { + render(); + expect(screen.getByText(/Status:/)).toBeInTheDocument(); + }); + + it('renders loading placeholder', () => { + vi.mocked(useSubscription).mockReturnValue({ + data: null, + loading: true, + error: null, + refresh: vi.fn(), + }); + render(); + expect(screen.getByText('…')).toBeInTheDocument(); + }); + + it('shows past_due billing warning', () => { + vi.mocked(useSubscription).mockReturnValue({ + data: testSubscription({ status: 'past_due' }), + loading: false, + error: null, + refresh: vi.fn(), + }); + render(); + expect(screen.getByText(/Payment issue/)).toBeInTheDocument(); + }); + + it('falls back to plan_id when plan row missing', () => { + vi.mocked(usePlan).mockReturnValue({ + data: null, + loading: false, + error: null, + }); + render(); + expect(screen.getByText(/plan_free/)).toBeInTheDocument(); + }); +}); diff --git a/packages/billing/src/components/SubscriptionStatus.native.tsx b/packages/billing/src/components/SubscriptionStatus.native.tsx new file mode 100644 index 00000000..86af2602 --- /dev/null +++ b/packages/billing/src/components/SubscriptionStatus.native.tsx @@ -0,0 +1,48 @@ +import type { ReactElement } from 'react'; +import { StyleSheet, Text, View } from 'react-native'; +import { usePlan } from '../hooks/usePlan.js'; +import { useSubscription } from '../hooks/useSubscription.js'; +import type { ProductBillingConfig } from '../schema.js'; +import { subscriptionStatusLabel } from '../utils/subscriptionStatusLabel.js'; +import type { SubscriptionStatusProps } from './SubscriptionStatus.types.js'; + +export function SubscriptionStatus

({ + style, +}: SubscriptionStatusProps): ReactElement { + const { data: sub, loading } = useSubscription

(); + const { data: plan } = usePlan

(); + + if (loading) { + return ( + + + + ); + } + + const renewal = sub?.current_period_end + ? new Date(sub.current_period_end).toLocaleDateString() + : '—'; + + return ( + + + Plan: {plan?.display_name ?? sub?.plan_id ?? '—'} + + + Status: {subscriptionStatusLabel(sub, renewal)} + + {sub?.status === 'past_due' ? ( + + Payment issue — update billing in the portal. + + ) : null} + Renews / period ends: {renewal} + + ); +} + +const styles = StyleSheet.create({ + line: { marginBottom: 4 }, + warn: { color: '#92400e', marginBottom: 4 }, +}); diff --git a/packages/billing/src/components/SubscriptionStatus.types.ts b/packages/billing/src/components/SubscriptionStatus.types.ts new file mode 100644 index 00000000..643d952f --- /dev/null +++ b/packages/billing/src/components/SubscriptionStatus.types.ts @@ -0,0 +1,4 @@ +export type SubscriptionStatusProps = { + className?: string; + style?: object; +}; diff --git a/packages/billing/src/components/SubscriptionStatus.web.test.tsx b/packages/billing/src/components/SubscriptionStatus.web.test.tsx new file mode 100644 index 00000000..680e1ed2 --- /dev/null +++ b/packages/billing/src/components/SubscriptionStatus.web.test.tsx @@ -0,0 +1,57 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import { SubscriptionStatus } from './SubscriptionStatus.web.js'; +import { useSubscription } from '../hooks/useSubscription.js'; +import { usePlan } from '../hooks/usePlan.js'; +import { testPlan, testSubscription } from '../test/billingFixtures.js'; + +vi.mock('../hooks/useSubscription.js', () => ({ useSubscription: vi.fn() })); +vi.mock('../hooks/usePlan.js', () => ({ usePlan: vi.fn() })); + +describe('SubscriptionStatus (web)', () => { + beforeEach(() => { + vi.mocked(useSubscription).mockReturnValue({ + data: testSubscription(), + loading: false, + error: null, + refresh: vi.fn(), + }); + vi.mocked(usePlan).mockReturnValue({ + data: testPlan({ display_name: 'Free' }), + loading: false, + error: null, + }); + }); + + it('shows loading placeholder', () => { + vi.mocked(useSubscription).mockReturnValue({ + data: null, + loading: true, + error: null, + refresh: vi.fn(), + }); + render(); + expect(screen.getByText('…')).toBeInTheDocument(); + }); + + it('renders plan and status', () => { + render(); + expect(screen.getByText(/Plan:/)).toBeInTheDocument(); + expect(screen.getByText('Free')).toBeInTheDocument(); + expect(screen.getByText(/Status:/)).toBeInTheDocument(); + }); + + it('shows past_due warning', () => { + vi.mocked(useSubscription).mockReturnValue({ + data: testSubscription({ status: 'past_due' }), + loading: false, + error: null, + refresh: vi.fn(), + }); + render(); + expect( + screen.getByText('Payment issue — update billing in the portal.') + ).toBeInTheDocument(); + }); +}); diff --git a/packages/billing/src/components/SubscriptionStatus.web.tsx b/packages/billing/src/components/SubscriptionStatus.web.tsx new file mode 100644 index 00000000..0b3568a4 --- /dev/null +++ b/packages/billing/src/components/SubscriptionStatus.web.tsx @@ -0,0 +1,41 @@ +import type { ReactElement } from 'react'; +import { usePlan } from '../hooks/usePlan.js'; +import { useSubscription } from '../hooks/useSubscription.js'; +import type { ProductBillingConfig } from '../schema.js'; +import { subscriptionStatusLabel } from '../utils/subscriptionStatusLabel.js'; +import type { SubscriptionStatusProps } from './SubscriptionStatus.types.js'; + +export function SubscriptionStatus

({ + className, + style, +}: SubscriptionStatusProps): ReactElement { + const { data: sub, loading } = useSubscription

(); + const { data: plan } = usePlan

(); + + if (loading) { + return

; + } + + const renewal = sub?.current_period_end + ? new Date(sub.current_period_end).toLocaleDateString() + : '—'; + + return ( +
+

+ Plan: {plan?.display_name ?? sub?.plan_id ?? '—'} +

+

+ Status: {subscriptionStatusLabel(sub, renewal)} +

+ {sub?.status === 'past_due' ? ( +

+ Payment issue — update billing in the portal. +

+ ) : null} +

+ Renews / period ends: {renewal} +

+
+ ); +} diff --git a/packages/billing/src/components/SubscriptionStatusBadge.native.test.tsx b/packages/billing/src/components/SubscriptionStatusBadge.native.test.tsx new file mode 100644 index 00000000..8f88de03 --- /dev/null +++ b/packages/billing/src/components/SubscriptionStatusBadge.native.test.tsx @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import { SubscriptionStatusBadge } from './SubscriptionStatusBadge.native.js'; +import { testSubscription } from '../test/billingFixtures.js'; + +describe('SubscriptionStatusBadge (native)', () => { + it('returns null without subscription', () => { + const { container } = render( + + ); + expect(container.firstChild).toBeNull(); + }); + + it('exposes accessibility label for Trial', () => { + render( + + ); + expect(screen.getByLabelText('Trial')).toBeInTheDocument(); + }); + + it('labels Free status', () => { + render( + + ); + expect(screen.getByLabelText('Free')).toBeInTheDocument(); + }); + + it('labels payment failed for past_due', () => { + render( + + ); + expect(screen.getByLabelText('Payment failed')).toBeInTheDocument(); + }); + + it('shows cancelling when cancel_at_period_end', () => { + render( + + ); + expect(screen.getByLabelText(/Cancelling/)).toBeInTheDocument(); + }); + + it('shows Active for active status', () => { + render(); + expect(screen.getByLabelText('Active')).toBeInTheDocument(); + }); +}); diff --git a/packages/billing/src/components/SubscriptionStatusBadge.native.tsx b/packages/billing/src/components/SubscriptionStatusBadge.native.tsx new file mode 100644 index 00000000..b8c88adf --- /dev/null +++ b/packages/billing/src/components/SubscriptionStatusBadge.native.tsx @@ -0,0 +1,66 @@ +import type { ReactElement } from 'react'; +import { StyleSheet, Text, View } from 'react-native'; +import type { SubscriptionRow } from '../types.js'; + +export type SubscriptionStatusBadgeProps = { + subscription: SubscriptionRow | null; +}; + +function periodEndLabel(sub: SubscriptionRow): string { + if (!sub.current_period_end) return '—'; + return new Date(sub.current_period_end).toLocaleDateString(); +} + +/** + * Compact pill for subscription state (active, trial, cancelling, past due, free). + */ +export function SubscriptionStatusBadge({ + subscription, +}: SubscriptionStatusBadgeProps): ReactElement | null { + if (!subscription) return null; + const st = subscription.status.toLowerCase(); + const end = periodEndLabel(subscription); + + let label = subscription.status; + let bg = '#F3F4F6'; + let fg = '#1F2937'; + + if (st === 'free') { + label = 'Free'; + } else if (st === 'past_due') { + label = 'Payment failed'; + bg = '#FEE2E2'; + fg = '#991B1B'; + } else if (st === 'trialing') { + label = 'Trial'; + bg = '#DBEAFE'; + fg = '#1E3A8A'; + } else if (subscription.cancel_at_period_end) { + label = `Cancelling ${end}`; + bg = '#FEF3C7'; + fg = '#78350F'; + } else if (st === 'active' || st === 'paused' || st === 'unpaid') { + label = 'Active'; + bg = '#D1FAE5'; + fg = '#065F46'; + } + + return ( + + {label} + + ); +} + +const styles = StyleSheet.create({ + pill: { + alignSelf: 'flex-start', + borderRadius: 9999, + paddingHorizontal: 10, + paddingVertical: 4, + }, + text: { fontSize: 12, fontWeight: '600' }, +}); diff --git a/packages/billing/src/components/SubscriptionStatusBadge.web.test.tsx b/packages/billing/src/components/SubscriptionStatusBadge.web.test.tsx new file mode 100644 index 00000000..0d298557 --- /dev/null +++ b/packages/billing/src/components/SubscriptionStatusBadge.web.test.tsx @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import { SubscriptionStatusBadge } from './SubscriptionStatusBadge.web.js'; +import { testSubscription } from '../test/billingFixtures.js'; + +describe('SubscriptionStatusBadge (web)', () => { + it('returns null without subscription', () => { + const { container } = render( + + ); + expect(container.firstChild).toBeNull(); + }); + + it('renders Free for free status', () => { + render( + + ); + expect(screen.getByTestId('subscription-status-badge')).toHaveTextContent( + 'Free' + ); + }); + + it('renders payment failed for past_due', () => { + render( + + ); + expect(screen.getByText('Payment failed')).toBeInTheDocument(); + }); + + it('renders Trial for trialing', () => { + render( + + ); + expect(screen.getByText('Trial')).toBeInTheDocument(); + }); + + it('renders Cancelling when cancel_at_period_end', () => { + render( + + ); + expect(screen.getByText(/Cancelling/)).toBeInTheDocument(); + }); + + it('renders Active for paused status', () => { + render( + + ); + expect(screen.getByText('Active')).toBeInTheDocument(); + }); + + it('uses default styling for unknown status', () => { + render( + + ); + expect(screen.getByTestId('subscription-status-badge')).toHaveTextContent( + 'incomplete' + ); + }); +}); diff --git a/packages/billing/src/components/SubscriptionStatusBadge.web.tsx b/packages/billing/src/components/SubscriptionStatusBadge.web.tsx new file mode 100644 index 00000000..13eb6423 --- /dev/null +++ b/packages/billing/src/components/SubscriptionStatusBadge.web.tsx @@ -0,0 +1,56 @@ +import type { ReactElement } from 'react'; +import type { SubscriptionRow } from '../types.js'; + +export type SubscriptionStatusBadgeProps = { + subscription: SubscriptionRow | null; + className?: string; +}; + +function periodEndLabel(sub: SubscriptionRow): string { + if (!sub.current_period_end) return '—'; + return new Date(sub.current_period_end).toLocaleDateString(); +} + +/** + * Compact pill for subscription state (active, trial, cancelling, past due, free). + */ +export function SubscriptionStatusBadge({ + subscription, + className = '', +}: SubscriptionStatusBadgeProps): ReactElement | null { + if (!subscription) return null; + const st = subscription.status.toLowerCase(); + const end = periodEndLabel(subscription); + + let label = subscription.status; + let classes = + 'inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium '; + + if (st === 'free') { + label = 'Free'; + classes += 'bg-gray-100 text-gray-800'; + } else if (st === 'past_due') { + label = 'Payment failed'; + classes += 'bg-red-100 text-red-800'; + } else if (st === 'trialing') { + label = 'Trial'; + classes += 'bg-blue-100 text-blue-800'; + } else if (subscription.cancel_at_period_end) { + label = `Cancelling ${end}`; + classes += 'bg-amber-100 text-amber-900'; + } else if (st === 'active' || st === 'paused' || st === 'unpaid') { + label = 'Active'; + classes += 'bg-green-100 text-green-800'; + } else { + classes += 'bg-gray-100 text-gray-800'; + } + + return ( + + {label} + + ); +} diff --git a/packages/billing/src/components/UpgradePrompt.native.test.tsx b/packages/billing/src/components/UpgradePrompt.native.test.tsx new file mode 100644 index 00000000..c1b74d49 --- /dev/null +++ b/packages/billing/src/components/UpgradePrompt.native.test.tsx @@ -0,0 +1,35 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import React from 'react'; +import * as RN from 'react-native'; +import { UpgradePrompt } from './UpgradePrompt.native.js'; +import { useCheckout } from '../hooks/useCheckout.js'; + +vi.mock('../hooks/useCheckout.js', () => ({ useCheckout: vi.fn() })); + +describe('UpgradePrompt (native)', () => { + const startCheckout = vi.fn(); + + beforeEach(() => { + startCheckout.mockReset(); + vi.mocked(useCheckout).mockReturnValue({ + startCheckout, + pending: false, + error: null, + }); + vi.spyOn(RN.Linking, 'openURL').mockResolvedValue(undefined as never); + }); + + it('opens checkout URL via Linking', async () => { + startCheckout.mockResolvedValue({ + checkoutUrl: 'https://pay.example/start', + }); + render(); + fireEvent.click(screen.getByText('Upgrade')); + await waitFor(() => { + expect(RN.Linking.openURL).toHaveBeenCalledWith( + 'https://pay.example/start' + ); + }); + }); +}); diff --git a/packages/billing/src/components/UpgradePrompt.native.tsx b/packages/billing/src/components/UpgradePrompt.native.tsx new file mode 100644 index 00000000..653293ef --- /dev/null +++ b/packages/billing/src/components/UpgradePrompt.native.tsx @@ -0,0 +1,40 @@ +import type { ReactElement } from 'react'; +import { Pressable, Text, View } from 'react-native'; +import { useCheckout } from '../hooks/useCheckout.js'; +import type { ProductBillingConfig } from '../schema.js'; +import type { UpgradePromptProps } from './UpgradePrompt.types.js'; + +export function UpgradePrompt

({ + targetTier, + suggestedPlanId, + reason, + children, + style, +}: UpgradePromptProps): ReactElement { + const planId = suggestedPlanId ?? targetTier; + const { startCheckout, pending, error } = useCheckout

(); + + const onUpgrade = async () => { + if (!planId) return; + const r = await startCheckout(planId); + if (r?.checkoutUrl) { + const { Linking } = await import('react-native'); + await Linking.openURL(r.checkoutUrl); + } + }; + + if (typeof children === 'function') { + return <>{children({ onUpgrade, pending })}; + } + + return ( + + {reason} + {error ? {error.message} : null} + void onUpgrade()}> + {pending ? '…' : 'Upgrade'} + + {children} + + ); +} diff --git a/packages/billing/src/components/UpgradePrompt.types.ts b/packages/billing/src/components/UpgradePrompt.types.ts new file mode 100644 index 00000000..4cc66578 --- /dev/null +++ b/packages/billing/src/components/UpgradePrompt.types.ts @@ -0,0 +1,17 @@ +import type { ReactNode } from 'react'; + +export type UpgradePromptProps = { + /** @deprecated Prefer {@link suggestedPlanId} (core spec name). */ + targetTier?: string; + /** Core spec: plan id to pass to checkout. */ + suggestedPlanId?: string; + reason: string; + children?: + | ReactNode + | ((ctx: { + onUpgrade: () => Promise; + pending: boolean; + }) => ReactNode); + className?: string; + style?: object; +}; diff --git a/packages/billing/src/components/UpgradePrompt.web.test.tsx b/packages/billing/src/components/UpgradePrompt.web.test.tsx new file mode 100644 index 00000000..b7b561f2 --- /dev/null +++ b/packages/billing/src/components/UpgradePrompt.web.test.tsx @@ -0,0 +1,59 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import React from 'react'; +import { UpgradePrompt } from './UpgradePrompt.web.js'; +import { useCheckout } from '../hooks/useCheckout.js'; + +vi.mock('../hooks/useCheckout.js', () => ({ useCheckout: vi.fn() })); + +describe('UpgradePrompt (web)', () => { + const startCheckout = vi.fn(); + + beforeEach(() => { + startCheckout.mockReset(); + vi.mocked(useCheckout).mockReturnValue({ + startCheckout, + pending: false, + error: null, + }); + vi.stubGlobal('location', { href: '' } as Pick< + Location, + 'href' + > as Location); + }); + + it('assigns location when checkout returns url', async () => { + startCheckout.mockResolvedValue({ + checkoutUrl: 'https://pay.example/start', + }); + render(); + fireEvent.click(screen.getByRole('button', { name: 'Upgrade' })); + await waitFor(() => { + expect((globalThis.location as Location).href).toBe( + 'https://pay.example/start' + ); + }); + }); + + it('renders render-prop children', () => { + startCheckout.mockResolvedValue(null); + render( + + {({ pending }) => ( + {pending ? 'p' : 'r'} + )} + + ); + expect(screen.getByTestId('child')).toHaveTextContent('r'); + }); + + it('shows error message', () => { + vi.mocked(useCheckout).mockReturnValue({ + startCheckout, + pending: false, + error: new Error('fail'), + }); + render(); + expect(screen.getByText('fail')).toBeInTheDocument(); + }); +}); diff --git a/packages/billing/src/components/UpgradePrompt.web.tsx b/packages/billing/src/components/UpgradePrompt.web.tsx new file mode 100644 index 00000000..128d5de9 --- /dev/null +++ b/packages/billing/src/components/UpgradePrompt.web.tsx @@ -0,0 +1,39 @@ +import type { ReactElement } from 'react'; +import { useCheckout } from '../hooks/useCheckout.js'; +import type { ProductBillingConfig } from '../schema.js'; +import type { UpgradePromptProps } from './UpgradePrompt.types.js'; + +export function UpgradePrompt

({ + targetTier, + suggestedPlanId, + reason, + children, + className, + style, +}: UpgradePromptProps): ReactElement { + const planId = suggestedPlanId ?? targetTier; + const { startCheckout, pending, error } = useCheckout

(); + + const onUpgrade = async () => { + if (!planId) return; + const r = await startCheckout(planId); + if (r?.checkoutUrl && typeof window !== 'undefined') { + window.location.href = r.checkoutUrl; + } + }; + + if (typeof children === 'function') { + return <>{children({ onUpgrade, pending })}; + } + + return ( +

+

{reason}

+ {error ?

{error.message}

: null} + + {children} +
+ ); +} diff --git a/packages/billing/src/components/UsageIndicator.native.test.tsx b/packages/billing/src/components/UsageIndicator.native.test.tsx new file mode 100644 index 00000000..8f9c35b7 --- /dev/null +++ b/packages/billing/src/components/UsageIndicator.native.test.tsx @@ -0,0 +1,84 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import { UsageIndicator } from './UsageIndicator.native.js'; +import { useUsage } from '../hooks/useUsage.js'; + +vi.mock('../hooks/useUsage.js', () => ({ useUsage: vi.fn() })); + +describe('UsageIndicator (native)', () => { + beforeEach(() => { + vi.mocked(useUsage).mockReturnValue({ + used: 2, + limit: 5, + remaining: 3, + resetsAt: '2026-06-01T00:00:00.000Z', + loading: false, + }); + }); + + it('renders text variant with remaining', () => { + render(); + expect(screen.getByText(/2 of 5 used/)).toBeInTheDocument(); + expect(screen.getByText(/3 left/)).toBeInTheDocument(); + }); + + it('renders compact variant', () => { + render(); + expect(screen.getByText('2/5')).toBeInTheDocument(); + }); + + it('renders expanded variant with label and progress track', () => { + render( + + ); + expect(screen.getByText('AI usage')).toBeInTheDocument(); + expect(screen.getByText('Resets each billing period')).toBeInTheDocument(); + }); + + it('renders bar variant when limit is set', () => { + render(); + expect(screen.getByText(/2 of 5 used/)).toBeInTheDocument(); + }); + + it('renders infinity symbol when limit is null', () => { + vi.mocked(useUsage).mockReturnValue({ + used: 4, + limit: null, + remaining: null, + resetsAt: '', + loading: false, + }); + render(); + expect(screen.getByText(/4 of ∞ used/)).toBeInTheDocument(); + }); + + it('renders unlimited expanded copy when limit is null', () => { + vi.mocked(useUsage).mockReturnValue({ + used: 4, + limit: null, + remaining: null, + resetsAt: '', + loading: false, + }); + render(); + expect(screen.getByText(/unlimited/)).toBeInTheDocument(); + }); + + it('shows ellipsis while loading', () => { + vi.mocked(useUsage).mockReturnValue({ + used: 0, + limit: 5, + remaining: 5, + resetsAt: '', + loading: true, + }); + render(); + expect(screen.getByText('…')).toBeInTheDocument(); + }); +}); diff --git a/packages/billing/src/components/UsageIndicator.native.tsx b/packages/billing/src/components/UsageIndicator.native.tsx new file mode 100644 index 00000000..a28f1ee3 --- /dev/null +++ b/packages/billing/src/components/UsageIndicator.native.tsx @@ -0,0 +1,83 @@ +import type { ReactElement } from 'react'; +import { StyleSheet, Text, View } from 'react-native'; +import { useUsage } from '../hooks/useUsage.js'; +import type { ProductBillingConfig } from '../schema.js'; +import type { UsageIndicatorProps } from './UsageIndicator.types.js'; + +export function UsageIndicator

( + props: UsageIndicatorProps

+): ReactElement { + const { meter, variant = 'text', style, label, description } = props; + const { used, limit, remaining, resetsAt, loading } = useUsage< + P, + typeof meter + >(meter); + const lim = limit === null ? '∞' : String(limit); + const rem = remaining === null ? '∞' : String(remaining); + const text = loading + ? '…' + : `${used} of ${lim} used · resets ${resetsAt ? new Date(resetsAt).toLocaleDateString() : '—'}`; + const textUnlimited = loading + ? '…' + : limit === null + ? `${used} used this period · unlimited` + : text; + if (variant === 'compact') { + return ( + + {loading ? '…' : `${used}/${lim}`} + + ); + } + if (variant === 'expanded') { + const capLine = + limit === null + ? textUnlimited + : `${used} of ${lim} used · resets ${resetsAt ? new Date(resetsAt).toLocaleDateString() : '—'}`; + const pct = + limit != null && limit > 0 ? Math.min(100, (used / limit) * 100) : 0; + return ( + + {!!label && {label}} + {!!description && {description}} + {limit != null && ( + + + + )} + {capLine} + + ); + } + if (variant === 'bar' && limit !== null) { + const pct = Math.min(100, (used / limit) * 100); + return ( + + + + + {text} + + ); + } + return ( + + {text} + {remaining !== null ? ` · ${rem} left` : ''} + + ); +} + +const styles = StyleSheet.create({ + text: { fontSize: 14 }, + label: { fontSize: 14, fontWeight: '600', color: '#111827' }, + description: { fontSize: 12, color: '#6B7280', marginTop: 2 }, + caption: { fontSize: 12, marginTop: 4 }, + track: { + height: 8, + backgroundColor: '#e5e7eb', + borderRadius: 4, + overflow: 'hidden', + }, + fill: { height: 8, backgroundColor: '#4f46e5' }, +}); diff --git a/packages/billing/src/components/UsageIndicator.types.ts b/packages/billing/src/components/UsageIndicator.types.ts new file mode 100644 index 00000000..f21b01fb --- /dev/null +++ b/packages/billing/src/components/UsageIndicator.types.ts @@ -0,0 +1,13 @@ +import type { InferMeterKeys } from '../schema.js'; +import type { ProductBillingConfig } from '../schema.js'; + +export type UsageIndicatorProps

= { + meter: InferMeterKeys

& string; + variant?: 'bar' | 'text' | 'compact' | 'expanded'; + /** Used with `variant="expanded"`. */ + label?: string; + /** Shown under the label in expanded layout. */ + description?: string; + className?: string; + style?: object; +}; diff --git a/packages/billing/src/components/UsageIndicator.web.test.tsx b/packages/billing/src/components/UsageIndicator.web.test.tsx new file mode 100644 index 00000000..31ea5ee7 --- /dev/null +++ b/packages/billing/src/components/UsageIndicator.web.test.tsx @@ -0,0 +1,229 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import { UsageIndicator } from './UsageIndicator.web.js'; +import { useUsage } from '../hooks/useUsage.js'; + +vi.mock('../hooks/useUsage.js', () => ({ useUsage: vi.fn() })); + +describe('UsageIndicator (web)', () => { + beforeEach(() => { + vi.mocked(useUsage).mockReturnValue({ + used: 3, + limit: 10, + remaining: 7, + resetsAt: '2026-06-01T00:00:00.000Z', + loading: false, + }); + }); + + it('renders default text variant', () => { + render(); + expect(screen.getByText(/3 of 10 used/)).toBeInTheDocument(); + expect(screen.getByText(/7 left/)).toBeInTheDocument(); + }); + + it('renders compact variant', () => { + render(); + expect(screen.getByText('3/10')).toBeInTheDocument(); + }); + + it('renders expanded with test id', () => { + render( + + ); + expect(screen.getByTestId('usage-indicator-expanded')).toBeInTheDocument(); + expect(screen.getByText('AI')).toBeInTheDocument(); + }); + + it('shows loading ellipsis', () => { + vi.mocked(useUsage).mockReturnValue({ + used: 0, + limit: 10, + remaining: 10, + resetsAt: null, + loading: true, + }); + render(); + expect(screen.getByText('…')).toBeInTheDocument(); + }); + + it('uses em dash when resetsAt is empty on default variant', () => { + vi.mocked(useUsage).mockReturnValue({ + used: 1, + limit: 5, + remaining: 4, + resetsAt: '', + loading: false, + }); + render(); + expect(screen.getByText(/resets —/)).toBeInTheDocument(); + }); + + it('does not append remaining when remaining is null', () => { + vi.mocked(useUsage).mockReturnValue({ + used: 2, + limit: 5, + remaining: null, + resetsAt: '2026-01-01T00:00:00.000Z', + loading: false, + }); + render(); + expect(screen.getByText(/2 of 5 used/)).toBeInTheDocument(); + expect(screen.queryByText(/left/)).not.toBeInTheDocument(); + }); + + it('shows infinity for unlimited limit on default variant', () => { + vi.mocked(useUsage).mockReturnValue({ + used: 4, + limit: null, + remaining: null, + resetsAt: '', + loading: false, + }); + render(); + expect(screen.getByText(/4 of ∞ used/)).toBeInTheDocument(); + }); + + it('renders compact with infinity when limit is null', () => { + vi.mocked(useUsage).mockReturnValue({ + used: 1, + limit: null, + remaining: null, + resetsAt: '', + loading: false, + }); + render(); + expect(screen.getByText('1/∞')).toBeInTheDocument(); + }); + + it('renders compact loading ellipsis', () => { + vi.mocked(useUsage).mockReturnValue({ + used: 0, + limit: 10, + remaining: 10, + resetsAt: '', + loading: true, + }); + render(); + expect(screen.getByText('…')).toBeInTheDocument(); + }); + + it('renders expanded unlimited copy when limit is null', () => { + vi.mocked(useUsage).mockReturnValue({ + used: 6, + limit: null, + remaining: null, + resetsAt: '', + loading: false, + }); + const { container } = render( + + ); + expect( + screen.getByText(/6 used this period · unlimited/) + ).toBeInTheDocument(); + expect(container.querySelector('[style*="height: 8px"]')).toBeNull(); + }); + + it('renders expanded cap line with em dash when limit set but resetsAt empty', () => { + vi.mocked(useUsage).mockReturnValue({ + used: 2, + limit: 8, + remaining: 6, + resetsAt: '', + loading: false, + }); + render(); + expect(screen.getByText(/2 of 8 used · resets —/)).toBeInTheDocument(); + }); + + it('renders expanded progress at 0% when limit is zero', () => { + vi.mocked(useUsage).mockReturnValue({ + used: 3, + limit: 0, + remaining: null, + resetsAt: '2026-03-01T00:00:00.000Z', + loading: false, + }); + const { container } = render( + + ); + const inner = container.querySelector('[style*="width"]') as HTMLElement; + expect(inner?.style.width).toBe('0%'); + }); + + it('caps expanded bar width at 100% when used exceeds limit', () => { + vi.mocked(useUsage).mockReturnValue({ + used: 50, + limit: 10, + remaining: 0, + resetsAt: '2026-04-01T00:00:00.000Z', + loading: false, + }); + const { container } = render( + + ); + expect( + [...container.querySelectorAll('div')].some( + el => (el as HTMLElement).style.width === '100%' + ) + ).toBe(true); + }); + + it('renders bar variant with track and caption', () => { + vi.mocked(useUsage).mockReturnValue({ + used: 4, + limit: 10, + remaining: 6, + resetsAt: '2026-05-01T00:00:00.000Z', + loading: false, + }); + const { container } = render( + + ); + expect(container.querySelector('.wrap')).toBeInTheDocument(); + expect(screen.getByText(/4 of 10 used/)).toBeInTheDocument(); + const inner = container.querySelector( + '[style*="background: rgb(59, 130, 246)"]' + ) as HTMLElement | undefined; + expect(inner?.style.width).toBe('40%'); + }); + + it('falls back to default variant when bar requested but limit is null', () => { + vi.mocked(useUsage).mockReturnValue({ + used: 2, + limit: null, + remaining: null, + resetsAt: '', + loading: false, + }); + render(); + expect(screen.getByText(/2 of ∞ used/)).toBeInTheDocument(); + }); + + it('applies className and style to default root', () => { + vi.mocked(useUsage).mockReturnValue({ + used: 1, + limit: 2, + remaining: 1, + resetsAt: '2026-01-01T00:00:00.000Z', + loading: false, + }); + const { container } = render( + + ); + const root = container.firstElementChild as HTMLElement; + expect(root).toHaveClass('usage-root'); + expect(root.style.marginTop).toBe('12px'); + }); +}); diff --git a/packages/billing/src/components/UsageIndicator.web.tsx b/packages/billing/src/components/UsageIndicator.web.tsx new file mode 100644 index 00000000..b90c588e --- /dev/null +++ b/packages/billing/src/components/UsageIndicator.web.tsx @@ -0,0 +1,100 @@ +import type { ReactElement } from 'react'; +import { useUsage } from '../hooks/useUsage.js'; +import type { ProductBillingConfig } from '../schema.js'; +import type { UsageIndicatorProps } from './UsageIndicator.types.js'; + +export function UsageIndicator

( + props: UsageIndicatorProps

+): ReactElement { + const { + meter, + variant = 'text', + className, + style, + label, + description, + } = props; + const { used, limit, remaining, resetsAt, loading } = useUsage< + P, + typeof meter + >(meter); + const lim = limit === null ? '∞' : String(limit); + const rem = remaining === null ? '∞' : String(remaining); + const text = loading + ? '…' + : `${used} of ${lim} used · resets ${resetsAt ? new Date(resetsAt).toLocaleDateString() : '—'}`; + const textUnlimited = loading + ? '…' + : limit === null + ? `${used} used this period · unlimited` + : text; + if (variant === 'compact') { + return ( + + {loading ? '…' : `${used}/${lim}`} + + ); + } + if (variant === 'expanded') { + const capLine = + limit === null + ? textUnlimited + : `${used} of ${lim} used · resets ${resetsAt ? new Date(resetsAt).toLocaleDateString() : '—'}`; + const pct = + limit != null && limit > 0 ? Math.min(100, (used / limit) * 100) : 0; + return ( +

+ {label && ( +
{label}
+ )} + {description && ( +

{description}

+ )} + {limit != null && ( +
+
+
+ )} +
{capLine}
+
+ ); + } + if (variant === 'bar' && limit !== null) { + const pct = Math.min(100, (used / limit) * 100); + return ( +
+
+
+
+
{text}
+
+ ); + } + return ( +
+ {text} + {remaining !== null && · {rem} left} +
+ ); +} diff --git a/packages/billing/src/context.test.ts b/packages/billing/src/context.test.ts new file mode 100644 index 00000000..e65a5060 --- /dev/null +++ b/packages/billing/src/context.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, it } from 'vitest'; +import { BillingConfigReactContext, BillingReactContext } from './context.js'; + +describe('billing react contexts', () => { + it('exports context objects with Provider and Consumer', () => { + expect(BillingReactContext.Provider).toBeDefined(); + expect(BillingReactContext.Consumer).toBeDefined(); + expect(BillingConfigReactContext.Provider).toBeDefined(); + expect(BillingConfigReactContext.Consumer).toBeDefined(); + }); +}); diff --git a/packages/billing/src/context.ts b/packages/billing/src/context.ts new file mode 100644 index 00000000..d4203967 --- /dev/null +++ b/packages/billing/src/context.ts @@ -0,0 +1,9 @@ +import { createContext } from 'react'; +import type { BillingContextValue } from './types.js'; +import type { ProductBillingConfig } from './schema.js'; + +export const BillingReactContext = + createContext | null>(null); + +export const BillingConfigReactContext = + createContext(null); diff --git a/packages/billing/src/entrypoints.test.ts b/packages/billing/src/entrypoints.test.ts new file mode 100644 index 00000000..473720fe --- /dev/null +++ b/packages/billing/src/entrypoints.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; + +describe('package entrypoints', () => { + it('exports core API from index', async () => { + const mod = await import('./index.js'); + expect(mod.defineBillingConfig).toBeTypeOf('function'); + expect(mod.BillingProvider).toBeTypeOf('function'); + expect(mod.usePlan).toBeTypeOf('function'); + expect(mod.getRemainingUsage).toBeTypeOf('function'); + expect(mod.hasExceededLimit).toBeTypeOf('function'); + expect(mod.getPlanById).toBeTypeOf('function'); + expect(mod.canUserAccessFeature).toBeTypeOf('function'); + }); + + it('exports web UI barrel', async () => { + const mod = await import('./web.js'); + expect(mod.FeatureGate).toBeTypeOf('function'); + expect(mod.UsageIndicator).toBeTypeOf('function'); + }); + + it('exports native UI barrel', async () => { + const mod = await import('./native.js'); + expect(mod.FeatureGate).toBeTypeOf('function'); + expect(mod.PricingTable).toBeTypeOf('function'); + }); +}); diff --git a/packages/billing/src/errors.test.ts b/packages/billing/src/errors.test.ts new file mode 100644 index 00000000..99b27f8a --- /dev/null +++ b/packages/billing/src/errors.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest'; +import { billingError, mapUnknownError } from './errors.js'; + +describe('billingError', () => { + it('includes optional cause', () => { + const inner = new Error('root'); + const e = billingError('validation', 'bad input', inner); + expect(e.kind).toBe('validation'); + expect(e.message).toBe('bad input'); + expect(e.cause).toBe(inner); + }); +}); + +describe('mapUnknownError', () => { + it('maps jwt-like messages to unauthenticated', () => { + const e = mapUnknownError(new Error('JWT expired')); + expect(e.kind).toBe('unauthenticated'); + }); + + it('maps auth keyword in message', () => { + expect(mapUnknownError(new Error('Not authenticated')).kind).toBe( + 'unauthenticated' + ); + }); + + it('maps literal unauthenticated message', () => { + expect(mapUnknownError(new Error('unauthenticated')).kind).toBe( + 'unauthenticated' + ); + }); + + it('maps fetch / network messages', () => { + expect(mapUnknownError(new Error('Failed to fetch')).kind).toBe('network'); + expect(mapUnknownError(new Error('Network error')).kind).toBe('network'); + }); + + it('maps non-Error values to unknown', () => { + const e = mapUnknownError('timeout'); + expect(e.kind).toBe('unknown'); + expect(e.message).toBe('timeout'); + }); + + it('passes through BillingError', () => { + const inner = billingError('stripe', 'bad'); + const e = mapUnknownError(inner); + expect(e).toEqual(inner); + }); +}); diff --git a/packages/billing/src/errors.ts b/packages/billing/src/errors.ts new file mode 100644 index 00000000..fb26d4f1 --- /dev/null +++ b/packages/billing/src/errors.ts @@ -0,0 +1,48 @@ +export type BillingErrorKind = + | 'unauthenticated' + | 'network' + | 'stripe' + | 'validation' + | 'rate_limit' + | 'unknown'; + +export type BillingError = { + kind: BillingErrorKind; + message: string; + cause?: unknown; +}; + +export function billingError( + kind: BillingErrorKind, + message: string, + cause?: unknown +): BillingError { + return { kind, message, cause }; +} + +export function mapUnknownError(err: unknown): BillingError { + if ( + err && + typeof err === 'object' && + 'kind' in err && + typeof (err as BillingError).kind === 'string' + ) { + return err as BillingError; + } + const msg = err instanceof Error ? err.message : String(err); + const lower = msg.toLowerCase(); + if ( + lower.includes('jwt') || + lower.includes('auth') || + lower.includes('unauthenticated') + ) { + return billingError('unauthenticated', msg, err); + } + if ( + msg.toLowerCase().includes('fetch') || + msg.toLowerCase().includes('network') + ) { + return billingError('network', msg, err); + } + return billingError('unknown', msg, err); +} diff --git a/packages/billing/src/featureAccess.test.ts b/packages/billing/src/featureAccess.test.ts new file mode 100644 index 00000000..dbfaa781 --- /dev/null +++ b/packages/billing/src/featureAccess.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; +import { isFeatureAccessible, readPlanFeatureValue } from './featureAccess.js'; + +describe('featureAccess', () => { + it('readPlanFeatureValue returns boolean and number', () => { + expect(readPlanFeatureValue({ a: true }, 'a')).toBe(true); + expect(readPlanFeatureValue({ n: 3 }, 'n')).toBe(3); + expect(readPlanFeatureValue({}, 'x')).toBe(null); + }); + + it('isFeatureAccessible treats numbers as accessible for boolean gate', () => { + expect(isFeatureAccessible({ cap: 5 }, 'cap')).toBe(true); + expect(isFeatureAccessible({ cap: 0 }, 'cap')).toBe(true); + expect(isFeatureAccessible({ x: false }, 'x')).toBe(false); + }); + + it('readPlanFeatureValue returns null for missing features map', () => { + expect(readPlanFeatureValue(null, 'a')).toBe(null); + expect(readPlanFeatureValue(undefined, 'a')).toBe(null); + }); + + it('isFeatureAccessible returns false for non-boolean non-number stored values', () => { + expect( + isFeatureAccessible( + { weird: 'yes' } as unknown as Record, + 'weird' + ) + ).toBe(false); + }); +}); diff --git a/packages/billing/src/featureAccess.ts b/packages/billing/src/featureAccess.ts new file mode 100644 index 00000000..421083dc --- /dev/null +++ b/packages/billing/src/featureAccess.ts @@ -0,0 +1,25 @@ +/** + * Pure helpers for plan feature gates (shared by `useFeature` and `billingClient`). + * Numeric feature values represent caps/limits; **enforcement** of those caps uses + * usage RPCs (`hasExceededLimit` / `getRemainingUsage`), not this boolean alone. + */ +export function readPlanFeatureValue( + features: Record | null | undefined, + featureName: string +): boolean | number | null { + if (!features) return null; + const raw = features[featureName]; + if (typeof raw === 'boolean' || typeof raw === 'number') return raw; + return null; +} + +/** Boolean gate: numeric features return `true` here (limit checked separately). */ +export function isFeatureAccessible( + features: Record | null | undefined, + featureName: string +): boolean { + const raw = readPlanFeatureValue(features, featureName); + if (typeof raw === 'boolean') return raw; + if (typeof raw === 'number') return true; + return false; +} diff --git a/packages/billing/src/hooks/billingReactContext.test.tsx b/packages/billing/src/hooks/billingReactContext.test.tsx new file mode 100644 index 00000000..5def8bb0 --- /dev/null +++ b/packages/billing/src/hooks/billingReactContext.test.tsx @@ -0,0 +1,71 @@ +import { describe, expect, it, vi } from 'vitest'; +import { renderHook } from '@testing-library/react'; +import React from 'react'; +import { BillingConfigReactContext, BillingReactContext } from '../context.js'; +import { + baseBillingContextExtras, + testBillingConfig, + testSubscription, +} from '../test/billingFixtures.js'; +import type { BillingContextValue } from '../types.js'; +import { useBillingConfig } from './useBillingConfig.js'; +import { useBillingContext } from './useBillingContext.js'; + +function configWrapper({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} + +function billingWrapper(ctx: BillingContextValue) { + return function Wrapper({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); + }; +} + +describe('useBillingConfig', () => { + it('returns config from provider', () => { + const { result } = renderHook(() => useBillingConfig(), { + wrapper: configWrapper, + }); + expect(result.current.productId).toBe('test_product'); + }); + + it('throws outside BillingConfigReactContext', () => { + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + expect(() => renderHook(() => useBillingConfig())).toThrow( + /useBillingConfig must be used within BillingProvider/ + ); + spy.mockRestore(); + }); +}); + +describe('useBillingContext', () => { + const ctx: BillingContextValue = { + ...baseBillingContextExtras(), + subscription: testSubscription(), + subscriptionLoading: false, + }; + + it('returns billing context', () => { + const { result } = renderHook(() => useBillingContext(), { + wrapper: billingWrapper(ctx), + }); + expect(result.current.subscription?.id).toBe('sub_1'); + expect(result.current.refreshSubscription).toBeTypeOf('function'); + }); + + it('throws outside BillingReactContext', () => { + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + expect(() => renderHook(() => useBillingContext())).toThrow( + /useBillingContext must be used within BillingProvider/ + ); + spy.mockRestore(); + }); +}); diff --git a/packages/billing/src/hooks/useBillingConfig.test.tsx b/packages/billing/src/hooks/useBillingConfig.test.tsx new file mode 100644 index 00000000..0d4fdc07 --- /dev/null +++ b/packages/billing/src/hooks/useBillingConfig.test.tsx @@ -0,0 +1,16 @@ +import { describe, expect, it, vi } from 'vitest'; +import { renderHook } from '@testing-library/react'; +import { useBillingConfig } from './useBillingConfig.js'; + +describe('useBillingConfig', () => { + it('throws outside BillingProvider', () => { + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + expect(() => renderHook(() => useBillingConfig())).toThrow( + 'useBillingConfig must be used within BillingProvider' + ); + } finally { + spy.mockRestore(); + } + }); +}); diff --git a/packages/billing/src/hooks/useBillingConfig.ts b/packages/billing/src/hooks/useBillingConfig.ts new file mode 100644 index 00000000..81d4a581 --- /dev/null +++ b/packages/billing/src/hooks/useBillingConfig.ts @@ -0,0 +1,13 @@ +import { useContext } from 'react'; +import { BillingConfigReactContext } from '../context.js'; +import type { ProductBillingConfig } from '../schema.js'; + +export function useBillingConfig< + P extends ProductBillingConfig = ProductBillingConfig, +>(): P { + const v = useContext(BillingConfigReactContext) as P | null; + if (!v) { + throw new Error('useBillingConfig must be used within BillingProvider'); + } + return v; +} diff --git a/packages/billing/src/hooks/useBillingContext.test.tsx b/packages/billing/src/hooks/useBillingContext.test.tsx new file mode 100644 index 00000000..35e5ecad --- /dev/null +++ b/packages/billing/src/hooks/useBillingContext.test.tsx @@ -0,0 +1,16 @@ +import { describe, expect, it, vi } from 'vitest'; +import { renderHook } from '@testing-library/react'; +import { useBillingContext } from './useBillingContext.js'; + +describe('useBillingContext', () => { + it('throws outside BillingProvider', () => { + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + expect(() => renderHook(() => useBillingContext())).toThrow( + 'useBillingContext must be used within BillingProvider' + ); + } finally { + spy.mockRestore(); + } + }); +}); diff --git a/packages/billing/src/hooks/useBillingContext.ts b/packages/billing/src/hooks/useBillingContext.ts new file mode 100644 index 00000000..8a66f6ff --- /dev/null +++ b/packages/billing/src/hooks/useBillingContext.ts @@ -0,0 +1,14 @@ +import { useContext } from 'react'; +import { BillingReactContext } from '../context.js'; +import type { ProductBillingConfig } from '../schema.js'; +import type { BillingContextValue } from '../types.js'; + +export function useBillingContext< + P extends ProductBillingConfig = ProductBillingConfig, +>(): BillingContextValue

{ + const v = useContext(BillingReactContext) as BillingContextValue

| null; + if (!v) { + throw new Error('useBillingContext must be used within BillingProvider'); + } + return v; +} diff --git a/packages/billing/src/hooks/useBillingState.test.tsx b/packages/billing/src/hooks/useBillingState.test.tsx new file mode 100644 index 00000000..a7ab75c0 --- /dev/null +++ b/packages/billing/src/hooks/useBillingState.test.tsx @@ -0,0 +1,151 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook } from '@testing-library/react'; +import type { Plan, SubscriptionRow } from '../types.js'; +import { + baseBillingContextExtras, + testPlan, + testSubscription, +} from '../test/billingFixtures.js'; +import { useBillingState } from './useBillingState.js'; + +const hp = vi.hoisted(() => ({ + subscription: null as SubscriptionRow | null, + subscriptionLoading: false, + plan: null as Plan | null, +})); + +vi.mock('./useBillingContext.js', () => ({ + useBillingContext: () => ({ + ...baseBillingContextExtras(), + subscription: hp.subscription, + subscriptionLoading: hp.subscriptionLoading, + }), +})); + +vi.mock('./usePlan.js', () => ({ + usePlan: () => ({ + data: hp.plan, + loading: false, + error: null, + }), +})); + +describe('useBillingState', () => { + beforeEach(() => { + hp.subscription = testSubscription(); + hp.subscriptionLoading = false; + hp.plan = testPlan(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('returns loading when subscription is loading', () => { + hp.subscriptionLoading = true; + const { result } = renderHook(() => useBillingState()); + expect(result.current.kind).toBe('loading'); + }); + + it('returns no_subscription when row missing', () => { + hp.subscription = null; + const { result } = renderHook(() => useBillingState()); + expect(result.current.kind).toBe('no_subscription'); + }); + + it('classifies free status', () => { + hp.subscription = testSubscription({ status: 'free' }); + const { result } = renderHook(() => useBillingState()); + expect(result.current.kind).toBe('free'); + }); + + it('classifies past_due as payment_failed', () => { + hp.subscription = testSubscription({ status: 'past_due' }); + const { result } = renderHook(() => useBillingState()); + expect(result.current.kind).toBe('payment_failed'); + }); + + it('classifies trialing with soon trial_end as trial_ending', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2025-01-01T12:00:00.000Z')); + hp.subscription = testSubscription({ + status: 'trialing', + trial_end: '2025-01-02T12:00:00.000Z', + }); + const { result } = renderHook(() => useBillingState()); + expect(result.current.kind).toBe('trial_ending'); + }); + + it('classifies trialing with distant trial_end', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2025-01-01T12:00:00.000Z')); + hp.subscription = testSubscription({ + status: 'trialing', + trial_end: '2025-02-01T12:00:00.000Z', + }); + const { result } = renderHook(() => useBillingState()); + expect(result.current.kind).toBe('trialing'); + }); + + it('classifies cancel_at_period_end without pending target as cancelled_pending', () => { + hp.subscription = testSubscription({ + status: 'active', + cancel_at_period_end: true, + pending_target_plan_id: null, + }); + const { result } = renderHook(() => useBillingState()); + expect(result.current.kind).toBe('cancelled_pending'); + }); + + it('classifies cancel_at_period_end with pending target as downgrade_pending', () => { + hp.subscription = testSubscription({ + status: 'active', + cancel_at_period_end: true, + pending_target_plan_id: 'plan_free', + }); + const { result } = renderHook(() => useBillingState()); + expect(result.current.kind).toBe('downgrade_pending'); + }); + + it('treats active without stripe subscription as free', () => { + hp.subscription = testSubscription({ + status: 'active', + stripe_subscription_id: null, + }); + const { result } = renderHook(() => useBillingState()); + expect(result.current.kind).toBe('free'); + }); + + it('classifies active with stripe as paid_active', () => { + hp.subscription = testSubscription({ + status: 'active', + stripe_subscription_id: 'sub_123', + }); + const { result } = renderHook(() => useBillingState()); + expect(result.current.kind).toBe('paid_active'); + }); + + it('maps canceled to free', () => { + hp.subscription = testSubscription({ status: 'canceled' }); + const { result } = renderHook(() => useBillingState()); + expect(result.current.kind).toBe('free'); + }); + + it('classifies paused as paid_active', () => { + hp.subscription = testSubscription({ status: 'paused' }); + const { result } = renderHook(() => useBillingState()); + expect(result.current.kind).toBe('paid_active'); + }); + + it('passes plan through from usePlan', () => { + hp.plan = testPlan({ display_name: 'Pro' }); + const { result } = renderHook(() => useBillingState()); + expect(result.current.plan?.display_name).toBe('Pro'); + }); + + it('defaults unrecognized status to paid_active', () => { + hp.subscription = testSubscription({ status: 'unknown_status' }); + const { result } = renderHook(() => useBillingState()); + expect(result.current.kind).toBe('paid_active'); + }); +}); diff --git a/packages/billing/src/hooks/useBillingState.ts b/packages/billing/src/hooks/useBillingState.ts new file mode 100644 index 00000000..d29ebab1 --- /dev/null +++ b/packages/billing/src/hooks/useBillingState.ts @@ -0,0 +1,96 @@ +import { useMemo } from 'react'; +import type { ProductBillingConfig } from '../schema.js'; +import type { Plan, SubscriptionRow } from '../types.js'; +import { useBillingContext } from './useBillingContext.js'; +import { usePlan } from './usePlan.js'; + +/** + * Coarse UI states for billing pages (align with billing UI v1 spec matrix). + * `downgrade_pending`: `cancel_at_period_end` with `pending_target_plan_id` set (e.g. app-initiated downgrade to Free). + * Paid→paid tier changes at period end without cancel are not implemented yet (Stripe updates apply immediately). + */ +export type BillingUiStateKind = + | 'loading' + | 'no_subscription' + | 'free' + | 'paid_active' + | 'cancelled_pending' + | 'payment_failed' + | 'trialing' + | 'trial_ending' + | 'downgrade_pending'; + +export type BillingUiState = { + kind: BillingUiStateKind; + plan: Plan | null; + subscription: SubscriptionRow | null; +}; + +const MS_PER_DAY = 24 * 60 * 60 * 1000; + +function deriveKind( + subscription: SubscriptionRow | null, + subscriptionLoading: boolean +): BillingUiStateKind { + if (subscriptionLoading) return 'loading'; + if (!subscription) return 'no_subscription'; + + const st = subscription.status.toLowerCase(); + if (st === 'free') return 'free'; + if (st === 'past_due') return 'payment_failed'; + + if (st === 'trialing') { + if (subscription.trial_end) { + const end = new Date(subscription.trial_end).getTime(); + if ( + !Number.isNaN(end) && + end > Date.now() && + end - Date.now() < 3 * MS_PER_DAY + ) { + return 'trial_ending'; + } + } + return 'trialing'; + } + + if (subscription.cancel_at_period_end) { + if (subscription.pending_target_plan_id) { + return 'downgrade_pending'; + } + return 'cancelled_pending'; + } + + if (st === 'active' && !subscription.stripe_subscription_id) { + return 'free'; + } + + if ( + st === 'active' || + st === 'paused' || + st === 'incomplete' || + st === 'unpaid' + ) { + return 'paid_active'; + } + + if (st === 'canceled') { + return 'free'; + } + + return 'paid_active'; +} + +export function useBillingState< + Config extends ProductBillingConfig, +>(): BillingUiState { + const { subscription, subscriptionLoading } = useBillingContext(); + const { data: plan } = usePlan(); + + return useMemo((): BillingUiState => { + return { + kind: deriveKind(subscription, subscriptionLoading), + plan, + subscription, + }; + }, [subscription, subscriptionLoading, plan]); +} diff --git a/packages/billing/src/hooks/useBillingStripeActions.test.tsx b/packages/billing/src/hooks/useBillingStripeActions.test.tsx new file mode 100644 index 00000000..6a7016fe --- /dev/null +++ b/packages/billing/src/hooks/useBillingStripeActions.test.tsx @@ -0,0 +1,96 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { renderHook, waitFor } from '@testing-library/react'; +import { + baseBillingContextExtras, + testBillingConfig, +} from '../test/billingFixtures.js'; +import { useBillingStripeActions } from './useBillingStripeActions.js'; + +const { invoke, mockSupabase, refreshSubscription } = vi.hoisted(() => { + const invoke = vi.fn(); + const refreshSubscription = vi.fn().mockResolvedValue(undefined); + const mockSupabase = { + functions: { invoke }, + } as unknown as ReturnType['supabase']; + return { invoke, mockSupabase, refreshSubscription }; +}); + +vi.mock('./useBillingContext.js', () => ({ + useBillingContext: () => ({ + ...baseBillingContextExtras(), + supabase: mockSupabase, + config: testBillingConfig, + stripeFunctionName: 'billing-stripe', + refreshSubscription, + }), +})); + +describe('useBillingStripeActions', () => { + beforeEach(() => { + invoke.mockReset(); + }); + + it('updateSubscription calls edge function', async () => { + invoke.mockResolvedValue({ data: {}, error: null }); + const { result } = renderHook(() => useBillingStripeActions()); + const ok = await result.current.updateSubscription('plan_x', 'annual'); + expect(ok).toBe(true); + expect(invoke).toHaveBeenCalledWith( + 'billing-stripe', + expect.objectContaining({ + body: expect.objectContaining({ + action: 'update_subscription', + planId: 'plan_x', + cadence: 'annual', + }), + }) + ); + }); + + it('scheduleCancelToFree posts downgrade action', async () => { + invoke.mockResolvedValue({ data: {}, error: null }); + const { result } = renderHook(() => useBillingStripeActions()); + const ok = await result.current.scheduleCancelToFree(); + expect(ok).toBe(true); + expect(invoke).toHaveBeenCalledWith( + 'billing-stripe', + expect.objectContaining({ + body: expect.objectContaining({ action: 'schedule_cancel_to_free' }), + }) + ); + }); + + it('reactivateSubscription posts resume action', async () => { + invoke.mockResolvedValue({ data: {}, error: null }); + const { result } = renderHook(() => useBillingStripeActions()); + const ok = await result.current.reactivateSubscription(); + expect(ok).toBe(true); + expect(invoke).toHaveBeenCalledWith( + 'billing-stripe', + expect.objectContaining({ + body: expect.objectContaining({ action: 'resume_subscription' }), + }) + ); + }); + + it('cancelSubscriptionImmediately posts cancel_immediately', async () => { + invoke.mockResolvedValue({ data: {}, error: null }); + const { result } = renderHook(() => useBillingStripeActions()); + const ok = await result.current.cancelSubscriptionImmediately(); + expect(ok).toBe(true); + expect(invoke).toHaveBeenCalledWith( + 'billing-stripe', + expect.objectContaining({ + body: expect.objectContaining({ action: 'cancel_immediately' }), + }) + ); + }); + + it('returns false on invoke error', async () => { + invoke.mockResolvedValue({ data: null, error: new Error('bad') }); + const { result } = renderHook(() => useBillingStripeActions()); + const ok = await result.current.updateSubscription('p'); + expect(ok).toBe(false); + await waitFor(() => expect(result.current.error).not.toBeNull()); + }); +}); diff --git a/packages/billing/src/hooks/useBillingStripeActions.ts b/packages/billing/src/hooks/useBillingStripeActions.ts new file mode 100644 index 00000000..12e74a1e --- /dev/null +++ b/packages/billing/src/hooks/useBillingStripeActions.ts @@ -0,0 +1,99 @@ +import { useCallback, useMemo, useState } from 'react'; +import { mapUnknownError } from '../errors.js'; +import type { BillingError } from '../errors.js'; +import type { ProductBillingConfig } from '../schema.js'; +import { useBillingContext } from './useBillingContext.js'; + +/** + * Server-backed subscription mutations (Edge `billing-stripe` function). + */ +export function useBillingStripeActions< + P extends ProductBillingConfig = ProductBillingConfig, +>(): { + updateSubscription: ( + planId: string, + cadence?: 'monthly' | 'annual' + ) => Promise; + /** Sets `cancel_at_period_end` on the Stripe subscription (moves to free at period end). */ + scheduleCancelToFree: () => Promise; + /** Clears `cancel_at_period_end` on the Stripe subscription (reactivate before period end). */ + reactivateSubscription: () => Promise; + /** Cancels the Stripe subscription immediately (no period-end grace). */ + cancelSubscriptionImmediately: () => Promise; + pending: boolean; + error: BillingError | null; +} { + const { supabase, config, refreshSubscription, stripeFunctionName } = + useBillingContext

(); + const [pending, setPending] = useState(false); + const [error, setError] = useState(null); + + const run = useCallback( + async (body: Record) => { + setPending(true); + setError(null); + try { + const { error: fnErr } = await supabase.functions.invoke( + stripeFunctionName, + { + body: { + ...body, + productId: config.productId, + }, + } + ); + if (fnErr) throw fnErr; + await refreshSubscription(); + return true; + } catch (e) { + setError(mapUnknownError(e)); + return false; + } finally { + setPending(false); + } + }, + [supabase, stripeFunctionName, config.productId, refreshSubscription] + ); + + const updateSubscription = useCallback( + async (planId: string, cadence: 'monthly' | 'annual' = 'monthly') => { + return run({ + action: 'update_subscription', + planId, + cadence, + }); + }, + [run] + ); + + const scheduleCancelToFree = useCallback(async () => { + return run({ action: 'schedule_cancel_to_free' }); + }, [run]); + + const reactivateSubscription = useCallback(async () => { + return run({ action: 'resume_subscription' }); + }, [run]); + + const cancelSubscriptionImmediately = useCallback(async () => { + return run({ action: 'cancel_immediately' }); + }, [run]); + + return useMemo( + () => ({ + updateSubscription, + scheduleCancelToFree, + reactivateSubscription, + cancelSubscriptionImmediately, + pending, + error, + }), + [ + updateSubscription, + scheduleCancelToFree, + reactivateSubscription, + cancelSubscriptionImmediately, + pending, + error, + ] + ); +} diff --git a/packages/billing/src/hooks/useCheckout.test.tsx b/packages/billing/src/hooks/useCheckout.test.tsx new file mode 100644 index 00000000..383121fd --- /dev/null +++ b/packages/billing/src/hooks/useCheckout.test.tsx @@ -0,0 +1,85 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { renderHook, waitFor } from '@testing-library/react'; +import { + baseBillingContextExtras, + testBillingConfig, +} from '../test/billingFixtures.js'; +import { useCheckout } from './useCheckout.js'; + +const { invoke, mockSupabase, refreshSubscription } = vi.hoisted(() => { + const invoke = vi.fn(); + const refreshSubscription = vi.fn().mockResolvedValue(undefined); + const mockSupabase = { + functions: { invoke }, + } as unknown as ReturnType['supabase']; + return { invoke, mockSupabase, refreshSubscription }; +}); + +vi.mock('./useBillingContext.js', () => ({ + useBillingContext: () => ({ + ...baseBillingContextExtras(), + supabase: mockSupabase, + config: testBillingConfig, + checkoutSuccessUrl: 'https://ok', + checkoutCancelUrl: 'https://cancel', + stripeFunctionName: 'billing-stripe', + refreshSubscription, + }), +})); + +describe('useCheckout', () => { + beforeEach(() => { + invoke.mockReset(); + }); + + it('returns checkoutUrl on success', async () => { + invoke.mockResolvedValue({ + data: { checkoutUrl: 'https://stripe.test/session' }, + error: null, + }); + const { result } = renderHook(() => useCheckout()); + const r = await result.current.startCheckout('plan_free', 'monthly'); + expect(r?.checkoutUrl).toBe('https://stripe.test/session'); + expect(invoke).toHaveBeenCalledWith( + 'billing-stripe', + expect.objectContaining({ + body: expect.objectContaining({ + action: 'checkout', + planId: 'plan_free', + cadence: 'monthly', + }), + }) + ); + }); + + it('returns null when checkoutUrl missing', async () => { + invoke.mockResolvedValue({ data: {}, error: null }); + const { result } = renderHook(() => useCheckout()); + const r = await result.current.startCheckout('plan_free'); + expect(r).toBeNull(); + await waitFor(() => expect(result.current.error?.kind).toBe('stripe')); + }); + + it('maps invoke errors', async () => { + invoke.mockResolvedValue({ data: null, error: new Error('fn fail') }); + const { result } = renderHook(() => useCheckout()); + const r = await result.current.startCheckout('plan_free'); + expect(r).toBeNull(); + await waitFor(() => expect(result.current.error).not.toBeNull()); + }); + + it('includes trialDays in invoke body when provided', async () => { + invoke.mockResolvedValue({ + data: { checkoutUrl: 'https://stripe.test/session' }, + error: null, + }); + const { result } = renderHook(() => useCheckout()); + await result.current.startCheckout('plan_free', 'annual', 14); + expect(invoke).toHaveBeenCalledWith( + 'billing-stripe', + expect.objectContaining({ + body: expect.objectContaining({ trialDays: 14, cadence: 'annual' }), + }) + ); + }); +}); diff --git a/packages/billing/src/hooks/useCheckout.ts b/packages/billing/src/hooks/useCheckout.ts new file mode 100644 index 00000000..35d6ba01 --- /dev/null +++ b/packages/billing/src/hooks/useCheckout.ts @@ -0,0 +1,83 @@ +import { useCallback, useMemo, useState } from 'react'; +import { billingError, mapUnknownError } from '../errors.js'; +import type { BillingError } from '../errors.js'; +import type { ProductBillingConfig } from '../schema.js'; +import { useBillingContext } from './useBillingContext.js'; + +export function useCheckout< + P extends ProductBillingConfig = ProductBillingConfig, +>(): { + startCheckout: ( + planId: string, + cadence?: 'monthly' | 'annual', + trialDays?: number + ) => Promise<{ checkoutUrl: string } | null>; + pending: boolean; + error: BillingError | null; +} { + const { + supabase, + config, + checkoutSuccessUrl, + checkoutCancelUrl, + stripeFunctionName, + } = useBillingContext

(); + const [pending, setPending] = useState(false); + const [error, setError] = useState(null); + + const startCheckout = useCallback( + async ( + planId: string, + cadence: 'monthly' | 'annual' = 'monthly', + trialDays?: number + ) => { + setPending(true); + setError(null); + try { + const body: Record = { + action: 'checkout', + productId: config.productId, + planId, + cadence, + successUrl: checkoutSuccessUrl, + cancelUrl: checkoutCancelUrl, + }; + if (typeof trialDays === 'number' && !Number.isNaN(trialDays)) { + body['trialDays'] = trialDays; + } + const { data, error: fnErr } = await supabase.functions.invoke( + stripeFunctionName, + { body } + ); + if (fnErr) { + throw fnErr; + } + const checkoutUrl = (data as { checkoutUrl?: string })?.checkoutUrl; + if (!checkoutUrl) { + throw billingError( + 'stripe', + 'Missing checkoutUrl from billing-stripe function' + ); + } + return { checkoutUrl }; + } catch (e) { + setError(mapUnknownError(e)); + return null; + } finally { + setPending(false); + } + }, + [ + supabase, + stripeFunctionName, + config.productId, + checkoutSuccessUrl, + checkoutCancelUrl, + ] + ); + + return useMemo( + () => ({ startCheckout, pending, error }), + [startCheckout, pending, error] + ); +} diff --git a/packages/billing/src/hooks/useCustomerPortal.test.tsx b/packages/billing/src/hooks/useCustomerPortal.test.tsx new file mode 100644 index 00000000..3630dbd6 --- /dev/null +++ b/packages/billing/src/hooks/useCustomerPortal.test.tsx @@ -0,0 +1,63 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { renderHook, waitFor } from '@testing-library/react'; +import { + baseBillingContextExtras, + testBillingConfig, +} from '../test/billingFixtures.js'; +import { useCustomerPortal } from './useCustomerPortal.js'; + +const { invoke, mockSupabase, refreshSubscription } = vi.hoisted(() => { + const invoke = vi.fn(); + const refreshSubscription = vi.fn().mockResolvedValue(undefined); + const mockSupabase = { + functions: { invoke }, + } as unknown as ReturnType['supabase']; + return { invoke, mockSupabase, refreshSubscription }; +}); + +vi.mock('./useBillingContext.js', () => ({ + useBillingContext: () => ({ + ...baseBillingContextExtras(), + supabase: mockSupabase, + config: testBillingConfig, + portalReturnUrl: 'https://return', + stripeFunctionName: 'billing-stripe', + refreshSubscription, + }), +})); + +describe('useCustomerPortal', () => { + beforeEach(() => { + invoke.mockReset(); + // Plain object avoids jsdom "Not implemented: navigation" on `location.href = url` + Object.defineProperty(window, 'location', { + configurable: true, + writable: true, + value: { href: '' } as unknown as Location, + }); + }); + + it('returns portal url on success', async () => { + invoke.mockResolvedValue({ + data: { url: 'https://billing.stripe/session' }, + error: null, + }); + const { result } = renderHook(() => useCustomerPortal()); + const url = await result.current.openPortal(); + expect(url).toBe('https://billing.stripe/session'); + expect(invoke).toHaveBeenCalledWith( + 'billing-stripe', + expect.objectContaining({ + body: expect.objectContaining({ action: 'portal' }), + }) + ); + }); + + it('returns null when url missing', async () => { + invoke.mockResolvedValue({ data: {}, error: null }); + const { result } = renderHook(() => useCustomerPortal()); + const url = await result.current.openPortal(); + expect(url).toBeNull(); + await waitFor(() => expect(result.current.error?.kind).toBe('stripe')); + }); +}); diff --git a/packages/billing/src/hooks/useCustomerPortal.ts b/packages/billing/src/hooks/useCustomerPortal.ts new file mode 100644 index 00000000..72019d55 --- /dev/null +++ b/packages/billing/src/hooks/useCustomerPortal.ts @@ -0,0 +1,77 @@ +import { useCallback, useMemo, useState } from 'react'; +import { billingError, mapUnknownError } from '../errors.js'; +import type { BillingError } from '../errors.js'; +import type { ProductBillingConfig } from '../schema.js'; +import { useBillingContext } from './useBillingContext.js'; + +export function useCustomerPortal< + P extends ProductBillingConfig = ProductBillingConfig, +>(): { + openPortal: () => Promise; + pending: boolean; + error: BillingError | null; +} { + const { + supabase, + config, + portalReturnUrl, + stripeFunctionName, + refreshSubscription, + } = useBillingContext

(); + const [pending, setPending] = useState(false); + const [error, setError] = useState(null); + + const openPortal = useCallback(async () => { + setPending(true); + setError(null); + try { + const { data, error: fnErr } = await supabase.functions.invoke( + stripeFunctionName, + { + body: { + action: 'portal', + productId: config.productId, + returnUrl: portalReturnUrl, + }, + } + ); + if (fnErr) throw fnErr; + const url = (data as { url?: string })?.url; + if (!url) { + throw billingError( + 'stripe', + 'Missing portal url from billing-stripe function' + ); + } + // Redirect immediately. A pre-navigation `refreshSubscription()` can block or fail + // (slow / hung / RLS) and made the portal feel "broken" with no on-page error. + // After the user returns to `portalReturnUrl`, BillingProvider reloads subscription. + if (typeof window !== 'undefined' && window.location) { + try { + window.location.href = url; + } catch { + // jsdom throws "Not implemented: navigation" on full navigation; real browsers proceed. + } + return url; + } + await refreshSubscription(); + return url; + } catch (e) { + setError(mapUnknownError(e)); + return null; + } finally { + setPending(false); + } + }, [ + supabase, + stripeFunctionName, + config.productId, + portalReturnUrl, + refreshSubscription, + ]); + + return useMemo( + () => ({ openPortal, pending, error }), + [openPortal, pending, error] + ); +} diff --git a/packages/billing/src/hooks/useFeature.test.tsx b/packages/billing/src/hooks/useFeature.test.tsx new file mode 100644 index 00000000..2283e7d1 --- /dev/null +++ b/packages/billing/src/hooks/useFeature.test.tsx @@ -0,0 +1,117 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { renderHook } from '@testing-library/react'; +import type { InferFeatureKeys } from '../schema.js'; +import { testBillingConfig } from '../test/billingFixtures.js'; +import { useFeature } from './useFeature.js'; +import { usePlan } from './usePlan.js'; + +vi.mock('./usePlan.js', () => ({ + usePlan: vi.fn(), +})); + +type TestConfig = typeof testBillingConfig; +type TestFeatureKey = InferFeatureKeys & string; + +describe('useFeature', () => { + beforeEach(() => { + vi.mocked(usePlan).mockReset(); + }); + + it('returns loading state when plan is missing', () => { + vi.mocked(usePlan).mockReturnValue({ + data: null, + loading: true, + error: null, + }); + const { result } = renderHook(() => + useFeature('feature_x') + ); + expect(result.current.enabled).toBe(false); + expect(result.current.value).toBeNull(); + expect(result.current.loading).toBe(true); + }); + + it('maps boolean feature to enabled and value', () => { + vi.mocked(usePlan).mockReturnValue({ + data: { + id: 'p', + product_id: 'x', + display_name: 'P', + description: null, + price_cents: 0, + billing_period: 'free', + stripe_price_id_monthly: null, + stripe_price_id_annual: null, + stripe_product_id: null, + features: { feature_x: true }, + usage_limits: {}, + trial_period_days: 0, + is_public: true, + display_order: 1, + }, + loading: false, + error: null, + }); + const { result } = renderHook(() => + useFeature('feature_x') + ); + expect(result.current.enabled).toBe(true); + expect(result.current.value).toBe(true); + }); + + it('maps numeric feature to enabled with numeric value', () => { + vi.mocked(usePlan).mockReturnValue({ + data: { + id: 'p', + product_id: 'x', + display_name: 'P', + description: null, + price_cents: 0, + billing_period: 'free', + stripe_price_id_monthly: null, + stripe_price_id_annual: null, + stripe_product_id: null, + features: { num: 7 }, + usage_limits: {}, + trial_period_days: 0, + is_public: true, + display_order: 1, + }, + loading: false, + error: null, + }); + const { result } = renderHook(() => + useFeature('num') + ); + expect(result.current.enabled).toBe(true); + expect(result.current.value).toBe(7); + }); + + it('treats absent feature on plan as disabled', () => { + vi.mocked(usePlan).mockReturnValue({ + data: { + id: 'p', + product_id: 'x', + display_name: 'P', + description: null, + price_cents: 0, + billing_period: 'free', + stripe_price_id_monthly: null, + stripe_price_id_annual: null, + stripe_product_id: null, + features: { feature_x: true }, + usage_limits: {}, + trial_period_days: 0, + is_public: true, + display_order: 1, + }, + loading: false, + error: null, + }); + const { result } = renderHook(() => + useFeature('num') + ); + expect(result.current.enabled).toBe(false); + expect(result.current.value).toBeNull(); + }); +}); diff --git a/packages/billing/src/hooks/useFeature.ts b/packages/billing/src/hooks/useFeature.ts new file mode 100644 index 00000000..1765614b --- /dev/null +++ b/packages/billing/src/hooks/useFeature.ts @@ -0,0 +1,32 @@ +import { useMemo } from 'react'; +import { readPlanFeatureValue } from '../featureAccess.js'; +import type { InferFeatureKeys } from '../schema.js'; +import type { ProductBillingConfig } from '../schema.js'; +import { usePlan } from './usePlan.js'; + +export function useFeature< + P extends ProductBillingConfig, + K extends InferFeatureKeys

& string, +>( + featureKey: K +): { + enabled: boolean; + value: boolean | number | null; + loading: boolean; + error: import('../errors.js').BillingError | null; +} { + const { data: plan, loading, error } = usePlan

(); + return useMemo(() => { + if (!plan) { + return { enabled: false, value: null, loading, error }; + } + const raw = readPlanFeatureValue(plan.features, featureKey as string); + if (typeof raw === 'boolean') { + return { enabled: raw, value: raw, loading, error }; + } + if (typeof raw === 'number') { + return { enabled: true, value: raw, loading, error }; + } + return { enabled: false, value: null, loading, error }; + }, [plan, featureKey, loading, error]); +} diff --git a/packages/billing/src/hooks/useInvoices.test.tsx b/packages/billing/src/hooks/useInvoices.test.tsx new file mode 100644 index 00000000..28059492 --- /dev/null +++ b/packages/billing/src/hooks/useInvoices.test.tsx @@ -0,0 +1,157 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { renderHook, waitFor } from '@testing-library/react'; +import { + baseBillingContextExtras, + testBillingConfig, +} from '../test/billingFixtures.js'; +import type { BillingInvoiceRow } from '../types.js'; +import { useInvoices } from './useInvoices.js'; + +const { range, from, mockSupabase, order } = vi.hoisted(() => { + const range = vi.fn(); + const order = vi.fn(() => ({ range })); + const from = vi.fn(() => ({ + select: vi.fn(() => ({ + order, + })), + })); + const mockSupabase = { + from, + } as unknown as ReturnType['supabase']; + return { range, from, mockSupabase, order }; +}); + +const ctxImpl = vi.fn(); + +vi.mock('./useBillingContext.js', () => ({ + useBillingContext: () => ctxImpl(), +})); + +describe('useInvoices', () => { + beforeEach(() => { + range.mockReset(); + order.mockClear(); + from.mockClear(); + ctxImpl.mockImplementation(() => ({ + ...baseBillingContextExtras(), + userId: 'user-1', + supabase: mockSupabase, + config: testBillingConfig, + })); + }); + + it('loads first page', async () => { + const inv: BillingInvoiceRow = { + id: 'inv1', + user_id: 'user-1', + stripe_invoice_id: 'in_1', + stripe_customer_id: 'cus', + stripe_subscription_id: null, + amount_due: 100, + amount_paid: 0, + currency: 'usd', + status: 'open', + description: null, + hosted_invoice_url: null, + invoice_pdf_url: null, + period_start: null, + period_end: null, + created_at: new Date().toISOString(), + finalized_at: null, + paid_at: null, + }; + range.mockResolvedValue({ data: [inv], error: null }); + const { result } = renderHook(() => useInvoices({ pageSize: 20 })); + await waitFor(() => expect(result.current.items.length).toBe(1)); + expect(result.current.hasMore).toBe(false); + }); + + it('returns empty list when userId is missing', async () => { + ctxImpl.mockImplementation(() => ({ + ...baseBillingContextExtras(), + userId: null, + supabase: mockSupabase, + config: testBillingConfig, + })); + const { result } = renderHook(() => useInvoices()); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.items).toEqual([]); + expect(from).not.toHaveBeenCalled(); + }); + + it('loadMore appends next page when hasMore', async () => { + const inv1: BillingInvoiceRow = { + id: 'inv1', + user_id: 'user-1', + stripe_invoice_id: 'in_1', + stripe_customer_id: 'cus', + stripe_subscription_id: null, + amount_due: 100, + amount_paid: 0, + currency: 'usd', + status: 'open', + description: null, + hosted_invoice_url: null, + invoice_pdf_url: null, + period_start: null, + period_end: null, + created_at: '2026-01-02T00:00:00.000Z', + finalized_at: null, + paid_at: null, + }; + const inv2: BillingInvoiceRow = { + ...inv1, + id: 'inv2', + stripe_invoice_id: 'in_2', + }; + range + .mockResolvedValueOnce({ + data: Array.from({ length: 20 }, (_, i) => ({ ...inv1, id: `i${i}` })), + error: null, + }) + .mockResolvedValueOnce({ data: [inv2], error: null }); + const { result } = renderHook(() => useInvoices({ pageSize: 20 })); + await waitFor(() => expect(result.current.items.length).toBe(20)); + expect(result.current.hasMore).toBe(true); + await result.current.loadMore(); + await waitFor(() => expect(result.current.items.length).toBe(21)); + expect(result.current.items[20]?.id).toBe('inv2'); + }); + + it('surfaces query errors', async () => { + range.mockResolvedValue({ data: null, error: new Error('query failed') }); + const { result } = renderHook(() => useInvoices()); + await waitFor(() => expect(result.current.error).not.toBeNull()); + expect(result.current.items).toEqual([]); + expect(result.current.hasMore).toBe(false); + }); + + it('refresh reloads first page', async () => { + const inv: BillingInvoiceRow = { + id: 'inv1', + user_id: 'user-1', + stripe_invoice_id: 'in_1', + stripe_customer_id: 'cus', + stripe_subscription_id: null, + amount_due: 100, + amount_paid: 0, + currency: 'usd', + status: 'open', + description: null, + hosted_invoice_url: null, + invoice_pdf_url: null, + period_start: null, + period_end: null, + created_at: new Date().toISOString(), + finalized_at: null, + paid_at: null, + }; + range.mockResolvedValue({ data: [inv], error: null }); + const { result } = renderHook(() => useInvoices({ pageSize: 20 })); + await waitFor(() => expect(result.current.items.length).toBe(1)); + range.mockClear(); + await result.current.refresh(); + await waitFor(() => expect(range).toHaveBeenCalled()); + expect(range).toHaveBeenCalledWith(0, 19); + }); +}); diff --git a/packages/billing/src/hooks/useInvoices.ts b/packages/billing/src/hooks/useInvoices.ts new file mode 100644 index 00000000..cba7463d --- /dev/null +++ b/packages/billing/src/hooks/useInvoices.ts @@ -0,0 +1,90 @@ +import { useCallback, useEffect, useState } from 'react'; +import { mapUnknownError } from '../errors.js'; +import type { BillingError } from '../errors.js'; +import type { ProductBillingConfig } from '../schema.js'; +import type { BillingInvoiceRow } from '../types.js'; +import { useBillingContext } from './useBillingContext.js'; + +/** + * Paginated `billing_invoices` rows (client can SELECT own rows; webhook writes data). + */ +export function useInvoices< + Config extends ProductBillingConfig = ProductBillingConfig, +>({ + pageSize = 20, +}: { + pageSize?: number; +} = {}): { + items: BillingInvoiceRow[]; + loading: boolean; + error: BillingError | null; + hasMore: boolean; + loadMore: () => Promise; + refresh: () => Promise; +} { + const { supabase, userId } = useBillingContext(); + const [items, setItems] = useState([]); + const [offset, setOffset] = useState(0); + const [hasMore, setHasMore] = useState(true); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const loadPage = useCallback( + async (start: number, append: boolean) => { + if (!userId) { + setItems([]); + setHasMore(false); + setLoading(false); + return; + } + setError(null); + if (!append) setLoading(true); + try { + const end = start + pageSize - 1; + const { data, error: qErr } = await supabase + .from('billing_invoices') + .select('*') + .order('created_at', { ascending: false }) + .range(start, end); + if (qErr) throw qErr; + const page = (data as BillingInvoiceRow[] | null) ?? []; + setItems(prev => (append ? [...prev, ...page] : page)); + setOffset(start + page.length); + setHasMore(page.length === pageSize); + } catch (e) { + setError(mapUnknownError(e)); + if (!append) setItems([]); + setHasMore(false); + } finally { + setLoading(false); + } + }, + [supabase, userId, pageSize] + ); + + useEffect(() => { + setOffset(0); + setItems([]); + setHasMore(true); + void loadPage(0, false); + }, [loadPage, userId]); + + const loadMore = useCallback(async () => { + if (!hasMore || loading) return; + await loadPage(offset, true); + }, [hasMore, loading, loadPage, offset]); + + const refresh = useCallback(async () => { + setOffset(0); + await loadPage(0, false); + }, [loadPage]); + + return { + items, + loading, + error, + hasMore, + loadMore, + refresh, + }; +} diff --git a/packages/billing/src/hooks/usePlan.test.tsx b/packages/billing/src/hooks/usePlan.test.tsx new file mode 100644 index 00000000..2072ced0 --- /dev/null +++ b/packages/billing/src/hooks/usePlan.test.tsx @@ -0,0 +1,97 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { renderHook, waitFor } from '@testing-library/react'; +import type { SubscriptionRow } from '../types.js'; +import { baseBillingContextExtras, testPlan } from '../test/billingFixtures.js'; +import { usePlan } from './usePlan.js'; + +const hp = vi.hoisted(() => { + const maybeSingle = vi.fn(); + const supabase = { + from: vi.fn(() => ({ + select: vi.fn(() => ({ + eq: vi.fn(() => ({ maybeSingle })), + })), + })), + }; + const subscription: SubscriptionRow = { + id: 'sub_1', + user_id: 'user_1', + product_id: 'test_product', + plan_id: 'plan_free', + stripe_customer_id: null, + stripe_subscription_id: 'sub_x', + stripe_price_id: 'price_monthly', + status: 'active', + current_period_start: null, + current_period_end: null, + cancel_at_period_end: false, + pending_target_plan_id: null, + canceled_at: null, + trial_start: null, + trial_end: null, + }; + return { maybeSingle, supabase, subscription, subscriptionLoading: false }; +}); + +vi.mock('./useBillingContext.js', () => ({ + useBillingContext: () => ({ + ...baseBillingContextExtras(), + supabase: + hp.supabase as unknown as import('@supabase/supabase-js').SupabaseClient, + subscription: hp.subscription, + subscriptionLoading: hp.subscriptionLoading, + }), +})); + +describe('usePlan', () => { + beforeEach(() => { + hp.maybeSingle.mockReset(); + hp.subscriptionLoading = false; + hp.subscription = { + ...hp.subscription, + plan_id: 'plan_free', + }; + }); + + it('stays idle while subscription is loading', () => { + hp.subscriptionLoading = true; + const { result } = renderHook(() => usePlan()); + expect(result.current.loading).toBe(true); + expect(hp.supabase.from).not.toHaveBeenCalled(); + }); + + it('clears plan when subscription has no plan_id', async () => { + hp.subscription = { ...hp.subscription, plan_id: '' }; + const { result } = renderHook(() => usePlan()); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.data).toBeNull(); + expect(hp.supabase.from).not.toHaveBeenCalled(); + }); + + it('loads plan from billing_plans', async () => { + const row = { ...testPlan(), features: {} }; + hp.maybeSingle.mockResolvedValue({ data: row, error: null }); + const { result } = renderHook(() => usePlan()); + await waitFor(() => expect(result.current.data?.id).toBe('plan_free')); + expect(hp.supabase.from).toHaveBeenCalledWith('billing_plans'); + expect(result.current.error).toBeNull(); + }); + + it('maps query errors', async () => { + hp.maybeSingle.mockResolvedValue({ + data: null, + error: { message: 'query failed' }, + }); + const { result } = renderHook(() => usePlan()); + await waitFor(() => expect(result.current.error).not.toBeNull()); + expect(result.current.error?.kind).toBe('unknown'); + }); + + it('sets plan null when query returns no row', async () => { + hp.maybeSingle.mockResolvedValue({ data: null, error: null }); + const { result } = renderHook(() => usePlan()); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.data).toBeNull(); + expect(result.current.error).toBeNull(); + }); +}); diff --git a/packages/billing/src/hooks/usePlan.ts b/packages/billing/src/hooks/usePlan.ts new file mode 100644 index 00000000..20b7fb22 --- /dev/null +++ b/packages/billing/src/hooks/usePlan.ts @@ -0,0 +1,71 @@ +import { useEffect, useMemo, useState } from 'react'; +import { mapUnknownError } from '../errors.js'; +import type { BillingError } from '../errors.js'; +import type { ProductBillingConfig } from '../schema.js'; +import type { Plan } from '../types.js'; +import { useBillingContext } from './useBillingContext.js'; + +export function usePlan< + P extends ProductBillingConfig = ProductBillingConfig, +>(): { + data: Plan | null; + loading: boolean; + error: BillingError | null; +} { + const { supabase, subscription, subscriptionLoading } = + useBillingContext

(); + const [plan, setPlan] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + if (subscriptionLoading) { + setLoading(true); + return; + } + if (!subscription?.plan_id) { + setPlan(null); + setLoading(false); + return; + } + let cancelled = false; + void (async () => { + setLoading(true); + setError(null); + try { + const { data, error: qErr } = await supabase + .from('billing_plans') + .select('*') + .eq('id', subscription.plan_id) + .maybeSingle(); + if (qErr) throw qErr; + if (!cancelled) { + setPlan( + data + ? ({ + ...data, + features: data.features as Plan['features'], + } as Plan) + : null + ); + } + } catch (e) { + if (!cancelled) setError(mapUnknownError(e)); + } finally { + if (!cancelled) setLoading(false); + } + })(); + return () => { + cancelled = true; + }; + }, [supabase, subscription?.plan_id, subscriptionLoading]); + + return useMemo( + () => ({ + data: plan, + loading: loading || subscriptionLoading, + error, + }), + [plan, loading, subscriptionLoading, error] + ); +} diff --git a/packages/billing/src/hooks/usePlanCatalog.test.tsx b/packages/billing/src/hooks/usePlanCatalog.test.tsx new file mode 100644 index 00000000..b9b59456 --- /dev/null +++ b/packages/billing/src/hooks/usePlanCatalog.test.tsx @@ -0,0 +1,53 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { renderHook, waitFor } from '@testing-library/react'; +import { + baseBillingContextExtras, + testBillingConfig, + testPlan, +} from '../test/billingFixtures.js'; +import { usePlanCatalog } from './usePlanCatalog.js'; + +const { order, mockSupabase } = vi.hoisted(() => { + const order = vi.fn(); + const mockSupabase = { + from: vi.fn(() => ({ + select: vi.fn(() => ({ + eq: vi.fn(() => ({ + eq: vi.fn(() => ({ + order, + })), + })), + })), + })), + } as unknown as ReturnType['supabase']; + return { order, mockSupabase }; +}); + +vi.mock('./useBillingContext.js', () => ({ + useBillingContext: () => ({ + ...baseBillingContextExtras(), + supabase: mockSupabase, + config: testBillingConfig, + }), +})); + +describe('usePlanCatalog', () => { + beforeEach(() => { + order.mockReset(); + order.mockResolvedValue({ data: [testPlan()], error: null }); + }); + + it('loads public plans', async () => { + const { result } = renderHook(() => usePlanCatalog()); + await waitFor(() => expect(result.current.plans.length).toBe(1)); + expect(result.current.plans[0].id).toBe('plan_free'); + expect(result.current.error).toBeNull(); + }); + + it('maps query errors', async () => { + order.mockResolvedValue({ data: null, error: { message: 'db' } }); + const { result } = renderHook(() => usePlanCatalog()); + await waitFor(() => expect(result.current.error).not.toBeNull()); + expect(result.current.plans).toEqual([]); + }); +}); diff --git a/packages/billing/src/hooks/usePlanCatalog.ts b/packages/billing/src/hooks/usePlanCatalog.ts new file mode 100644 index 00000000..a2feb2de --- /dev/null +++ b/packages/billing/src/hooks/usePlanCatalog.ts @@ -0,0 +1,49 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { mapUnknownError } from '../errors.js'; +import type { BillingError } from '../errors.js'; +import type { ProductBillingConfig } from '../schema.js'; +import type { Plan } from '../types.js'; +import { useBillingContext } from './useBillingContext.js'; + +export function usePlanCatalog< + P extends ProductBillingConfig = ProductBillingConfig, +>(): { + plans: Plan[]; + loading: boolean; + error: BillingError | null; + refresh: () => Promise; +} { + const { supabase, config } = useBillingContext

(); + const [plans, setPlans] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const refresh = useCallback(async () => { + setLoading(true); + setError(null); + try { + const { data, error: qErr } = await supabase + .from('billing_plans') + .select('*') + .eq('product_id', config.productId) + .eq('is_public', true) + .order('display_order', { ascending: true }); + if (qErr) throw qErr; + setPlans(((data ?? []) as Plan[]) ?? []); + } catch (e) { + setError(mapUnknownError(e)); + setPlans([]); + } finally { + setLoading(false); + } + }, [supabase, config.productId]); + + useEffect(() => { + void refresh(); + }, [refresh]); + + return useMemo( + () => ({ plans, loading, error, refresh }), + [plans, loading, error, refresh] + ); +} diff --git a/packages/billing/src/hooks/useRecordUsage.test.tsx b/packages/billing/src/hooks/useRecordUsage.test.tsx new file mode 100644 index 00000000..ef0bf2fe --- /dev/null +++ b/packages/billing/src/hooks/useRecordUsage.test.tsx @@ -0,0 +1,72 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { renderHook, waitFor } from '@testing-library/react'; +import { + baseBillingContextExtras, + testBillingConfig, +} from '../test/billingFixtures.js'; +import { useRecordUsage } from './useRecordUsage.js'; + +const { rpc, mockSupabase, refreshMeter } = vi.hoisted(() => { + const rpc = vi.fn(); + const refreshMeter = vi.fn().mockResolvedValue(undefined); + const mockSupabase = { + rpc, + } as unknown as ReturnType['supabase']; + return { rpc, mockSupabase, refreshMeter }; +}); + +vi.mock('./useUsage.js', () => ({ + useUsage: () => ({ refresh: refreshMeter }), +})); + +vi.mock('./useBillingContext.js', () => ({ + useBillingContext: () => ({ + ...baseBillingContextExtras(), + supabase: mockSupabase, + config: testBillingConfig, + }), +})); + +describe('useRecordUsage', () => { + beforeEach(() => { + rpc.mockReset(); + refreshMeter.mockClear(); + }); + + it('records usage and refreshes meter', async () => { + rpc.mockResolvedValue({ error: null }); + const { result } = renderHook(() => useRecordUsage('ai')); + await result.current.record(2); + await waitFor(() => expect(result.current.lastRecordedAt).not.toBeNull()); + expect(refreshMeter).toHaveBeenCalled(); + expect(rpc).toHaveBeenCalledWith( + 'billing_record_usage_event', + expect.objectContaining({ + p_event_type: 'ai', + p_quantity: 2, + p_metadata: {}, + }) + ); + }); + + it('passes metadata to RPC', async () => { + rpc.mockResolvedValue({ error: null }); + const { result } = renderHook(() => useRecordUsage('ai')); + await result.current.record(1, { source: 'test' }); + await waitFor(() => expect(result.current.lastRecordedAt).not.toBeNull()); + expect(rpc).toHaveBeenCalledWith( + 'billing_record_usage_event', + expect.objectContaining({ + p_metadata: { source: 'test' }, + }) + ); + }); + + it('maps RPC errors', async () => { + rpc.mockResolvedValue({ error: { message: 'rpc fail' } }); + const { result } = renderHook(() => useRecordUsage('ai')); + await result.current.record(); + await waitFor(() => expect(result.current.error).not.toBeNull()); + expect(refreshMeter).toHaveBeenCalled(); + }); +}); diff --git a/packages/billing/src/hooks/useRecordUsage.ts b/packages/billing/src/hooks/useRecordUsage.ts new file mode 100644 index 00000000..538d61c2 --- /dev/null +++ b/packages/billing/src/hooks/useRecordUsage.ts @@ -0,0 +1,61 @@ +import { useCallback, useMemo, useState } from 'react'; +import { mapUnknownError } from '../errors.js'; +import type { BillingError } from '../errors.js'; +import type { InferMeterKeys } from '../schema.js'; +import type { ProductBillingConfig } from '../schema.js'; +import { useBillingContext } from './useBillingContext.js'; +import { useUsage } from './useUsage.js'; + +export function useRecordUsage< + P extends ProductBillingConfig, + K extends InferMeterKeys

& string, +>( + meterKey: K +): { + record: ( + quantity?: number, + metadata?: Record + ) => Promise; + pending: boolean; + error: BillingError | null; + lastRecordedAt: number | null; +} { + const { supabase, config } = useBillingContext

(); + const { refresh } = useUsage(meterKey); + const [pending, setPending] = useState(false); + const [error, setError] = useState(null); + const [lastRecordedAt, setLastRecordedAt] = useState(null); + + const record = useCallback( + async (quantity = 1, metadata?: Record) => { + setPending(true); + setError(null); + try { + const meta = metadata ?? {}; + const { error: rpcErr } = await supabase.rpc( + 'billing_record_usage_event', + { + p_product_id: config.productId, + p_event_type: meterKey, + p_quantity: quantity, + p_metadata: meta, + } + ); + if (rpcErr) throw rpcErr; + setLastRecordedAt(Date.now()); + await refresh(); + } catch (e) { + setError(mapUnknownError(e)); + await refresh(); + } finally { + setPending(false); + } + }, + [supabase, config.productId, meterKey, refresh] + ); + + return useMemo( + () => ({ record, pending, error, lastRecordedAt }), + [record, pending, error, lastRecordedAt] + ); +} diff --git a/packages/billing/src/hooks/useSubscription.test.tsx b/packages/billing/src/hooks/useSubscription.test.tsx new file mode 100644 index 00000000..be520719 --- /dev/null +++ b/packages/billing/src/hooks/useSubscription.test.tsx @@ -0,0 +1,57 @@ +import { describe, expect, it, vi } from 'vitest'; +import { renderHook } from '@testing-library/react'; +import React from 'react'; +import { BillingReactContext } from '../context.js'; +import { + baseBillingContextExtras, + testSubscription, +} from '../test/billingFixtures.js'; +import type { BillingContextValue } from '../types.js'; +import { useSubscription } from './useSubscription.js'; + +function makeWrapper(ctx: BillingContextValue) { + return function Wrapper({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); + }; +} + +describe('useSubscription', () => { + it('exposes subscription row and refresh', () => { + const refresh = vi.fn().mockResolvedValue(undefined); + const sub = testSubscription({ status: 'trialing' }); + const ctx: BillingContextValue = { + ...baseBillingContextExtras(), + subscription: sub, + subscriptionLoading: false, + subscriptionError: null, + refreshSubscription: refresh, + }; + const { result } = renderHook(() => useSubscription(), { + wrapper: makeWrapper(ctx), + }); + expect(result.current.data).toEqual(sub); + expect(result.current.loading).toBe(false); + expect(result.current.error).toBeNull(); + void result.current.refresh(); + expect(refresh).toHaveBeenCalledTimes(1); + }); + + it('reflects loading and error from context', () => { + const ctx: BillingContextValue = { + ...baseBillingContextExtras(), + subscription: null, + subscriptionLoading: true, + subscriptionError: { kind: 'network', message: 'offline' }, + refreshSubscription: vi.fn(), + }; + const { result } = renderHook(() => useSubscription(), { + wrapper: makeWrapper(ctx), + }); + expect(result.current.loading).toBe(true); + expect(result.current.error?.kind).toBe('network'); + }); +}); diff --git a/packages/billing/src/hooks/useSubscription.ts b/packages/billing/src/hooks/useSubscription.ts new file mode 100644 index 00000000..fd0497a5 --- /dev/null +++ b/packages/billing/src/hooks/useSubscription.ts @@ -0,0 +1,30 @@ +import { useMemo } from 'react'; +import { useBillingContext } from './useBillingContext.js'; +import type { ProductBillingConfig } from '../schema.js'; +import type { SubscriptionRow } from '../types.js'; +import type { BillingError } from '../errors.js'; + +export function useSubscription< + P extends ProductBillingConfig = ProductBillingConfig, +>(): { + data: SubscriptionRow | null; + loading: boolean; + error: BillingError | null; + refresh: () => Promise; +} { + const { + subscription, + subscriptionLoading, + subscriptionError, + refreshSubscription, + } = useBillingContext

(); + return useMemo( + () => ({ + data: subscription, + loading: subscriptionLoading, + error: subscriptionError, + refresh: refreshSubscription, + }), + [subscription, subscriptionLoading, subscriptionError, refreshSubscription] + ); +} diff --git a/packages/billing/src/hooks/useUsage.test.tsx b/packages/billing/src/hooks/useUsage.test.tsx new file mode 100644 index 00000000..b2c2f29d --- /dev/null +++ b/packages/billing/src/hooks/useUsage.test.tsx @@ -0,0 +1,114 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { renderHook, waitFor } from '@testing-library/react'; +import { + baseBillingContextExtras, + testBillingConfig, +} from '../test/billingFixtures.js'; +import { useUsage } from './useUsage.js'; + +const { rpc, mockSupabase, usageRealtimeCb, removeChannel } = vi.hoisted(() => { + const rpc = vi.fn(); + const usageRealtimeCb = { + current: undefined as ((p: unknown) => void) | undefined, + }; + const removeChannel = vi.fn(); + const channel = vi.fn(() => { + const chain = { + on: vi.fn((type: string, _cfg: unknown, cb: (p: unknown) => void) => { + if (type === 'postgres_changes') usageRealtimeCb.current = cb; + return chain; + }), + subscribe: vi.fn(), + }; + return chain; + }); + const mockSupabase = { + rpc, + channel, + removeChannel, + } as unknown as ReturnType['supabase']; + return { rpc, mockSupabase, usageRealtimeCb, removeChannel }; +}); + +vi.mock('./useBillingContext.js', () => ({ + useBillingContext: () => ({ + ...baseBillingContextExtras(), + subscriptionLoading: false, + supabase: mockSupabase, + config: testBillingConfig, + }), +})); + +describe('useUsage', () => { + beforeEach(() => { + rpc.mockReset(); + }); + + it('parses RPC payload', async () => { + rpc.mockResolvedValue({ + data: { + used: 3, + limit: 10, + remaining: 7, + periodEnd: '2025-12-31', + periodStart: '2025-12-01', + }, + error: null, + }); + const { result } = renderHook(() => useUsage('ai')); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.used).toBe(3); + expect(result.current.limit).toBe(10); + expect(result.current.remaining).toBe(7); + expect(result.current.exceeded).toBe(false); + }); + + it('treats unauthenticated RPC payload as error', async () => { + rpc.mockResolvedValue({ data: { error: 'unauthenticated' }, error: null }); + const { result } = renderHook(() => useUsage('ai')); + await waitFor(() => + expect(result.current.error?.kind).toBe('unauthenticated') + ); + expect(result.current.used).toBe(0); + }); + + it('sets exceeded when used >= limit', async () => { + rpc.mockResolvedValue({ + data: { + used: 10, + limit: 10, + remaining: 0, + periodEnd: '', + periodStart: '', + }, + error: null, + }); + const { result } = renderHook(() => useUsage('ai')); + await waitFor(() => expect(result.current.exceeded).toBe(true)); + }); + + it('refetches when billing_usage_aggregates realtime matches product and meter', async () => { + rpc.mockResolvedValue({ + data: { + used: 0, + limit: 5, + remaining: 5, + periodEnd: '2026-01-01', + periodStart: '2025-12-01', + }, + error: null, + }); + const { result, unmount } = renderHook(() => useUsage('ai')); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(mockSupabase.channel).toHaveBeenCalled(); + rpc.mockClear(); + usageRealtimeCb.current?.({ + new: { product_id: 'test_product', event_type: 'ai' }, + old: null, + }); + await waitFor(() => expect(rpc).toHaveBeenCalled()); + expect(result.current.used).toBe(0); + unmount(); + expect(removeChannel).toHaveBeenCalled(); + }); +}); diff --git a/packages/billing/src/hooks/useUsage.ts b/packages/billing/src/hooks/useUsage.ts new file mode 100644 index 00000000..ff29d43e --- /dev/null +++ b/packages/billing/src/hooks/useUsage.ts @@ -0,0 +1,134 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { mapUnknownError } from '../errors.js'; +import type { BillingError } from '../errors.js'; +import type { InferMeterKeys } from '../schema.js'; +import type { ProductBillingConfig } from '../schema.js'; +import { useBillingContext } from './useBillingContext.js'; + +type RpcRow = { + used: number; + limit: number | null; + remaining: number | null; + periodEnd: string; + periodStart: string; +}; + +export function useUsage< + P extends ProductBillingConfig, + K extends InferMeterKeys

& string, +>( + meterKey: K +): { + used: number; + limit: number | null; + remaining: number | null; + resetsAt: string; + exceeded: boolean; + loading: boolean; + error: BillingError | null; + refresh: () => Promise; +} { + const { supabase, config, subscriptionLoading, userId } = + useBillingContext

(); + const [snap, setSnap] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const fetchUsageRef = useRef<() => Promise>(async () => {}); + + const fetchUsage = useCallback(async () => { + setLoading(true); + setError(null); + try { + const { data, error: rpcErr } = await supabase.rpc( + 'billing_get_remaining_usage', + { + p_product_id: config.productId, + p_event_type: meterKey, + } + ); + if (rpcErr) throw rpcErr; + const j = data as Record | null; + if (j?.['error'] === 'unauthenticated') { + setSnap(null); + setError(mapUnknownError(new Error('unauthenticated'))); + return; + } + setSnap({ + used: Number(j?.['used'] ?? 0), + limit: + j?.['limit'] === null || typeof j?.['limit'] === 'undefined' + ? null + : Number(j['limit']), + remaining: + j?.['remaining'] === null || typeof j?.['remaining'] === 'undefined' + ? null + : Number(j['remaining']), + periodEnd: String(j?.['periodEnd'] ?? ''), + periodStart: String(j?.['periodStart'] ?? ''), + }); + } catch (e) { + setError(mapUnknownError(e)); + setSnap(null); + } finally { + setLoading(false); + } + }, [supabase, config.productId, meterKey]); + + fetchUsageRef.current = fetchUsage; + + useEffect(() => { + void fetchUsage(); + }, [fetchUsage, subscriptionLoading]); + + useEffect(() => { + if (!userId || typeof supabase.channel !== 'function') return; + const filter = `user_id=eq.${userId}`; + const ch = supabase + .channel( + `billing_usage_aggregates:${config.productId}:${userId}:${meterKey}` + ) + .on( + 'postgres_changes', + { + event: '*', + schema: 'public', + table: 'billing_usage_aggregates', + filter, + }, + payload => { + const row = (payload.new ?? payload.old) as + | { product_id?: string; event_type?: string } + | undefined; + if ( + row?.product_id === config.productId && + row?.event_type === meterKey + ) { + void fetchUsageRef.current(); + } + } + ) + .subscribe(); + return () => { + void supabase.removeChannel(ch); + }; + }, [supabase, config.productId, userId, meterKey]); + + const exceeded = useMemo(() => { + if (!snap || snap.limit === null) return false; + return snap.used >= snap.limit; + }, [snap]); + + return useMemo( + () => ({ + used: snap?.used ?? 0, + limit: snap?.limit ?? null, + remaining: snap?.remaining ?? null, + resetsAt: snap?.periodEnd ?? '', + exceeded, + loading: loading || subscriptionLoading, + error, + refresh: fetchUsage, + }), + [snap, exceeded, loading, subscriptionLoading, error, fetchUsage] + ); +} diff --git a/packages/billing/src/index.ts b/packages/billing/src/index.ts new file mode 100644 index 00000000..8014c82b --- /dev/null +++ b/packages/billing/src/index.ts @@ -0,0 +1,71 @@ +/** + * @beakerstack/billing — shared types, schema, provider, and hooks (no platform UI). + * UI: `@beakerstack/billing/web` or `@beakerstack/billing/native`. + * Next.js App Router: `@beakerstack/billing/client` (marks use client). + */ +export { + defineBillingConfig, + productBillingConfigSchema, + billingPlanConfigSchema, + planFeatureRowSchema, + downgradeConstraintCopySchema, + type BillingConfig, + type BillingPlanConfig, + type ProductBillingConfig, + type PlanFeatureRowConfig, + type DowngradeConstraintCopy, + type InferFeatureKeys, + type InferMeterKeys, +} from './schema.js'; +export { + billingError, + mapUnknownError, + type BillingError, + type BillingErrorKind, +} from './errors.js'; +export { BillingErrorBoundary } from './BillingErrorBoundary.js'; +export { + BillingProvider, + type BillingProviderProps, +} from './BillingProvider.js'; +export type { + Plan, + PlanId, + SubscriptionRow, + UsageSnapshot, + BillingContextValue, + BillingInvoiceRow, +} from './types.js'; +export { useBillingContext } from './hooks/useBillingContext.js'; +export { useBillingConfig } from './hooks/useBillingConfig.js'; +export { useSubscription } from './hooks/useSubscription.js'; +export { usePlan } from './hooks/usePlan.js'; +export { useFeature } from './hooks/useFeature.js'; +export { useUsage } from './hooks/useUsage.js'; +export { useRecordUsage } from './hooks/useRecordUsage.js'; +export { useCheckout } from './hooks/useCheckout.js'; +export { useCustomerPortal } from './hooks/useCustomerPortal.js'; +export { usePlanCatalog } from './hooks/usePlanCatalog.js'; +export { useInvoices } from './hooks/useInvoices.js'; +export { useBillingStripeActions } from './hooks/useBillingStripeActions.js'; +export { + useBillingState, + type BillingUiState, + type BillingUiStateKind, +} from './hooks/useBillingState.js'; +export { resolveCadence, type BillingCadence } from './utils/cadence.js'; +export { + getRemainingUsage, + hasExceededLimit, + getPlanById, + canUserAccessFeature, + readPlanFeatureValue, + isFeatureAccessible, + type RemainingUsageResult, +} from './billingClient.js'; +export type { FeatureGateProps } from './components/FeatureGate.types.js'; +export type { UsageIndicatorProps } from './components/UsageIndicator.types.js'; +export type { UpgradePromptProps } from './components/UpgradePrompt.types.js'; +export type { PricingTableProps } from './components/PricingTable.types.js'; +export type { SubscriptionStatusProps } from './components/SubscriptionStatus.types.js'; +export type { CustomerPortalLinkProps } from './components/CustomerPortalLink.types.js'; diff --git a/packages/billing/src/native.ts b/packages/billing/src/native.ts new file mode 100644 index 00000000..fe0b6838 --- /dev/null +++ b/packages/billing/src/native.ts @@ -0,0 +1,9 @@ +/** React Native entry — import from `@beakerstack/billing/native` in Expo apps. */ +export { FeatureGate } from './components/FeatureGate.native.js'; +export { UsageIndicator } from './components/UsageIndicator.native.js'; +export { UpgradePrompt } from './components/UpgradePrompt.native.js'; +export { PricingTable } from './components/PricingTable.native.js'; +export { SubscriptionStatus } from './components/SubscriptionStatus.native.js'; +export { CustomerPortalLink } from './components/CustomerPortalLink.native.js'; +export { SubscriptionStatusBadge } from './components/SubscriptionStatusBadge.native.js'; +export type { SubscriptionStatusBadgeProps } from './components/SubscriptionStatusBadge.native.js'; diff --git a/packages/billing/src/schema.test.ts b/packages/billing/src/schema.test.ts new file mode 100644 index 00000000..fae6b7db --- /dev/null +++ b/packages/billing/src/schema.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'vitest'; +import { + defineBillingConfig, + planFeatureRowSchema, + productBillingConfigSchema, +} from './schema.js'; + +const validPlan = { + id: 'p1', + displayName: 'Pro', + priceCents: 1000, + billingPeriod: 'monthly' as const, + features: { a: true }, + usageLimits: { m: 10 }, +}; + +describe('defineBillingConfig', () => { + it('returns parsed config for valid input', () => { + const c = defineBillingConfig({ + productId: 'x', + displayName: 'X', + plans: [validPlan], + }); + expect(c.productId).toBe('x'); + expect(c.plans[0].id).toBe('p1'); + }); + + it('throws on invalid config', () => { + expect(() => + defineBillingConfig({ + productId: '', + displayName: 'X', + plans: [validPlan], + }) + ).toThrow(); + }); +}); + +describe('planFeatureRowSchema', () => { + it('accepts boolean row', () => { + const row = planFeatureRowSchema.parse({ + id: '1', + featureKey: 'f', + kind: 'boolean', + label: 'L', + }); + expect(row.kind).toBe('boolean'); + }); + + it('accepts number row', () => { + const row = planFeatureRowSchema.parse({ + id: '2', + featureKey: 'cap', + kind: 'number', + unlimitedLabel: '∞', + limitedLabelTemplate: '{count}', + }); + expect(row.kind).toBe('number'); + }); +}); + +describe('productBillingConfigSchema', () => { + it('accepts optional planFeatureRows and copy blocks', () => { + const parsed = productBillingConfigSchema.parse({ + productId: 'prod', + displayName: 'Prod', + plans: [validPlan], + planFeatureRows: [ + { + id: 'b', + featureKey: 'a', + kind: 'boolean', + label: 'A', + }, + ], + downgradeConstraintCopy: { + collectionsOverCap: 'x', + }, + usageMeterCopy: { + m: { label: 'Meter' }, + }, + usageLimitsCopy: { + collectionsRowName: 'C', + }, + }); + expect(parsed.planFeatureRows).toHaveLength(1); + expect(parsed.downgradeConstraintCopy?.collectionsOverCap).toBe('x'); + }); +}); diff --git a/packages/billing/src/schema.ts b/packages/billing/src/schema.ts new file mode 100644 index 00000000..f3e79102 --- /dev/null +++ b/packages/billing/src/schema.ts @@ -0,0 +1,104 @@ +import { z } from 'zod'; + +/** One row in the plan card “What’s included” list (product-level, shared by all tiers). */ +export const planFeatureRowSchema = z.discriminatedUnion('kind', [ + z.object({ + id: z.string().min(1), + featureKey: z.string().min(1), + kind: z.literal('boolean'), + /** Line text (check vs X reflects plan value). */ + label: z.string().min(1), + }), + z.object({ + id: z.string().min(1), + featureKey: z.string().min(1), + kind: z.literal('number'), + unlimitedLabel: z.string().min(1), + /** Use `{count}` for the numeric cap (non-negative). */ + limitedLabelTemplate: z.string().min(1), + }), +]); + +export type PlanFeatureRowConfig = z.infer; + +/** Optional templates for downgrade constraint messages (`{placeholder}` replaced at runtime). */ +export const downgradeConstraintCopySchema = z.object({ + collectionsOverCap: z.string().optional(), + /** `{featureLabel}`, `{exclusivePlanName}`, `{targetPlanName}` */ + booleanFeatureLoss: z.string().optional(), + meterOverCap: z.string().optional(), + /** `{maxItems}`, `{cap}`, `{targetPlan}` — max items in any single collection vs target tier cap */ + itemsPerCollectionOverCap: z.string().optional(), +}); + +export type DowngradeConstraintCopy = z.infer< + typeof downgradeConstraintCopySchema +>; + +/** Single plan row as embedded in app config (mirrors public.billing_plans semantics, minus DB-only columns). */ +export const billingPlanConfigSchema = z.object({ + id: z.string().min(1), + displayName: z.string(), + description: z.string().optional(), + /** Short line under the plan title on pricing cards (falls back to `description` then app defaults). */ + planCardTagline: z.string().optional(), + priceCents: z.number().int().nonnegative(), + billingPeriod: z.enum(['free', 'monthly', 'yearly', 'one_time']), + /** @deprecated Use stripePriceIdMonthly / stripePriceIdAnnual; kept for older configs. */ + stripePriceId: z.string().nullable().optional(), + stripePriceIdMonthly: z.string().nullable().optional(), + stripePriceIdAnnual: z.string().nullable().optional(), + stripeProductId: z.string().nullable().optional(), + features: z.record(z.union([z.boolean(), z.number()])), + usageLimits: z.record(z.number()), + trialPeriodDays: z.number().int().nonnegative().optional(), + isPublic: z.boolean().optional(), + displayOrder: z.number().int().optional(), +}); + +export const productBillingConfigSchema = z.object({ + productId: z.string().min(1), + displayName: z.string(), + description: z.string().optional(), + plans: z.array(billingPlanConfigSchema).min(1), + /** Plan card feature bullets; omit to use app-level defaults. */ + planFeatureRows: z.array(planFeatureRowSchema).optional(), + /** Downgrade blocker copy; omit to use app-level defaults. */ + downgradeConstraintCopy: downgradeConstraintCopySchema.optional(), + /** Usage page meter labels keyed by meter id (e.g. `ai_summarize`). */ + usageMeterCopy: z + .record( + z.object({ + label: z.string(), + description: z.string().optional(), + }) + ) + .optional(), + /** Usage page “Limits” row titles / footnote. */ + usageLimitsCopy: z + .object({ + collectionsRowName: z.string().optional(), + itemsRowName: z.string().optional(), + collectionsFootnote: z.string().optional(), + }) + .optional(), +}); + +export type BillingPlanConfig = z.infer; +export type ProductBillingConfig = z.infer; + +export type BillingConfig = ProductBillingConfig; + +export function defineBillingConfig( + config: C +): C { + return productBillingConfigSchema.parse(config) as C; +} + +/** Union of feature keys declared across all plans in config. */ +export type InferFeatureKeys

= + keyof P['plans'][number]['features']; + +/** Union of meter keys in usage_limits across plans. */ +export type InferMeterKeys

= + keyof P['plans'][number]['usageLimits']; diff --git a/packages/billing/src/test/billingFixtures.ts b/packages/billing/src/test/billingFixtures.ts new file mode 100644 index 00000000..e494ef29 --- /dev/null +++ b/packages/billing/src/test/billingFixtures.ts @@ -0,0 +1,77 @@ +import { defineBillingConfig } from '../schema.js'; +import type { Plan, SubscriptionRow } from '../types.js'; + +export const testBillingConfig = defineBillingConfig({ + productId: 'test_product', + displayName: 'Test', + plans: [ + { + id: 'plan_free', + displayName: 'Free', + priceCents: 0, + billingPeriod: 'free', + stripePriceIdMonthly: null, + stripePriceIdAnnual: null, + stripeProductId: null, + features: { feature_x: true, num: 3 }, + usageLimits: { ai: 10 }, + }, + ], +}); + +export function testPlan(over: Partial = {}): Plan { + return { + id: 'plan_free', + product_id: 'test_product', + display_name: 'Free', + description: null, + price_cents: 0, + billing_period: 'free', + stripe_price_id_monthly: 'price_monthly', + stripe_price_id_annual: 'price_annual', + stripe_product_id: null, + features: { feature_x: true, num: 3 }, + usage_limits: { ai: 10 }, + trial_period_days: 0, + is_public: true, + display_order: 1, + ...over, + }; +} + +export function testSubscription( + over: Partial = {} +): SubscriptionRow { + return { + id: 'sub_1', + user_id: 'user_1', + product_id: 'test_product', + plan_id: 'plan_free', + stripe_customer_id: 'cus_1', + stripe_subscription_id: 'sub_stripe', + stripe_price_id: 'price_monthly', + status: 'active', + current_period_start: null, + current_period_end: null, + cancel_at_period_end: false, + pending_target_plan_id: null, + canceled_at: null, + trial_start: null, + trial_end: null, + ...over, + }; +} + +export function baseBillingContextExtras() { + return { + supabase: {} as import('@supabase/supabase-js').SupabaseClient, + config: testBillingConfig, + userId: 'user_1' as string | null, + subscriptionError: null, + refreshSubscription: (): Promise => Promise.resolve(), + checkoutSuccessUrl: 'https://example.com/success', + checkoutCancelUrl: 'https://example.com/cancel', + portalReturnUrl: 'https://example.com/portal', + stripeFunctionName: 'billing-stripe', + }; +} diff --git a/packages/billing/src/typeOnlyBarrel.test.ts b/packages/billing/src/typeOnlyBarrel.test.ts new file mode 100644 index 00000000..7656ba44 --- /dev/null +++ b/packages/billing/src/typeOnlyBarrel.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest'; + +const typeOnlyModules = [ + './types.js', + './components/FeatureGate.types.js', + './components/CustomerPortalLink.types.js', + './components/UsageIndicator.types.js', + './components/PricingTable.types.js', + './components/SubscriptionStatus.types.js', + './components/UpgradePrompt.types.js', +]; + +describe('component type-only modules', () => { + it.each(typeOnlyModules)('import %s', async path => { + await expect(import(path)).resolves.toBeDefined(); + }); +}); diff --git a/packages/billing/src/types.ts b/packages/billing/src/types.ts new file mode 100644 index 00000000..edffcaad --- /dev/null +++ b/packages/billing/src/types.ts @@ -0,0 +1,87 @@ +import type { BillingError } from './errors.js'; +import type { ProductBillingConfig } from './schema.js'; + +export type PlanId = string; + +/** Plan row from DB (subset used by hooks). */ +export type Plan = { + id: string; + product_id: string; + display_name: string; + description: string | null; + price_cents: number; + billing_period: string; + stripe_price_id_monthly: string | null; + stripe_price_id_annual: string | null; + stripe_product_id: string | null; + features: Record; + usage_limits: Record; + trial_period_days: number; + is_public: boolean; + display_order: number; +}; + +export type SubscriptionRow = { + id: string; + user_id: string; + product_id: string; + plan_id: string; + stripe_customer_id: string | null; + stripe_subscription_id: string | null; + /** Stripe Price id for the current subscription (monthly or annual). */ + stripe_price_id: string | null; + status: string; + current_period_start: string | null; + current_period_end: string | null; + cancel_at_period_end: boolean; + /** When set with `cancel_at_period_end`, user is scheduled to move to this plan (e.g. free). */ + pending_target_plan_id: string | null; + canceled_at: string | null; + trial_start: string | null; + trial_end: string | null; +}; + +/** Row from `billing_invoices` (client read-only). */ +export type BillingInvoiceRow = { + id: string; + user_id: string; + stripe_invoice_id: string; + stripe_customer_id: string; + stripe_subscription_id: string | null; + amount_due: number; + amount_paid: number; + currency: string; + status: string; + description: string | null; + hosted_invoice_url: string | null; + invoice_pdf_url: string | null; + period_start: string | null; + period_end: string | null; + created_at: string; + finalized_at: string | null; + paid_at: string | null; +}; + +export type UsageSnapshot = { + used: number; + limit: number | null; + remaining: number | null; + periodEnd: string; + periodStart: string; +}; + +export type BillingContextValue< + P extends ProductBillingConfig = ProductBillingConfig, +> = { + supabase: import('@supabase/supabase-js').SupabaseClient; + config: P; + userId: string | null; + subscription: SubscriptionRow | null; + subscriptionLoading: boolean; + subscriptionError: BillingError | null; + refreshSubscription: () => Promise; + checkoutSuccessUrl: string; + checkoutCancelUrl: string; + portalReturnUrl: string; + stripeFunctionName: string; +}; diff --git a/packages/billing/src/utils/cadence.test.ts b/packages/billing/src/utils/cadence.test.ts new file mode 100644 index 00000000..8d9a2c2e --- /dev/null +++ b/packages/billing/src/utils/cadence.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest'; +import { resolveCadence } from './cadence.js'; +import { testPlan, testSubscription } from '../test/billingFixtures.js'; + +describe('resolveCadence', () => { + const plan = testPlan(); + + it('returns null without plan', () => { + expect(resolveCadence(null, testSubscription())).toBeNull(); + }); + + it('returns null without stripe_price_id on subscription', () => { + expect( + resolveCadence(plan, testSubscription({ stripe_price_id: null })) + ).toBeNull(); + }); + + it('returns monthly when price matches monthly', () => { + expect( + resolveCadence( + plan, + testSubscription({ stripe_price_id: 'price_monthly' }) + ) + ).toBe('monthly'); + }); + + it('returns annual when price matches annual', () => { + expect( + resolveCadence( + plan, + testSubscription({ stripe_price_id: 'price_annual' }) + ) + ).toBe('annual'); + }); + + it('returns null when price matches neither', () => { + expect( + resolveCadence(plan, testSubscription({ stripe_price_id: 'price_other' })) + ).toBeNull(); + }); +}); diff --git a/packages/billing/src/utils/cadence.ts b/packages/billing/src/utils/cadence.ts new file mode 100644 index 00000000..35444fae --- /dev/null +++ b/packages/billing/src/utils/cadence.ts @@ -0,0 +1,22 @@ +import type { Plan, SubscriptionRow } from '../types.js'; + +export type BillingCadence = 'monthly' | 'annual'; + +/** + * Which Stripe price cadence the user is on, from subscription + plan price ids. + */ +export function resolveCadence( + plan: Plan | null, + sub: SubscriptionRow | null +): BillingCadence | null { + if (!plan || !sub?.stripe_price_id) { + return null; + } + if (sub.stripe_price_id === plan.stripe_price_id_monthly) { + return 'monthly'; + } + if (sub.stripe_price_id === plan.stripe_price_id_annual) { + return 'annual'; + } + return null; +} diff --git a/packages/billing/src/utils/subscriptionStatusLabel.test.ts b/packages/billing/src/utils/subscriptionStatusLabel.test.ts new file mode 100644 index 00000000..d5af02d0 --- /dev/null +++ b/packages/billing/src/utils/subscriptionStatusLabel.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest'; +import { subscriptionStatusLabel } from './subscriptionStatusLabel.js'; +import { testSubscription } from '../test/billingFixtures.js'; + +describe('subscriptionStatusLabel', () => { + it('returns em dash without subscription', () => { + expect(subscriptionStatusLabel(null, 'Jan 1')).toBe('—'); + expect(subscriptionStatusLabel(undefined, 'Jan 1')).toBe('—'); + }); + + it('returns em dash when status missing', () => { + expect( + subscriptionStatusLabel( + testSubscription({ status: '' as unknown as string }), + 'Jan 1' + ) + ).toBe('—'); + }); + + it('shows cancelled copy when cancel_at_period_end and active', () => { + const s = testSubscription({ + status: 'active', + cancel_at_period_end: true, + }); + expect(subscriptionStatusLabel(s, 'Feb 2')).toBe( + 'Cancelled - Subscription ends on Feb 2' + ); + }); + + it('shows cancelled copy for trialing with cancel at period end', () => { + const s = testSubscription({ + status: 'trialing', + cancel_at_period_end: true, + }); + expect(subscriptionStatusLabel(s, 'Mar 3')).toContain('Cancelled'); + }); + + it('shows active copy for active without cancel', () => { + const s = testSubscription({ + status: 'active', + cancel_at_period_end: false, + }); + expect(subscriptionStatusLabel(s, 'Apr 4')).toBe( + 'Active - Renews on Apr 4' + ); + }); + + it('shows active copy for trialing', () => { + const s = testSubscription({ status: 'trialing' }); + expect(subscriptionStatusLabel(s, 'May 5')).toContain('Active'); + }); + + it('returns Free for free status', () => { + expect( + subscriptionStatusLabel(testSubscription({ status: 'free' }), 'x') + ).toBe('Free'); + }); + + it('returns raw status for other values', () => { + expect( + subscriptionStatusLabel(testSubscription({ status: 'paused' }), 'x') + ).toBe('paused'); + }); +}); diff --git a/packages/billing/src/utils/subscriptionStatusLabel.ts b/packages/billing/src/utils/subscriptionStatusLabel.ts new file mode 100644 index 00000000..7b2224e0 --- /dev/null +++ b/packages/billing/src/utils/subscriptionStatusLabel.ts @@ -0,0 +1,24 @@ +import type { SubscriptionRow } from '../types.js'; + +/** + * Human-readable status label aligned with Stripe lifecycle semantics. + */ +export function subscriptionStatusLabel( + sub: SubscriptionRow | null | undefined, + periodEndLabel: string +): string { + if (!sub?.status) return '—'; + if ( + sub.cancel_at_period_end && + (sub.status === 'active' || sub.status === 'trialing') + ) { + return `Cancelled - Subscription ends on ${periodEndLabel}`; + } + if (sub.status === 'active' || sub.status === 'trialing') { + return `Active - Renews on ${periodEndLabel}`; + } + if (sub.status === 'free') { + return 'Free'; + } + return sub.status; +} diff --git a/packages/billing/src/web.ts b/packages/billing/src/web.ts new file mode 100644 index 00000000..1bfe72c4 --- /dev/null +++ b/packages/billing/src/web.ts @@ -0,0 +1,9 @@ +/** Web entry — import from `@beakerstack/billing/web` in Vite apps. */ +export { FeatureGate } from './components/FeatureGate.web.js'; +export { UsageIndicator } from './components/UsageIndicator.web.js'; +export { UpgradePrompt } from './components/UpgradePrompt.web.js'; +export { PricingTable } from './components/PricingTable.web.js'; +export { SubscriptionStatus } from './components/SubscriptionStatus.web.js'; +export { CustomerPortalLink } from './components/CustomerPortalLink.web.js'; +export { SubscriptionStatusBadge } from './components/SubscriptionStatusBadge.web.js'; +export type { SubscriptionStatusBadgeProps } from './components/SubscriptionStatusBadge.web.js'; diff --git a/packages/billing/tsconfig.json b/packages/billing/tsconfig.json new file mode 100644 index 00000000..78368353 --- /dev/null +++ b/packages/billing/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "jsx": "react-jsx", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "isolatedModules": true + }, + "include": ["src/**/*"] +} diff --git a/packages/billing/vitest.config.ts b/packages/billing/vitest.config.ts new file mode 100644 index 00000000..5a0d3d51 --- /dev/null +++ b/packages/billing/vitest.config.ts @@ -0,0 +1,36 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { defineConfig } from 'vitest/config'; + +const pkgRoot = path.dirname(fileURLToPath(new URL(import.meta.url))); +const repoRoot = path.resolve(pkgRoot, '../..'); + +export default defineConfig({ + resolve: { + alias: { + 'react-native': path.join(repoRoot, 'node_modules/react-native-web'), + }, + }, + test: { + environment: 'jsdom', + maxWorkers: 1, + setupFiles: ['./vitest.setup.ts'], + include: ['src/**/*.{test,spec}.{ts,tsx}'], + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html', 'lcov'], + exclude: [ + 'node_modules/', + '**/*.d.ts', + '**/dist/**', + '**/vitest.config.*', + '**/vitest.setup.ts', + '**/*.{test,spec}.{ts,tsx}', + 'src/test/**', + // Type-only modules: no executable statements after TS emit; covered via imports in typeOnlyBarrel.test.ts + 'src/types.ts', + 'src/**/*.types.ts', + ], + }, + }, +}); diff --git a/packages/billing/vitest.setup.ts b/packages/billing/vitest.setup.ts new file mode 100644 index 00000000..0bcc78cb --- /dev/null +++ b/packages/billing/vitest.setup.ts @@ -0,0 +1,7 @@ +import { cleanup } from '@testing-library/react'; +import { afterEach } from 'vitest'; +import '@testing-library/jest-dom/vitest'; + +afterEach(() => { + cleanup(); +}); diff --git a/packages/shared-tests/__tests__/components/primitives/Modal.native.test.tsx b/packages/shared-tests/__tests__/components/primitives/Modal.native.test.tsx new file mode 100644 index 00000000..5d0b8f1e --- /dev/null +++ b/packages/shared-tests/__tests__/components/primitives/Modal.native.test.tsx @@ -0,0 +1,65 @@ +import { describe, it, expect, jest, beforeEach } from '@jest/globals'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { Text } from 'react-native'; +import '@testing-library/jest-dom'; +import { Modal } from '@beakerstack/shared/components/primitives/Modal.native'; + +describe('Modal (Native)', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders children when open', () => { + const onClose = jest.fn(); + render( + + Modal body + + ); + expect(screen.getByText('Modal body')).toBeInTheDocument(); + }); + + it('renders title and close control when title is set', () => { + const onClose = jest.fn(); + render( + + Content + + ); + expect(screen.getByText('Settings')).toBeInTheDocument(); + expect(screen.getByText('×')).toBeInTheDocument(); + }); + + it('hides close button in header when showCloseButton is false', () => { + const onClose = jest.fn(); + render( + + Content + + ); + expect(screen.queryByText('×')).not.toBeInTheDocument(); + }); + + it('calls onClose when header close is pressed', () => { + const onClose = jest.fn(); + render( + + Content + + ); + fireEvent.click(screen.getByText('×')); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('calls onClose when backdrop is activated', () => { + const onClose = jest.fn(); + render( + + Body + + ); + const closeTargets = screen.getAllByLabelText('Close dialog'); + fireEvent.click(closeTargets[0]); + expect(onClose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/shared-tests/__tests__/components/primitives/Modal.test.tsx b/packages/shared-tests/__tests__/components/primitives/Modal.test.tsx new file mode 100644 index 00000000..b8901eca --- /dev/null +++ b/packages/shared-tests/__tests__/components/primitives/Modal.test.tsx @@ -0,0 +1,156 @@ +import { + describe, + it, + expect, + jest, + beforeEach, + afterEach, +} from '@jest/globals'; +import { render, screen, fireEvent, within } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { Modal } from '@beakerstack/shared/components/primitives/Modal.web'; + +describe('Modal (Web)', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + }); + + it('renders nothing when closed', () => { + const onClose = jest.fn(); + render( + + Body + + ); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + it('renders dialog with children when open', () => { + const onClose = jest.fn(); + render( + +

Body text

+ + ); + const dialog = screen.getByRole('dialog'); + expect(within(dialog).getByText('My title')).toBeInTheDocument(); + expect(within(dialog).getByText('Body text')).toBeInTheDocument(); + }); + + it('uses aria-label when title is absent', () => { + const onClose = jest.fn(); + render( + + X + + ); + expect( + screen.getByRole('dialog', { name: 'Custom label' }) + ).toBeInTheDocument(); + }); + + it('calls onClose when Escape is pressed', () => { + const onClose = jest.fn(); + render( + + X + + ); + fireEvent.keyDown(document, { + key: 'Escape', + code: 'Escape', + bubbles: true, + }); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('calls onClose when backdrop is clicked', () => { + const onClose = jest.fn(); + render( + + Inner + + ); + const dialog = screen.getByRole('dialog'); + const backdrop = dialog.firstElementChild as HTMLElement; + fireEvent.click(backdrop); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('does not close when panel content is clicked', () => { + const onClose = jest.fn(); + render( + + + + ); + fireEvent.click(screen.getByRole('button', { name: 'Inside' })); + expect(onClose).not.toHaveBeenCalled(); + }); + + it('calls onClose from header close button', () => { + const onClose = jest.fn(); + render( + + Body + + ); + fireEvent.click(screen.getByRole('button', { name: 'Close dialog' })); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('hides header close button when showCloseButton is false', () => { + const onClose = jest.fn(); + render( + + Body + + ); + expect( + screen.queryByRole('button', { name: 'Close dialog' }) + ).not.toBeInTheDocument(); + }); + + it('applies size max-width class to panel', () => { + const onClose = jest.fn(); + const { rerender } = render( + + S + + ); + let dialog = screen.getByRole('dialog'); + let panel = dialog.querySelector('.max-w-sm'); + expect(panel).toBeTruthy(); + + rerender( + + L + + ); + dialog = screen.getByRole('dialog'); + panel = dialog.querySelector('.max-w-lg'); + expect(panel).toBeTruthy(); + }); + + it('merges className and contentClassName', () => { + const onClose = jest.fn(); + render( + + C + + ); + const dialog = screen.getByRole('dialog'); + expect(dialog.querySelector('.panel-extra')).toBeTruthy(); + expect(dialog.querySelector('.content-extra')).toBeTruthy(); + }); +}); diff --git a/packages/shared-tests/__tests__/components/primitives/Skeleton.native.test.tsx b/packages/shared-tests/__tests__/components/primitives/Skeleton.native.test.tsx new file mode 100644 index 00000000..44645804 --- /dev/null +++ b/packages/shared-tests/__tests__/components/primitives/Skeleton.native.test.tsx @@ -0,0 +1,16 @@ +import { describe, it, expect } from '@jest/globals'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { Skeleton } from '@beakerstack/shared/components/primitives/Skeleton.native'; + +describe('Skeleton (Native)', () => { + it('renders block with progressbar role', () => { + render(); + expect(screen.getByRole('progressbar')).toBeTruthy(); + }); + + it('Skeleton.Text renders wrapper progressbar', () => { + render(); + expect(screen.getByRole('progressbar')).toBeTruthy(); + }); +}); diff --git a/packages/shared-tests/__tests__/components/primitives/Skeleton.test.tsx b/packages/shared-tests/__tests__/components/primitives/Skeleton.test.tsx new file mode 100644 index 00000000..d8da212f --- /dev/null +++ b/packages/shared-tests/__tests__/components/primitives/Skeleton.test.tsx @@ -0,0 +1,54 @@ +import { describe, it, expect } from '@jest/globals'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { Skeleton } from '@beakerstack/shared/components/primitives/Skeleton.web'; + +describe('Skeleton (Web)', () => { + it('renders with status role and loading label', () => { + render(); + const el = screen.getByTestId('sk'); + expect(el).toHaveAttribute('role', 'status'); + expect(el).toHaveAttribute('aria-label', 'Loading'); + }); + + it('applies default height and pulse classes', () => { + render(); + const el = screen.getByTestId('sk'); + expect(el.className).toMatch(/animate-pulse/); + expect(el).toHaveStyle({ minHeight: '16px' }); + }); + + it('does not add w-full when className includes width utility', () => { + render(); + const el = screen.getByTestId('sk'); + expect(el.className).toMatch(/w-24/); + expect(el.className).not.toMatch(/\bw-full\b/); + }); + + it('applies rounded variants', () => { + const { rerender } = render(); + expect(screen.getByTestId('sk').className).toMatch(/rounded-full/); + + rerender(); + expect(screen.getByTestId('sk').className).toMatch(/rounded-none/); + }); + + it('Skeleton.Text renders multiple lines', () => { + const { container } = render(); + const statuses = container.querySelectorAll('[role="status"]'); + expect(statuses.length).toBe(5); + expect(statuses[0].className).toMatch(/space-y-2/); + }); + + it('Skeleton.Text applies lastLineWidth', () => { + const { rerender, container } = render( + + ); + let lastLine = container.querySelector('.w-1\\/2'); + expect(lastLine).toBeTruthy(); + + rerender(); + lastLine = container.querySelector('.space-y-2 .w-full'); + expect(lastLine).toBeTruthy(); + }); +}); diff --git a/packages/shared-tests/__tests__/components/profile/AvatarUpload.native.test.tsx b/packages/shared-tests/__tests__/components/profile/AvatarUpload.native.test.tsx index a8675747..a4229df2 100644 --- a/packages/shared-tests/__tests__/components/profile/AvatarUpload.native.test.tsx +++ b/packages/shared-tests/__tests__/components/profile/AvatarUpload.native.test.tsx @@ -23,6 +23,16 @@ jest.mock('expo-image-picker', () => ({ MediaTypeOptions: { Images: 'Images', }, + UIImagePickerPresentationStyle: { + FULL_SCREEN: 'fullScreen', + PAGE_SHEET: 'pageSheet', + FORM_SHEET: 'formSheet', + CURRENT_CONTEXT: 'currentContext', + OVER_FULL_SCREEN: 'overFullScreen', + OVER_CURRENT_CONTEXT: 'overCurrentContext', + POPOVER: 'popover', + AUTOMATIC: 'automatic', + }, })); // Mock expo-file-system diff --git a/packages/shared-tests/__tests__/hooks/useAuth.native.test.ts b/packages/shared-tests/__tests__/hooks/useAuth.native.test.ts index 0cb247b3..bbe38200 100644 --- a/packages/shared-tests/__tests__/hooks/useAuth.native.test.ts +++ b/packages/shared-tests/__tests__/hooks/useAuth.native.test.ts @@ -2,10 +2,10 @@ import { renderHook, act, waitFor } from '@testing-library/react'; import { useAuth, configureGoogleSignIn, + resetGoogleSignInModuleStateForTests, } from '@beakerstack/shared/hooks/useAuth.native'; import type { SupabaseClient, User, Session } from '@supabase/supabase-js'; -// Mock expo-constants jest.mock('expo-constants', () => ({ default: { expoConfig: { @@ -25,7 +25,15 @@ jest.mock('expo-constants', () => ({ }, })); -// Mock @react-native-google-signin/google-signin +/** Mutable flags read by the hoisted Google Sign-In mock factory and getters */ +const googleSignInMockControl = { + /** Synchronous throw when the module is first evaluated (import().catch path) */ + throwOnFactory: false, + /** Throw when accessing GoogleSignin / statusCodes exports (getGoogleSignIn catch) */ + throwOnAccess: false, + throwMessage: 'Module not found', +}; + const mockGoogleSignin = { configure: jest.fn(), hasPlayServices: jest.fn().mockResolvedValue(undefined), @@ -43,26 +51,64 @@ const mockStatusCodes = { PLAY_SERVICES_NOT_AVAILABLE: 'PLAY_SERVICES_NOT_AVAILABLE', }; -// Mock the module before any imports jest.mock( '@react-native-google-signin/google-signin', - () => ({ - GoogleSignin: mockGoogleSignin, - statusCodes: mockStatusCodes, - }), + () => { + if (googleSignInMockControl.throwOnFactory) { + throw new Error(googleSignInMockControl.throwMessage); + } + return { + get GoogleSignin() { + if (googleSignInMockControl.throwOnAccess) { + throw new Error(googleSignInMockControl.throwMessage); + } + return mockGoogleSignin; + }, + get statusCodes() { + if (googleSignInMockControl.throwOnAccess) { + throw new Error(googleSignInMockControl.throwMessage); + } + return mockStatusCodes; + }, + }; + }, { virtual: true } ); -// Mock Logger jest.mock('@beakerstack/shared/utils/logger', () => ({ Logger: { debug: jest.fn(), + info: jest.fn(), warn: jest.fn(), error: jest.fn(), }, })); -// Mock Supabase client +async function flushPromises(times = 8) { + for (let i = 0; i < times; i += 1) { + await Promise.resolve(); + } +} + +/** + * `configureGoogleSignIn` schedules work with `setTimeout(100)`; the timeout must be + * registered while fake timers are active, then advanced, or the callback never runs. + */ +async function runDeferredGoogleConfigure(setup: () => void): Promise { + jest.useFakeTimers(); + try { + setup(); + await act(async () => { + jest.advanceTimersByTime(100); + }); + await act(async () => { + await flushPromises(); + }); + } finally { + jest.useRealTimers(); + } +} + const createMockSupabaseClient = (hasStorage = false) => { const mockUser: User = { id: 'test-user-id', @@ -147,6 +193,9 @@ const createMockSupabaseClient = (hasStorage = false) => { describe('useAuth (Native)', () => { beforeEach(() => { jest.clearAllMocks(); + resetGoogleSignInModuleStateForTests(); + googleSignInMockControl.throwOnFactory = false; + googleSignInMockControl.throwOnAccess = false; // @ts-expect-error test-only assignment to global __DEV__ global.__DEV__ = false; }); @@ -287,7 +336,6 @@ describe('useAuth (Native)', () => { await result.current.signOut(); }); - // Should have attempted to clear storage expect(mockStorage?.getAllKeys).toHaveBeenCalled(); expect(mockStorage?.removeItem).toHaveBeenCalledWith('sb-test-auth-token'); expect(mockStorage?.removeItem).toHaveBeenCalledWith('supabase.auth.token'); @@ -316,7 +364,6 @@ describe('useAuth (Native)', () => { await result.current.signOut(); }); - // Should still clear state even if storage fails expect(result.current.user).toBeNull(); expect(result.current.session).toBeNull(); }); @@ -342,7 +389,6 @@ describe('useAuth (Native)', () => { await result.current.signOut(); }); - // Should still clear state expect(result.current.user).toBeNull(); expect(result.current.session).toBeNull(); }); @@ -363,12 +409,39 @@ describe('useAuth (Native)', () => { await result.current.signOut(); }); - // Should still clear storage and state expect(mockStorage?.getAllKeys).toHaveBeenCalled(); expect(result.current.user).toBeNull(); expect(result.current.session).toBeNull(); }); + it('should warn when getAllKeys fails in catch path after signOut throws', async () => { + const { Logger } = require('@beakerstack/shared/utils/logger'); + const { mockClient, mockStorage } = createMockSupabaseClient(true); + (mockClient.auth.signOut as jest.Mock).mockRejectedValue( + new Error('Network error') + ); + (mockStorage?.getAllKeys as jest.Mock).mockRejectedValue( + new Error('Storage error in catch') + ); + + const { result } = renderHook(() => useAuth(mockClient)); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + await act(async () => { + await result.current.signOut(); + }); + + expect(Logger.warn).toHaveBeenCalledWith( + '[useAuth] Failed to clear AsyncStorage:', + expect.any(Error) + ); + expect(result.current.user).toBeNull(); + expect(result.current.session).toBeNull(); + }); + it('should handle sign out when no storage available', async () => { const { mockClient } = createMockSupabaseClient(false); const errorMessage = 'Sign out failed'; @@ -387,20 +460,159 @@ describe('useAuth (Native)', () => { await result.current.signOut(); }); - // Should still clear state expect(result.current.user).toBeNull(); expect(result.current.session).toBeNull(); }); - it.skip('should handle Google sign in successfully', async () => { - // Skip - complex lazy import mocking required - // Reset the module cache to ensure fresh import - jest.resetModules(); + it('should handle auth state changes', async () => { + const { mockClient, mockUser, mockSession, getAuthStateCallback } = + createMockSupabaseClient(); + const { result } = renderHook(() => useAuth(mockClient)); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + const callback = getAuthStateCallback(); + expect(callback).not.toBeNull(); + + act(() => { + callback!('SIGNED_IN', mockSession); + }); + + await waitFor(() => { + expect(result.current.user).toEqual(mockUser); + expect(result.current.session).toEqual(mockSession); + }); + }); + + it('should handle auth state change to signed out', async () => { + const { mockClient, getAuthStateCallback } = createMockSupabaseClient(); + const { result } = renderHook(() => useAuth(mockClient)); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + const callback = getAuthStateCallback(); + expect(callback).not.toBeNull(); + + act(() => { + callback!('SIGNED_OUT', null); + }); + + await waitFor(() => { + expect(result.current.user).toBeNull(); + expect(result.current.session).toBeNull(); + }); + }); + + it('should warn when configureGoogleSignIn called without webClientId', () => { + const { Logger } = require('@beakerstack/shared/utils/logger'); + configureGoogleSignIn({}); + expect(Logger.warn).toHaveBeenCalledWith( + '[useAuth] Google Sign-In not configured: webClientId is missing', + expect.objectContaining({ + hasWebClientId: false, + hasIosClientId: false, + hasAndroidClientId: false, + }) + ); + }); +}); + +describe('Google Sign-In and configureGoogleSignIn', () => { + beforeEach(() => { + jest.clearAllTimers(); + resetGoogleSignInModuleStateForTests(); + jest.clearAllMocks(); + googleSignInMockControl.throwOnFactory = false; + googleSignInMockControl.throwOnAccess = false; + mockGoogleSignin.configure.mockReset(); + mockGoogleSignin.hasPlayServices.mockReset(); + mockGoogleSignin.hasPlayServices.mockResolvedValue(undefined); + mockGoogleSignin.signIn.mockReset(); + mockGoogleSignin.signIn.mockResolvedValue(undefined); + mockGoogleSignin.getTokens.mockReset(); + mockGoogleSignin.getTokens.mockResolvedValue({ + idToken: 'mock-id-token', + accessToken: 'mock-access-token', + }); + }); + + // Runs first: @react-native-google-signin/google-signin must not be cached yet + // so `throwOnFactory` is honored at module evaluation (import().catch path). + it('should log Logger.error when Google Sign-In import fails during configure', async () => { + const { Logger } = require('@beakerstack/shared/utils/logger'); + await runDeferredGoogleConfigure(() => { + googleSignInMockControl.throwOnFactory = true; + configureGoogleSignIn({ webClientId: 'test-web-client-id' }); + }); + expect(Logger.error).toHaveBeenCalledWith( + '[useAuth]', + expect.stringContaining('Failed to import Google Sign-In module'), + expect.any(Error) + ); + }); + + it('should configure Google Sign-In with webClientId', async () => { + await runDeferredGoogleConfigure(() => + configureGoogleSignIn({ webClientId: 'test-web-client-id' }) + ); + expect(mockGoogleSignin.configure).toHaveBeenCalledWith({ + webClientId: 'test-web-client-id', + offlineAccess: true, + }); + }); + + it('should configure Google Sign-In with iosClientId', async () => { + await runDeferredGoogleConfigure(() => + configureGoogleSignIn({ + webClientId: 'test-web-client-id', + iosClientId: 'test-ios-client-id', + }) + ); + expect(mockGoogleSignin.configure).toHaveBeenCalledWith({ + webClientId: 'test-web-client-id', + iosClientId: 'test-ios-client-id', + offlineAccess: true, + }); + }); + + it('should return same promise when configureGoogleSignIn called again after success', async () => { + let p1!: Promise; + await runDeferredGoogleConfigure(() => { + p1 = configureGoogleSignIn({ webClientId: 'test-web-client-id' }); + }); + expect(mockGoogleSignin.configure).toHaveBeenCalledTimes(1); + const p2 = configureGoogleSignIn({ webClientId: 'test-web-client-id' }); + expect(p2).toBe(p1); + await expect(p2).resolves.toBeUndefined(); + expect(mockGoogleSignin.configure).toHaveBeenCalledTimes(1); + }); + + it('should log Logger.error when GoogleSignin.configure throws', async () => { + const { Logger } = require('@beakerstack/shared/utils/logger'); + mockGoogleSignin.configure.mockImplementationOnce(() => { + throw new Error('configure boom'); + }); + await runDeferredGoogleConfigure(() => + configureGoogleSignIn({ webClientId: 'test-web-client-id' }) + ); + expect(Logger.error).toHaveBeenCalledWith( + '[useAuth]', + expect.stringContaining('Failed to configure Google Sign-In'), + expect.any(Error) + ); + }); + + it('should handle Google sign in successfully', async () => { const { mockClient } = createMockSupabaseClient(); - const { - useAuth: useAuthNative, - } = require('@beakerstack/shared/hooks/useAuth.native'); - const { result } = renderHook(() => useAuthNative(mockClient)); + await runDeferredGoogleConfigure(() => + configureGoogleSignIn({ webClientId: 'test-web-client-id' }) + ); + + const { result } = renderHook(() => useAuth(mockClient)); await waitFor(() => { expect(result.current.loading).toBe(false); @@ -419,13 +631,9 @@ describe('useAuth (Native)', () => { }); }); - it.skip('should handle Google sign in when module not available', async () => { - // Mock the import to fail - jest.resetModules(); - jest.doMock('@react-native-google-signin/google-signin', () => { - throw new Error('Module not found'); - }); - + it('should handle Google sign in when module not available', async () => { + const { Logger } = require('@beakerstack/shared/utils/logger'); + googleSignInMockControl.throwOnAccess = true; const { mockClient } = createMockSupabaseClient(); const { result } = renderHook(() => useAuth(mockClient)); @@ -434,49 +642,48 @@ describe('useAuth (Native)', () => { }); await act(async () => { - try { - await result.current.signInWithGoogle(); - } catch (err) { - expect(err).toBeInstanceOf(Error); - expect((err as Error).message).toBe( - 'Google Sign-In module not available' - ); - } + await expect(result.current.signInWithGoogle()).rejects.toThrow( + 'Google Sign-In module not available' + ); }); + + expect(Logger.warn).toHaveBeenCalledWith( + '[useAuth] Google Sign-In module not available:', + expect.any(Error) + ); }); - it.skip('should handle Google sign in when no ID token received', async () => { + it('should handle Google sign in when no ID token received', async () => { mockGoogleSignin.getTokens.mockResolvedValueOnce({ idToken: null, accessToken: 'mock-access-token', }); - - jest.resetModules(); const { mockClient } = createMockSupabaseClient(); - const { - useAuth: useAuthNative, - } = require('@beakerstack/shared/hooks/useAuth.native'); - const { result } = renderHook(() => useAuthNative(mockClient)); + await runDeferredGoogleConfigure(() => + configureGoogleSignIn({ webClientId: 'test-web-client-id' }) + ); + + const { result } = renderHook(() => useAuth(mockClient)); await waitFor(() => { expect(result.current.loading).toBe(false); }); await act(async () => { - try { - await result.current.signInWithGoogle(); - } catch (err) { - expect(err).toBeInstanceOf(Error); - expect((err as Error).message).toBe('No ID token received from Google'); - } + await expect(result.current.signInWithGoogle()).rejects.toThrow( + 'No ID token received from Google' + ); }); }); - it.skip('should handle Google sign in cancellation', async () => { + it('should handle Google sign in cancellation', async () => { const cancelError = { code: mockStatusCodes.SIGN_IN_CANCELLED }; mockGoogleSignin.signIn.mockRejectedValueOnce(cancelError); - const { mockClient } = createMockSupabaseClient(); + await runDeferredGoogleConfigure(() => + configureGoogleSignIn({ webClientId: 'test-web-client-id' }) + ); + const { result } = renderHook(() => useAuth(mockClient)); await waitFor(() => { @@ -484,22 +691,22 @@ describe('useAuth (Native)', () => { }); await act(async () => { - try { - await result.current.signInWithGoogle(); - } catch (err) { - expect(err).toBeInstanceOf(Error); - expect((err as Error).message).toBe('Google sign-in was cancelled'); - } + await expect(result.current.signInWithGoogle()).rejects.toThrow( + 'Google sign-in was cancelled' + ); }); expect(result.current.error?.message).toBe('Google sign-in was cancelled'); }); - it.skip('should handle Google sign in already in progress', async () => { + it('should handle Google sign in already in progress', async () => { const progressError = { code: mockStatusCodes.IN_PROGRESS }; mockGoogleSignin.signIn.mockRejectedValueOnce(progressError); - const { mockClient } = createMockSupabaseClient(); + await runDeferredGoogleConfigure(() => + configureGoogleSignIn({ webClientId: 'test-web-client-id' }) + ); + const { result } = renderHook(() => useAuth(mockClient)); await waitFor(() => { @@ -507,14 +714,9 @@ describe('useAuth (Native)', () => { }); await act(async () => { - try { - await result.current.signInWithGoogle(); - } catch (err) { - expect(err).toBeInstanceOf(Error); - expect((err as Error).message).toBe( - 'Google sign-in already in progress' - ); - } + await expect(result.current.signInWithGoogle()).rejects.toThrow( + 'Google sign-in already in progress' + ); }); expect(result.current.error?.message).toBe( @@ -522,11 +724,14 @@ describe('useAuth (Native)', () => { ); }); - it.skip('should handle Google Play Services not available', async () => { + it('should handle Google Play Services not available', async () => { const servicesError = { code: mockStatusCodes.PLAY_SERVICES_NOT_AVAILABLE }; mockGoogleSignin.hasPlayServices.mockRejectedValueOnce(servicesError); - const { mockClient } = createMockSupabaseClient(); + await runDeferredGoogleConfigure(() => + configureGoogleSignIn({ webClientId: 'test-web-client-id' }) + ); + const { result } = renderHook(() => useAuth(mockClient)); await waitFor(() => { @@ -534,14 +739,9 @@ describe('useAuth (Native)', () => { }); await act(async () => { - try { - await result.current.signInWithGoogle(); - } catch (err) { - expect(err).toBeInstanceOf(Error); - expect((err as Error).message).toBe( - 'Google Play Services not available' - ); - } + await expect(result.current.signInWithGoogle()).rejects.toThrow( + 'Google Play Services not available' + ); }); expect(result.current.error?.message).toBe( @@ -549,17 +749,16 @@ describe('useAuth (Native)', () => { ); }); - it.skip('should handle Google sign in auth error', async () => { - const authError = { - message: 'Auth failed', - name: 'AuthError', - status: 401, - }; + it('should handle Google sign in auth error', async () => { + const authError = new Error('Auth failed'); const { mockClient } = createMockSupabaseClient(); (mockClient.auth.signInWithIdToken as jest.Mock).mockResolvedValueOnce({ data: { user: null, session: null }, error: authError, }); + await runDeferredGoogleConfigure(() => + configureGoogleSignIn({ webClientId: 'test-web-client-id' }) + ); const { result } = renderHook(() => useAuth(mockClient)); @@ -568,118 +767,56 @@ describe('useAuth (Native)', () => { }); await act(async () => { - try { - await result.current.signInWithGoogle(); - } catch (err) { - expect(err).toEqual(authError); - } + await expect(result.current.signInWithGoogle()).rejects.toThrow( + 'Auth failed' + ); }); + + expect(result.current.error?.message).toBe('Auth failed'); }); - it('should handle auth state changes', async () => { - const { mockClient, mockUser, mockSession, getAuthStateCallback } = - createMockSupabaseClient(); + it('should throw when Google Sign-In not configured before signInWithGoogle', async () => { + const { Logger } = require('@beakerstack/shared/utils/logger'); + const { mockClient } = createMockSupabaseClient(); const { result } = renderHook(() => useAuth(mockClient)); await waitFor(() => { expect(result.current.loading).toBe(false); }); - const callback = getAuthStateCallback(); - expect(callback).not.toBeNull(); - - // Simulate auth state change - act(() => { - callback!('SIGNED_IN', mockSession); + await act(async () => { + await expect(result.current.signInWithGoogle()).rejects.toThrow( + /Google Sign-In not configured/ + ); }); - await waitFor(() => { - expect(result.current.user).toEqual(mockUser); - expect(result.current.session).toEqual(mockSession); - }); + expect(Logger.error).toHaveBeenCalledWith( + '[useAuth]', + expect.stringContaining('Google Sign-In not configured') + ); }); - it('should handle auth state change to signed out', async () => { - const { mockClient, getAuthStateCallback } = createMockSupabaseClient(); + it('should log unknown Google error code and rethrow', async () => { + const { Logger } = require('@beakerstack/shared/utils/logger'); + mockGoogleSignin.signIn.mockRejectedValueOnce({ code: 'UNKNOWN_CODE' }); + const { mockClient } = createMockSupabaseClient(); + await runDeferredGoogleConfigure(() => + configureGoogleSignIn({ webClientId: 'test-web-client-id' }) + ); + const { result } = renderHook(() => useAuth(mockClient)); await waitFor(() => { expect(result.current.loading).toBe(false); }); - const callback = getAuthStateCallback(); - expect(callback).not.toBeNull(); - - // Simulate sign out - act(() => { - callback!('SIGNED_OUT', null); - }); - - await waitFor(() => { - expect(result.current.user).toBeNull(); - expect(result.current.session).toBeNull(); - }); - }); - - describe('configureGoogleSignIn', () => { - it.skip('should configure Google Sign-In with webClientId', async () => { - // Skip - async import timing issues - configureGoogleSignIn({ webClientId: 'test-web-client-id' }); - - // Wait for async import - await waitFor(() => { - expect(mockGoogleSignin.configure).toHaveBeenCalledWith({ - webClientId: 'test-web-client-id', - offlineAccess: true, - }); - }); - }); - - it.skip('should configure Google Sign-In with iosClientId', async () => { - // Skip - async import timing issues - configureGoogleSignIn({ - webClientId: 'test-web-client-id', - iosClientId: 'test-ios-client-id', - }); - - await waitFor(() => { - expect(mockGoogleSignin.configure).toHaveBeenCalledWith({ - webClientId: 'test-web-client-id', - iosClientId: 'test-ios-client-id', - offlineAccess: true, - }); - }); - }); - - it('should warn when webClientId is missing', () => { - const { Logger } = require('@beakerstack/shared/utils/logger'); - configureGoogleSignIn({}); - expect(Logger.warn).toHaveBeenCalledWith( - '[useAuth] Google Sign-In not configured: webClientId is missing', - expect.objectContaining({ - hasWebClientId: false, - hasIosClientId: false, - hasAndroidClientId: false, - }) - ); + await act(async () => { + await expect(result.current.signInWithGoogle()).rejects.toThrow(); }); - it.skip('should handle configuration error', async () => { - // Skip - async import timing issues - jest.resetModules(); - jest.doMock('@react-native-google-signin/google-signin', () => { - throw new Error('Module not found'); - }); - - const { Logger } = require('@beakerstack/shared/utils/logger'); - configureGoogleSignIn({ webClientId: 'test-web-client-id' }); - - await waitFor(() => { - expect(Logger.warn).toHaveBeenCalledWith( - '[useAuth] Failed to configure Google Sign-In:', - expect.any(Error) - ); - }); - }); + expect(Logger.error).toHaveBeenCalledWith( + '[useAuth] Unknown Google Sign-In error code:', + 'UNKNOWN_CODE' + ); }); }); diff --git a/packages/shared-tests/babel.config.cjs b/packages/shared-tests/babel.config.cjs index c59036a8..0779df32 100644 --- a/packages/shared-tests/babel.config.cjs +++ b/packages/shared-tests/babel.config.cjs @@ -6,6 +6,8 @@ module.exports = function(api) { if (process.env.NATIVE_TESTS === 'true' || process.env.JEST_WORKER_ID) { return { presets: ['babel-preset-expo'], + // Jest cannot evaluate native dynamic import(); compile to require(). + plugins: ['babel-plugin-dynamic-import-node'], }; } diff --git a/packages/shared-tests/package.json b/packages/shared-tests/package.json index c87d4ac9..2806acac 100644 --- a/packages/shared-tests/package.json +++ b/packages/shared-tests/package.json @@ -21,6 +21,7 @@ "@testing-library/react": "^14.1.2", "@testing-library/react-native": "^12.9.0", "babel-jest": "^29.7.0", + "babel-plugin-dynamic-import-node": "^2.3.3", "babel-preset-expo": "^54.0.6", "expo": "~50.0.0", "jest": "^29.7.0", diff --git a/packages/shared/package.json b/packages/shared/package.json index e6c1cc32..bdb1a23b 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -13,11 +13,14 @@ "clean": "rm -rf dist" }, "dependencies": { + "lucide-react": "^0.460.0", "@supabase/supabase-js": "^2.38.0", + "buffer": "^6.0.3", "zod": "^3.22.0" }, "peerDependencies": { "react": "18.2.0", + "react-dom": "18.2.0", "react-native": "0.73.x", "react-router-dom": "^6" }, @@ -25,11 +28,15 @@ "react-native": { "optional": true }, + "react-dom": { + "optional": true + }, "react-router-dom": { "optional": true } }, "devDependencies": { + "@types/react-dom": "18.2.0", "@types/react": "18.2.0", "@typescript-eslint/eslint-plugin": "^7.18.0", "@typescript-eslint/parser": "^7.18.0", diff --git a/packages/shared/src/components/forms/FormButton.native.tsx b/packages/shared/src/components/forms/FormButton.native.tsx index cad7f53a..1f3c9b87 100644 --- a/packages/shared/src/components/forms/FormButton.native.tsx +++ b/packages/shared/src/components/forms/FormButton.native.tsx @@ -1,11 +1,4 @@ -import { - TouchableOpacity, - Text, - ActivityIndicator, - StyleSheet, - ViewStyle, - TextStyle, -} from 'react-native'; +import { Button, type ButtonVariant } from '../primitives/Button.native'; export interface FormButtonProps { title: string; @@ -14,13 +7,18 @@ export interface FormButtonProps { disabled?: boolean; variant?: 'primary' | 'secondary' | 'danger'; fullWidth?: boolean; - style?: ViewStyle; - textStyle?: TextStyle; + style?: import('react-native').ViewStyle; + textStyle?: import('react-native').TextStyle; +} + +function mapVariant(v: FormButtonProps['variant']): ButtonVariant { + if (v === 'danger') return 'destructive'; + if (v === 'secondary') return 'secondary'; + return 'primary'; } /** - * FormButton component for React Native - * Provides a styled button with loading and disabled states + * FormButton: thin wrapper around {@link Button} for existing auth/profile forms. */ export function FormButton({ title, @@ -32,74 +30,18 @@ export function FormButton({ style, textStyle, }: FormButtonProps) { - const isDisabled = disabled || loading; - - const getVariantStyles = () => { - switch (variant) { - case 'primary': - return { - backgroundColor: '#4F46E5', - textColor: '#FFFFFF', - }; - case 'secondary': - return { - backgroundColor: '#E5E7EB', - textColor: '#111827', - }; - case 'danger': - return { - backgroundColor: '#DC2626', - textColor: '#FFFFFF', - }; - default: - return { - backgroundColor: '#4F46E5', - textColor: '#FFFFFF', - }; - } - }; - - const variantStyles = getVariantStyles(); - return ( - - {loading ? ( - - ) : ( - - {title} - - )} - + {title} + ); } - -const styles = StyleSheet.create({ - button: { - paddingVertical: 12, - paddingHorizontal: 16, - borderRadius: 8, - alignItems: 'center', - justifyContent: 'center', - minHeight: 44, - }, - text: { - fontSize: 16, - fontWeight: '600', - }, -}); diff --git a/packages/shared/src/components/forms/FormButton.web.tsx b/packages/shared/src/components/forms/FormButton.web.tsx index 098d3d54..4fac25f3 100644 --- a/packages/shared/src/components/forms/FormButton.web.tsx +++ b/packages/shared/src/components/forms/FormButton.web.tsx @@ -1,3 +1,5 @@ +import { Button, type ButtonVariant } from '../primitives/Button.web'; + export interface FormButtonProps { title: string; onPress: () => void; @@ -9,9 +11,14 @@ export interface FormButtonProps { className?: string; } +function mapVariant(v: FormButtonProps['variant']): ButtonVariant { + if (v === 'danger') return 'destructive'; + if (v === 'secondary') return 'secondary'; + return 'primary'; +} + /** - * FormButton component for web - * Provides a styled button with loading and disabled states + * FormButton: thin wrapper around {@link Button} for existing auth/profile forms. */ export function FormButton({ title, @@ -23,59 +30,18 @@ export function FormButton({ type = 'button', className = '', }: FormButtonProps) { - const isDisabled = disabled || loading; - - const baseClasses = - 'px-4 py-2 rounded-md text-sm font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2'; - - const variantClasses = { - primary: - 'bg-primary-600 text-white hover:bg-primary-700 focus:ring-primary-500', - secondary: - 'bg-gray-200 text-gray-900 hover:bg-gray-300 focus:ring-gray-500', - danger: '!bg-red-600 !text-white hover:!bg-red-700 focus:!ring-red-500', - }; - - const disabledClasses = isDisabled - ? 'opacity-50 cursor-not-allowed' - : 'cursor-pointer'; - - const widthClasses = fullWidth ? 'w-full' : ''; - return ( - + {title} + ); } diff --git a/packages/shared/src/components/index.native.ts b/packages/shared/src/components/index.native.ts new file mode 100644 index 00000000..3e09216b --- /dev/null +++ b/packages/shared/src/components/index.native.ts @@ -0,0 +1,4 @@ +/** + * Re-exports for native consumers that prefer a barrel import. + */ +export * from './primitives/index.native'; diff --git a/packages/shared/src/components/index.web.ts b/packages/shared/src/components/index.web.ts new file mode 100644 index 00000000..9666f9d2 --- /dev/null +++ b/packages/shared/src/components/index.web.ts @@ -0,0 +1,5 @@ +/** + * Re-exports for web consumers that prefer a barrel import. + * Most apps import from `@beakerstack/shared/components//...` instead. + */ +export * from './primitives/index.web'; diff --git a/packages/shared/src/components/navigation/AppHeader.native.tsx b/packages/shared/src/components/navigation/AppHeader.native.tsx index 70dc238b..b56612f5 100644 --- a/packages/shared/src/components/navigation/AppHeader.native.tsx +++ b/packages/shared/src/components/navigation/AppHeader.native.tsx @@ -25,6 +25,7 @@ type RootStackParamList = { Signup: undefined; Dashboard: undefined; Profile: undefined; + Billing: undefined; }; type NavigationProp = NativeStackNavigationProp; diff --git a/packages/shared/src/components/navigation/AppHeader.web.tsx b/packages/shared/src/components/navigation/AppHeader.web.tsx index 6310952f..cedda2c9 100644 --- a/packages/shared/src/components/navigation/AppHeader.web.tsx +++ b/packages/shared/src/components/navigation/AppHeader.web.tsx @@ -32,7 +32,7 @@ export function AppHeader({ supabaseClient: _supabaseClient }: AppHeaderProps) { return (
-
+
{/* Left side: App icon and title */}
diff --git a/packages/shared/src/components/navigation/UserMenu.native.tsx b/packages/shared/src/components/navigation/UserMenu.native.tsx index 6f0cdad6..6af63c58 100644 --- a/packages/shared/src/components/navigation/UserMenu.native.tsx +++ b/packages/shared/src/components/navigation/UserMenu.native.tsx @@ -21,6 +21,7 @@ type RootStackParamList = { Signup: undefined; Dashboard: undefined; Profile: undefined; + Billing: undefined; }; type NavigationProp = NativeStackNavigationProp; @@ -62,7 +63,7 @@ export function UserMenu({ user, profile, navigation }: UserMenuProps) { ]); }; - const handleNavigate = (route: 'Profile' | 'Dashboard') => { + const handleNavigate = (route: 'Profile' | 'Dashboard' | 'Billing') => { setIsOpen(false); navigation.navigate(route); }; @@ -136,6 +137,13 @@ export function UserMenu({ user, profile, navigation }: UserMenuProps) { > Profile + handleNavigate('Billing')} + style={styles.menuItem} + activeOpacity={0.7} + > + Billing + handleNavigate('Dashboard')} style={styles.menuItem} diff --git a/packages/shared/src/components/navigation/UserMenu.web.tsx b/packages/shared/src/components/navigation/UserMenu.web.tsx index f6009504..c69b9d8d 100644 --- a/packages/shared/src/components/navigation/UserMenu.web.tsx +++ b/packages/shared/src/components/navigation/UserMenu.web.tsx @@ -1,4 +1,5 @@ import { useState, useRef, useEffect } from 'react'; +import { CreditCard } from 'lucide-react'; import { Link, useNavigate } from 'react-router-dom'; import type { User } from '@supabase/supabase-js'; import type { UserProfile } from '../../types/profile'; @@ -79,6 +80,14 @@ export function UserMenu({ user, profile }: UserMenuProps) { > Profile + setIsOpen(false)} + className='flex items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100' + > + + Billing + setIsOpen(false)} @@ -86,6 +95,7 @@ export function UserMenu({ user, profile }: UserMenuProps) { > Dashboard +
+ ); +} diff --git a/packages/shared/src/components/primitives/Modal.native.tsx b/packages/shared/src/components/primitives/Modal.native.tsx new file mode 100644 index 00000000..d837fdd1 --- /dev/null +++ b/packages/shared/src/components/primitives/Modal.native.tsx @@ -0,0 +1,117 @@ +import { + Modal as RNModal, + Pressable, + StyleSheet, + Text, + View, + useWindowDimensions, + type TextStyle, + type ViewStyle, +} from 'react-native'; + +export type ModalSize = 'sm' | 'md' | 'lg'; + +export type ModalProps = { + open: boolean; + onClose: () => void; + children: React.ReactNode; + title?: string; + showCloseButton?: boolean; + size?: ModalSize; + style?: ViewStyle; + contentStyle?: ViewStyle; + titleStyle?: TextStyle; +}; + +/** + * React Native modal: backdrop + centered card. + */ +export function Modal({ + open, + onClose, + children, + title, + showCloseButton = true, + style, + contentStyle, + titleStyle, +}: ModalProps) { + const { width } = useWindowDimensions(); + const maxW = Math.min(width - 32, 400); + + return ( + + + + + {!!title && ( + + + {title} + + {showCloseButton && ( + + × + + )} + + )} + {children} + + + + ); +} diff --git a/packages/shared/src/components/primitives/Modal.web.tsx b/packages/shared/src/components/primitives/Modal.web.tsx new file mode 100644 index 00000000..4f2e4a92 --- /dev/null +++ b/packages/shared/src/components/primitives/Modal.web.tsx @@ -0,0 +1,194 @@ +import { useEffect, useId, useRef, type ReactNode } from 'react'; +import { createPortal } from 'react-dom'; + +export type ModalSize = 'sm' | 'md' | 'lg'; + +export type ModalProps = { + open: boolean; + onClose: () => void; + children: ReactNode; + title?: string; + /** Renders a title row when `title` is set. */ + showCloseButton?: boolean; + size?: ModalSize; + className?: string; + /** className for the white panel */ + contentClassName?: string; + /** ARIA: override label; defaults to `title` when set */ + 'aria-label'?: string; +}; + +const sizeToMax: Record = { + sm: 'max-w-sm', + md: 'max-w-md', + lg: 'max-w-lg', +}; + +function getFocusableElements(container: HTMLElement): HTMLElement[] { + const nodes = container.querySelectorAll( + [ + 'a[href]', + 'area[href]', + 'button:not([disabled])', + 'input:not([disabled]):not([type="hidden"])', + 'select:not([disabled])', + 'textarea:not([disabled])', + 'iframe', + '[tabindex]:not([tabindex="-1"])', + '[contenteditable="true"]', + ].join(', ') + ); + return Array.from(nodes).filter( + element => + !element.hasAttribute('disabled') && + element.tabIndex !== -1 && + element.getAttribute('aria-hidden') !== 'true' + ); +} + +function getPortalRoot(): Element | null { + if (typeof document === 'undefined') return null; + return document.body; +} + +/** + * Web modal: portal overlay, scroll lock, Escape to close, focus on open. + */ +export function Modal({ + open, + onClose, + children, + title, + showCloseButton = true, + size = 'md', + className = '', + contentClassName = '', + 'aria-label': ariaLabel, +}: ModalProps) { + const id = useId(); + const titleId = title ? `modal-title-${id}` : undefined; + const panelRef = useRef(null); + const previouslyFocused = useRef(null); + + useEffect(() => { + if (!open) return; + const html = document.documentElement; + const { overflow } = html.style; + html.style.overflow = 'hidden'; + previouslyFocused.current = document.activeElement as HTMLElement; + // Focus the dialog panel + setTimeout(() => { + panelRef.current?.focus(); + }, 0); + return () => { + html.style.overflow = overflow; + previouslyFocused.current?.focus?.(); + }; + }, [open]); + + useEffect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + onClose(); + return; + } + + if (e.key !== 'Tab') return; + const panel = panelRef.current; + if (!panel) return; + const focusable = getFocusableElements(panel); + if (focusable.length === 0) { + e.preventDefault(); + panel.focus(); + return; + } + + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + if (!first || !last) { + e.preventDefault(); + panel.focus(); + return; + } + const active = document.activeElement as HTMLElement | null; + const isShift = e.shiftKey; + + if (!active || !panel.contains(active)) { + e.preventDefault(); + (isShift ? last : first).focus(); + return; + } + + if (!isShift && active === last) { + e.preventDefault(); + first.focus(); + } else if (isShift && active === first) { + e.preventDefault(); + last.focus(); + } + }; + document.addEventListener('keydown', onKey); + return () => document.removeEventListener('keydown', onKey); + }, [open, onClose]); + + if (!open) { + return null; + } + + const root = getPortalRoot(); + if (!root) { + return null; + } + + return createPortal( +
+
+
e.stopPropagation()} + > + {title && ( +
+

+ {title} +

+ {showCloseButton && ( + + )} +
+ )} +
{children}
+
+
, + root + ); +} diff --git a/packages/shared/src/components/primitives/Skeleton.native.tsx b/packages/shared/src/components/primitives/Skeleton.native.tsx new file mode 100644 index 00000000..985e33b6 --- /dev/null +++ b/packages/shared/src/components/primitives/Skeleton.native.tsx @@ -0,0 +1,86 @@ +import { View, type ViewStyle } from 'react-native'; + +export type SkeletonProps = { + className?: string; + width?: number | string; + height?: number; + rounded?: 'none' | 'sm' | 'md' | 'full'; + style?: ViewStyle; +}; + +const bg = '#E5E7EB'; + +const borderRadius: Record, number> = { + none: 0, + sm: 4, + md: 6, + full: 9999, +}; + +/** + * Simple gray placeholder (native) — no pulse animation in v1. + */ +function SkeletonBlock({ + width: w = '100%', + height: h = 16, + rounded = 'md', + style, +}: SkeletonProps) { + return ( + + ); +} + +export type SkeletonTextProps = { + lines?: number; + className?: string; + lastLineWidth?: 'full' | '3/4' | '1/2'; +}; + +function SkeletonTextBlock({ + lines = 3, + lastLineWidth = '3/4', +}: SkeletonTextProps) { + const lastWPercent: `${number}%` = + lastLineWidth === 'full' ? '100%' : lastLineWidth === '1/2' ? '50%' : '75%'; + return ( + + {Array.from({ length: lines }).map((_, i) => ( + + + + ))} + + ); +} + +/** + * `Skeleton` block plus `Skeleton.Text` multi-line helper. + */ +export const Skeleton = Object.assign(SkeletonBlock, { + Text: SkeletonTextBlock, +}); diff --git a/packages/shared/src/components/primitives/Skeleton.web.tsx b/packages/shared/src/components/primitives/Skeleton.web.tsx new file mode 100644 index 00000000..a0329edc --- /dev/null +++ b/packages/shared/src/components/primitives/Skeleton.web.tsx @@ -0,0 +1,94 @@ +import type { HTMLAttributes } from 'react'; + +export type SkeletonProps = { + className?: string; + width?: string | number; + height?: string | number; + rounded?: 'none' | 'sm' | 'md' | 'full'; +} & Omit, 'width' | 'height'>; + +const roundedMap = { + none: 'rounded-none', + sm: 'rounded', + md: 'rounded-md', + full: 'rounded-full', +}; + +/** + * Animated placeholder block (web). + */ +function SkeletonBlock({ + className = '', + width, + height, + rounded = 'md', + style, + ...rest +}: SkeletonProps) { + const hasWidthClass = /\bw-/.test(className); + return ( +
+ ); +} + +export type SkeletonTextProps = { + lines?: number; + className?: string; + lastLineWidth?: 'full' | '3/4' | '1/2'; +}; + +/** + * Stacked line skeletons for text placeholders. + */ +function SkeletonTextBlock({ + lines = 3, + className = '', + lastLineWidth = '3/4', +}: SkeletonTextProps) { + const lastW = + lastLineWidth === 'full' + ? 'w-full' + : lastLineWidth === '1/2' + ? 'w-1/2' + : 'w-3/4'; + return ( +
+ {Array.from({ length: lines }).map((_, i) => ( + + ))} +
+ ); +} + +/** + * `Skeleton` block plus `Skeleton.Text` multi-line helper. + */ +export const Skeleton = Object.assign(SkeletonBlock, { + Text: SkeletonTextBlock, +}); diff --git a/packages/shared/src/components/primitives/index.native.ts b/packages/shared/src/components/primitives/index.native.ts new file mode 100644 index 00000000..24346164 --- /dev/null +++ b/packages/shared/src/components/primitives/index.native.ts @@ -0,0 +1,12 @@ +export { + Button, + type ButtonProps, + type ButtonSize, + type ButtonVariant, +} from './Button.native'; +export { Modal, type ModalProps, type ModalSize } from './Modal.native'; +export { + Skeleton, + type SkeletonProps, + type SkeletonTextProps, +} from './Skeleton.native'; diff --git a/packages/shared/src/components/primitives/index.web.ts b/packages/shared/src/components/primitives/index.web.ts new file mode 100644 index 00000000..4e30c42f --- /dev/null +++ b/packages/shared/src/components/primitives/index.web.ts @@ -0,0 +1,12 @@ +export { + Button, + type ButtonProps, + type ButtonSize, + type ButtonVariant, +} from './Button.web'; +export { Modal, type ModalProps, type ModalSize } from './Modal.web'; +export { + Skeleton, + type SkeletonProps, + type SkeletonTextProps, +} from './Skeleton.web'; diff --git a/packages/shared/src/components/profile/AvatarUpload.native.tsx b/packages/shared/src/components/profile/AvatarUpload.native.tsx index 22fdabe3..172d24e6 100644 --- a/packages/shared/src/components/profile/AvatarUpload.native.tsx +++ b/packages/shared/src/components/profile/AvatarUpload.native.tsx @@ -11,6 +11,7 @@ import { } from 'react-native'; import * as ImagePicker from 'expo-image-picker'; import * as FileSystem from 'expo-file-system'; +import { Buffer } from 'buffer'; import type { SupabaseClient } from '@supabase/supabase-js'; import { useAvatarUpload, @@ -125,21 +126,19 @@ export function AvatarUpload({ Logger.debug('[AvatarUpload] Launching image picker...'); - // Launch image picker with timeout to prevent hanging - // On Android 13+, the system picker handles permissions automatically + // Launch image picker with timeout to prevent hanging. + // On Android 13+, the system picker handles permissions automatically. const pickerOptions: ImagePicker.ImagePickerOptions = { mediaTypes: ImagePicker.MediaTypeOptions.Images, allowsEditing: true, aspect: [1, 1], quality: 0.8, - // Android-specific options allowsMultipleSelection: false, }; - // iOS presentation style (type definition may not include all iOS options) if (Platform.OS === 'ios') { - ( - pickerOptions as unknown as { presentationStyle?: string } - ).presentationStyle = 'pageSheet'; + // Use the public expo-image-picker enum (its string value is "pageSheet"). + pickerOptions.presentationStyle = + ImagePicker.UIImagePickerPresentationStyle.PAGE_SHEET; } const pickerPromise = ImagePicker.launchImageLibraryAsync(pickerOptions); @@ -210,37 +209,15 @@ export function AvatarUpload({ // Determine MIME type from asset or default to jpeg const mimeType = asset.mimeType || 'image/jpeg'; - // Convert base64 to Uint8Array, then to Blob - // React Native doesn't have atob, so we decode base64 manually - // Simple base64 decoder for React Native - const base64Chars = - 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/='; - const bytes: number[] = []; - - // Remove any whitespace or invalid characters + // React Native doesn't have a native atob, so we use the `buffer` + // polyfill to decode base64 into a byte array. Strip any stray + // characters that the encoder may have inserted (whitespace, etc.). const cleanBase64 = base64.replace(/[^A-Za-z0-9+/=]/g, ''); - - for (let i = 0; i < cleanBase64.length; i += 4) { - const enc1 = base64Chars.indexOf(cleanBase64.charAt(i)); - const enc2 = base64Chars.indexOf(cleanBase64.charAt(i + 1)); - const enc3 = base64Chars.indexOf(cleanBase64.charAt(i + 2)); - const enc4 = base64Chars.indexOf(cleanBase64.charAt(i + 3)); - - const byte1 = (enc1 << 2) | (enc2 >> 4); - bytes.push(byte1); - - if (enc3 !== 64) { - const byte2 = ((enc2 & 15) << 4) | (enc3 >> 2); - bytes.push(byte2); - } - - if (enc4 !== 64) { - const byte3 = ((enc3 & 3) << 6) | enc4; - bytes.push(byte3); - } + const decoded = Buffer.from(cleanBase64, 'base64'); + if (decoded.length === 0) { + throw new Error('Image file is empty or could not be converted'); } - - const uint8Array = new Uint8Array(bytes); + const uint8Array = new Uint8Array(decoded); // Convert Uint8Array to ArrayBuffer for Supabase upload // ArrayBuffer is more reliable than Blob in React Native diff --git a/packages/shared/src/hooks/useAuth.native.ts b/packages/shared/src/hooks/useAuth.native.ts index 6a33df6b..1af3ef7c 100644 --- a/packages/shared/src/hooks/useAuth.native.ts +++ b/packages/shared/src/hooks/useAuth.native.ts @@ -37,6 +37,14 @@ let statusCodes: GoogleStatusCodes | null = null; let isConfigured = false; let configurePromise: Promise | null = null; +/** Clears lazy Google Sign-In module state (for unit tests only). */ +export function resetGoogleSignInModuleStateForTests(): void { + GoogleSignin = null; + statusCodes = null; + isConfigured = false; + configurePromise = null; +} + async function getGoogleSignIn(): Promise<{ GoogleSignin: GoogleSignInModule | null; statusCodes: GoogleStatusCodes | null; diff --git a/packages/shared/src/types/database.ts b/packages/shared/src/types/database.ts index cd2465fb..8b9beca0 100644 --- a/packages/shared/src/types/database.ts +++ b/packages/shared/src/types/database.ts @@ -78,6 +78,306 @@ export type Database = { } Relationships: [] } + billing_products: { + Row: { + id: string + display_name: string + description: string | null + created_at: string + } + Insert: { + id: string + display_name: string + description?: string | null + created_at?: string + } + Update: { + id?: string + display_name?: string + description?: string | null + created_at?: string + } + Relationships: [] + } + billing_plans: { + Row: { + id: string + product_id: string + display_name: string + description: string | null + price_cents: number + billing_period: string + stripe_price_id_monthly: string | null + stripe_price_id_annual: string | null + stripe_product_id: string | null + features: Json + usage_limits: Json + trial_period_days: number + is_public: boolean + display_order: number + created_at: string + updated_at: string + } + Insert: { + id: string + product_id: string + display_name: string + description?: string | null + price_cents?: number + billing_period: string + stripe_price_id_monthly?: string | null + stripe_price_id_annual?: string | null + stripe_product_id?: string | null + features?: Json + usage_limits?: Json + trial_period_days?: number + is_public?: boolean + display_order?: number + created_at?: string + updated_at?: string + } + Update: { + id?: string + product_id?: string + display_name?: string + description?: string | null + price_cents?: number + billing_period?: string + stripe_price_id_monthly?: string | null + stripe_price_id_annual?: string | null + stripe_product_id?: string | null + features?: Json + usage_limits?: Json + trial_period_days?: number + is_public?: boolean + display_order?: number + created_at?: string + updated_at?: string + } + Relationships: [] + } + billing_subscriptions: { + Row: { + id: string + user_id: string + product_id: string + plan_id: string + stripe_customer_id: string | null + stripe_subscription_id: string | null + stripe_price_id: string | null + status: string + current_period_start: string | null + current_period_end: string | null + cancel_at_period_end: boolean + canceled_at: string | null + trial_start: string | null + trial_end: string | null + created_at: string + updated_at: string + } + Insert: { + id?: string + user_id: string + product_id: string + plan_id: string + stripe_customer_id?: string | null + stripe_subscription_id?: string | null + stripe_price_id?: string | null + status: string + current_period_start?: string | null + current_period_end?: string | null + cancel_at_period_end?: boolean + canceled_at?: string | null + trial_start?: string | null + trial_end?: string | null + created_at?: string + updated_at?: string + } + Update: { + id?: string + user_id?: string + product_id?: string + plan_id?: string + stripe_customer_id?: string | null + stripe_subscription_id?: string | null + stripe_price_id?: string | null + status?: string + current_period_start?: string | null + current_period_end?: string | null + cancel_at_period_end?: boolean + canceled_at?: string | null + trial_start?: string | null + trial_end?: string | null + created_at?: string + updated_at?: string + } + Relationships: [] + } + billing_invoices: { + Row: { + id: string + user_id: string + stripe_invoice_id: string + stripe_customer_id: string + stripe_subscription_id: string | null + amount_due: number + amount_paid: number + currency: string + status: string + description: string | null + hosted_invoice_url: string | null + invoice_pdf_url: string | null + period_start: string | null + period_end: string | null + created_at: string + finalized_at: string | null + paid_at: string | null + } + Insert: { + id?: string + user_id: string + stripe_invoice_id: string + stripe_customer_id: string + stripe_subscription_id?: string | null + amount_due: number + amount_paid: number + currency: string + status: string + description?: string | null + hosted_invoice_url?: string | null + invoice_pdf_url?: string | null + period_start?: string | null + period_end?: string | null + created_at?: string + finalized_at?: string | null + paid_at?: string | null + } + Update: { + id?: string + user_id?: string + stripe_invoice_id?: string + stripe_customer_id?: string + stripe_subscription_id?: string | null + amount_due?: number + amount_paid?: number + currency?: string + status?: string + description?: string | null + hosted_invoice_url?: string | null + invoice_pdf_url?: string | null + period_start?: string | null + period_end?: string | null + created_at?: string + finalized_at?: string | null + paid_at?: string | null + } + Relationships: [] + } + billing_usage_events: { + Row: { + id: string + user_id: string + product_id: string + event_type: string + quantity: number + metadata: Json + created_at: string + } + Insert: { + id?: string + user_id: string + product_id: string + event_type: string + quantity?: number + metadata?: Json + created_at?: string + } + Update: { + id?: string + user_id?: string + product_id?: string + event_type?: string + quantity?: number + metadata?: Json + created_at?: string + } + Relationships: [] + } + billing_usage_aggregates: { + Row: { + user_id: string + product_id: string + event_type: string + period_start: string + period_end: string + count: number + } + Insert: { + user_id: string + product_id: string + event_type: string + period_start: string + period_end: string + count?: number + } + Update: { + user_id?: string + product_id?: string + event_type?: string + period_start?: string + period_end?: string + count?: number + } + Relationships: [] + } + billing_webhook_events: { + Row: { + id: string + stripe_event_id: string + event_type: string + payload: Json + processed: boolean + processed_at: string | null + error: string | null + created_at: string + } + Insert: { + id?: string + stripe_event_id: string + event_type: string + payload: Json + processed?: boolean + processed_at?: string | null + error?: string | null + created_at?: string + } + Update: { + id?: string + stripe_event_id?: string + event_type?: string + payload?: Json + processed?: boolean + processed_at?: string | null + error?: string | null + created_at?: string + } + Relationships: [] + } + billing_system_flags: { + Row: { + key: string + value: boolean + } + Insert: { + key: string + value: boolean + } + Update: { + key?: string + value?: boolean + } + Relationships: [] + } } Views: { [_ in never]: never @@ -85,6 +385,52 @@ export type Database = { Functions: { generate_username: { Args: never; Returns: string } is_valid_email: { Args: { email: string }; Returns: boolean } + ensure_billing_subscription: { + Args: { p_product_id: string } + Returns: Json + } + billing_record_usage_event: { + Args: { + p_product_id: string + p_event_type: string + p_quantity?: number + p_metadata?: Json + } + Returns: undefined + } + billing_get_remaining_usage: { + Args: { p_product_id: string; p_event_type: string } + Returns: Json + } + billing_has_exceeded_limit: { + Args: { p_product_id: string; p_event_type: string } + Returns: boolean + } + billing_demo_mode_enabled: { Args: never; Returns: boolean } + billing_demo_simulate_upgrade: { + Args: { p_product_id: string; p_plan_id: string } + Returns: Json + } + billing_demo_reset_usage: { + Args: { p_product_id: string; p_event_type: string } + Returns: undefined + } + billing_demo_get_collections: { + Args: { p_product_id: string } + Returns: { id: string; item_count: number }[] + } + billing_demo_add_collection: { + Args: { p_product_id: string } + Returns: string + } + billing_demo_add_item: { + Args: { p_product_id: string; p_collection_id: string } + Returns: number + } + billing_demo_delete_collection: { + Args: { p_product_id: string; p_collection_id: string } + Returns: undefined + } } Enums: { [_ in never]: never diff --git a/scripts/__tests__/db-migration-filenames.test.mjs b/scripts/__tests__/db-migration-filenames.test.mjs new file mode 100644 index 00000000..511b36cc --- /dev/null +++ b/scripts/__tests__/db-migration-filenames.test.mjs @@ -0,0 +1,53 @@ +import assert from 'node:assert/strict'; +import { readdir } from 'node:fs/promises'; +import path from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const THIS_FILE = fileURLToPath(import.meta.url); +const THIS_DIR = path.dirname(THIS_FILE); +const REPO_ROOT = path.resolve(THIS_DIR, '..', '..'); +const MIGRATIONS_DIR = path.join(REPO_ROOT, 'supabase', 'migrations'); +const MIGRATION_FILENAME_RE = /^(\d{14})_(.+)\.sql$/; + +async function getSqlMigrationFiles() { + const entries = await readdir(MIGRATIONS_DIR, { withFileTypes: true }); + return entries + .filter(entry => entry.isFile() && entry.name.endsWith('.sql')) + .map(entry => entry.name) + .sort(); +} + +test('all SQL migrations use the expected timestamped filename format', async () => { + const files = await getSqlMigrationFiles(); + const invalid = files.filter(name => !MIGRATION_FILENAME_RE.test(name)); + assert.deepEqual( + invalid, + [], + `Invalid migration filename(s): ${invalid.join(', ')}. Expected: YYYYMMDDHHMMSS_description.sql` + ); +}); + +test('migration version prefixes are unique', async () => { + const files = await getSqlMigrationFiles(); + const seen = new Map(); + const duplicates = []; + + for (const name of files) { + const match = name.match(MIGRATION_FILENAME_RE); + if (!match) continue; + const version = match[1]; + const current = seen.get(version); + if (current) { + duplicates.push(`${version}: ${current}, ${name}`); + continue; + } + seen.set(version, name); + } + + assert.deepEqual( + duplicates, + [], + `Duplicate migration version prefix(es) found: ${duplicates.join(' | ')}` + ); +}); diff --git a/scripts/__tests__/setup-manifest-github-sync.test.mjs b/scripts/__tests__/setup-manifest-github-sync.test.mjs index c02adbac..f9f586ee 100644 --- a/scripts/__tests__/setup-manifest-github-sync.test.mjs +++ b/scripts/__tests__/setup-manifest-github-sync.test.mjs @@ -56,15 +56,22 @@ test('collectGithubSecretPayload picks up values supplied only via merged disk-s STAGING_SUPABASE_ANON_KEY: 'anon', STAGING_SUPABASE_PROJECT_REF: 'ref', STAGING_SUPABASE_DB_PASSWORD: 'pw', + STAGING_STRIPE_SECRET_KEY: 'sk_test_staging', + STAGING_STRIPE_WEBHOOK_SECRET: 'whsec_staging', + STAGING_BILLING_ALLOWED_ORIGINS: 'https://staging.app.example,https://app.example', PRODUCTION_SUPABASE_URL: 'https://prd.supabase.co', PRODUCTION_SUPABASE_ANON_KEY: 'anon2', PRODUCTION_SUPABASE_PROJECT_REF: 'ref2', PRODUCTION_SUPABASE_DB_PASSWORD: 'pw2', + PRODUCTION_STRIPE_SECRET_KEY: 'sk_live_production', + PRODUCTION_STRIPE_WEBHOOK_SECRET: 'whsec_production', PREVIEW_SUPABASE_URL: 'https://pv.supabase.co', PREVIEW_SUPABASE_ANON_KEY: 'anonp', SUPABASE_PREVIEW_PROJECT_REF: 'pref', SUPABASE_PREVIEW_DB_PASSWORD: 'pwp', SUPABASE_PREVIEW_DB_URL: 'postgresql://x', + PREVIEW_STRIPE_SECRET_KEY: 'sk_test_preview', + PREVIEW_STRIPE_WEBHOOK_SECRET: 'whsec_preview', PR_PREVIEW_CERTIFICATE_ARN: 'arn:aws:acm:…', EXPO_PROJECT_ID: 'uuid', }, @@ -74,6 +81,10 @@ test('collectGithubSecretPayload picks up values supplied only via merged disk-s assert.equal(payload.AWS_ACCESS_KEY_ID, 'AKIA'); assert.equal(payload.SUPABASE_ACCESS_TOKEN, 'sbp_pat'); assert.ok(payload.STAGING_SUPABASE_URL); + assert.equal( + payload.STAGING_BILLING_ALLOWED_ORIGINS, + 'https://staging.app.example,https://app.example' + ); assert.ok(payload.PR_PREVIEW_CERTIFICATE_ARN); }); @@ -96,15 +107,21 @@ test('listMissingRequiredGithubForCi: satisfied required entries are omitted', ( STAGING_SUPABASE_ANON_KEY: 'k1', STAGING_SUPABASE_PROJECT_REF: 'r1', STAGING_SUPABASE_DB_PASSWORD: 'p1', + STAGING_STRIPE_SECRET_KEY: 'sk_test_staging', + STAGING_STRIPE_WEBHOOK_SECRET: 'whsec_staging', PRODUCTION_SUPABASE_URL: 'u2', PRODUCTION_SUPABASE_ANON_KEY: 'k2', PRODUCTION_SUPABASE_PROJECT_REF: 'r2', PRODUCTION_SUPABASE_DB_PASSWORD: 'p2', + PRODUCTION_STRIPE_SECRET_KEY: 'sk_live_production', + PRODUCTION_STRIPE_WEBHOOK_SECRET: 'whsec_production', PREVIEW_SUPABASE_URL: 'u3', PREVIEW_SUPABASE_ANON_KEY: 'k3', SUPABASE_PREVIEW_PROJECT_REF: 'r3', SUPABASE_PREVIEW_DB_PASSWORD: 'p3', SUPABASE_PREVIEW_DB_URL: 'postgresql://x', + PREVIEW_STRIPE_SECRET_KEY: 'sk_test_preview', + PREVIEW_STRIPE_WEBHOOK_SECRET: 'whsec_preview', PR_PREVIEW_CERTIFICATE_ARN: 'arn', EXPO_TOKEN: 'et', EXPO_PROJECT_ID: 'ep', @@ -133,15 +150,21 @@ test('listMissingRequiredGithubForCi: optional google block not required', () => STAGING_SUPABASE_ANON_KEY: 'k1', STAGING_SUPABASE_PROJECT_REF: 'r1', STAGING_SUPABASE_DB_PASSWORD: 'p1', + STAGING_STRIPE_SECRET_KEY: 'sk_test_staging', + STAGING_STRIPE_WEBHOOK_SECRET: 'whsec_staging', PRODUCTION_SUPABASE_URL: 'u2', PRODUCTION_SUPABASE_ANON_KEY: 'k2', PRODUCTION_SUPABASE_PROJECT_REF: 'r2', PRODUCTION_SUPABASE_DB_PASSWORD: 'p2', + PRODUCTION_STRIPE_SECRET_KEY: 'sk_live_production', + PRODUCTION_STRIPE_WEBHOOK_SECRET: 'whsec_production', PREVIEW_SUPABASE_URL: 'u3', PREVIEW_SUPABASE_ANON_KEY: 'k3', SUPABASE_PREVIEW_PROJECT_REF: 'r3', SUPABASE_PREVIEW_DB_PASSWORD: 'p3', SUPABASE_PREVIEW_DB_URL: 'postgresql://x', + PREVIEW_STRIPE_SECRET_KEY: 'sk_test_preview', + PREVIEW_STRIPE_WEBHOOK_SECRET: 'whsec_preview', PR_PREVIEW_CERTIFICATE_ARN: 'arn', EXPO_TOKEN: 'et', EXPO_PROJECT_ID: 'ep', diff --git a/scripts/apply-billing-plans-from-config.ts b/scripts/apply-billing-plans-from-config.ts new file mode 100644 index 00000000..ff05b3bd --- /dev/null +++ b/scripts/apply-billing-plans-from-config.ts @@ -0,0 +1,125 @@ +/* eslint-disable no-console -- CLI */ +/** + * Push plan catalog from app billing config → public.billing_plans (features, usage_limits, + * display metadata). Does not touch Stripe price columns. + * + * SUPABASE_URL=... SUPABASE_SERVICE_ROLE_KEY=... npm run billing:apply-plans + * npm run billing:apply-plans -- --dry-run + */ +import { createClient } from '@supabase/supabase-js'; +import { pathToFileURL } from 'node:url'; +import path from 'node:path'; +import process from 'node:process'; +import type { ProductBillingConfig } from '@beakerstack/billing'; + +function parseArgs(argv: string[]) { + let dryRun = false; + for (let i = 2; i < argv.length; i++) { + if (argv[i] === '--dry-run') dryRun = true; + } + return { dryRun }; +} + +async function loadBillingConfig(spec: string): Promise { + const resolved = path.resolve(process.cwd(), spec); + const href = pathToFileURL(resolved).href; + const mod = await import(href); + const c = + (mod as { beakerstackBillingConfig?: ProductBillingConfig }) + .beakerstackBillingConfig ?? + (mod as { default?: ProductBillingConfig }).default; + if (!c?.productId || !Array.isArray(c.plans)) { + throw new Error( + `Module ${spec} must export beakerstackBillingConfig (or default) with productId and plans[]` + ); + } + return c; +} + +async function main() { + const { dryRun } = parseArgs(process.argv); + const url = process.env.SUPABASE_URL; + const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY; + + const configPath = + process.env.BILLING_PLAN_CONFIG_MODULE ?? + 'apps/web/src/billing/beakerstackBillingConfig.ts'; + const config = await loadBillingConfig(configPath); + const productId = config.productId; + + if (!dryRun && (!url || !serviceKey)) { + console.error( + 'Missing SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY (service role required unless --dry-run)' + ); + process.exit(1); + } + + const supabase = url && serviceKey ? createClient(url, serviceKey) : null; + + for (const plan of config.plans) { + const payload = { + display_name: plan.displayName, + description: plan.description ?? null, + price_cents: plan.priceCents, + billing_period: plan.billingPeriod, + features: plan.features as Record, + usage_limits: plan.usageLimits as Record, + trial_period_days: plan.trialPeriodDays ?? 0, + is_public: plan.isPublic ?? true, + display_order: plan.displayOrder ?? 0, + }; + + if (dryRun) { + console.log(`[dry-run] ${plan.id}:`, JSON.stringify(payload, null, 2)); + continue; + } + + if (!supabase) { + console.error('Internal error: supabase client missing'); + process.exit(1); + } + + const { data: existing, error: selErr } = await supabase + .from('billing_plans') + .select('id') + .eq('id', plan.id) + .eq('product_id', productId) + .maybeSingle(); + + if (selErr) { + console.error(`Select failed for ${plan.id}:`, selErr.message); + process.exit(1); + } + if (!existing) { + console.error( + `No billing_plans row for id=${plan.id} product_id=${productId}. Create plans via migrations/seed before applying.` + ); + process.exit(1); + } + + const { error: upErr } = await supabase + .from('billing_plans') + .update(payload) + .eq('id', plan.id) + .eq('product_id', productId); + + if (upErr) { + console.error(`Update failed for ${plan.id}:`, upErr.message); + process.exit(1); + } + console.log(`Updated billing_plans ${plan.id}`); + } + + if (dryRun) { + console.log('Dry run complete; no writes performed.'); + } else { + console.log( + `Applied ${config.plans.length} plan(s) for product ${productId}.` + ); + } +} + +main().catch(e => { + console.error(e); + process.exit(1); +}); diff --git a/scripts/ensure-stripe-webhook-endpoint.mjs b/scripts/ensure-stripe-webhook-endpoint.mjs new file mode 100644 index 00000000..4953609f --- /dev/null +++ b/scripts/ensure-stripe-webhook-endpoint.mjs @@ -0,0 +1,123 @@ +#!/usr/bin/env node +/** + * Ensures a Stripe webhook endpoint exists for the Supabase `stripe-webhook` URL and + * exposes the signing secret for CI (GITHUB_OUTPUT) or the next `supabase secrets set`. + * + * - If no endpoint matches STRIPE_WEBHOOK_URL, creates one with the events used by + * `supabase/functions/stripe-webhook/index.ts`. + * - If it already exists, updates `enabled_events` to match; the signing secret is only + * returned at create time, so STRIPE_WEBHOOK_SECRET must be set (e.g. GitHub secret + * from Dashboard → Reveal) on subsequent runs. + * + * Env: + * STRIPE_SECRET_KEY (required) + * STRIPE_WEBHOOK_URL (required) — full URL, e.g. https://.supabase.co/functions/v1/stripe-webhook + * STRIPE_WEBHOOK_SECRET (optional) — required when the endpoint already exists + * WEBHOOK_DESCRIPTION (optional) — Stripe Dashboard label + */ +import fs from 'node:fs'; +import process from 'node:process'; +import Stripe from 'stripe'; + +/** Keep in sync with `processStripeEvent` switch in stripe-webhook/index.ts */ +const ENABLED_EVENTS = [ + 'checkout.session.completed', + 'customer.subscription.updated', + 'customer.subscription.deleted', + 'customer.subscription.trial_will_end', + 'invoice.payment_failed', + 'invoice.paid', + 'invoice.payment_succeeded', + 'invoice.created', + 'invoice.finalized', + 'invoice.voided', +]; + +function emitResolvedSecret(secret) { + const outPath = process.env.GITHUB_OUTPUT; + if (outPath) { + fs.appendFileSync( + outPath, + `resolved_webhook_secret<<__WHSEC_EOF__\n${secret}\n__WHSEC_EOF__\n` + ); + } +} + +async function main() { + const sk = process.env.STRIPE_SECRET_KEY; + const url = process.env.STRIPE_WEBHOOK_URL?.replace(/\/+$/, ''); + if (!sk || !url) { + console.error( + 'Missing STRIPE_SECRET_KEY or STRIPE_WEBHOOK_URL (full …/functions/v1/stripe-webhook URL)' + ); + process.exit(1); + } + + const stripe = new Stripe(sk, { apiVersion: '2023-10-16' }); + const description = + process.env.WEBHOOK_DESCRIPTION || 'BeakerStack CI (stripe-webhook)'; + + const list = await stripe.webhookEndpoints.list({ limit: 100 }); + const found = list.data.find((e) => e.url.replace(/\/+$/, '') === url); + + let signingSecret = process.env.STRIPE_WEBHOOK_SECRET?.trim() || ''; + + if (found) { + const sorted = [...ENABLED_EVENTS].sort(); + const current = [...(found.enabled_events || [])].sort(); + const needsUpdate = + sorted.length !== current.length || + sorted.some((ev, i) => ev !== current[i]); + + if (needsUpdate) { + await stripe.webhookEndpoints.update(found.id, { + enabled_events: ENABLED_EVENTS, + description, + }); + console.log( + `Updated Stripe webhook endpoint ${found.id} enabled_events (${url})` + ); + } else { + console.log(`Stripe webhook endpoint already configured (${url})`); + } + + if (!signingSecret) { + console.error( + [ + 'This Stripe account already has a webhook for this URL, but STRIPE_WEBHOOK_SECRET is not set.', + 'In Stripe Dashboard → Developers → Webhooks → select this endpoint → Reveal signing secret,', + 'then add it as the matching GitHub repository secret (e.g. PREVIEW_STRIPE_WEBHOOK_SECRET).', + ].join('\n') + ); + process.exit(1); + } + + emitResolvedSecret(signingSecret); + return; + } + + const created = await stripe.webhookEndpoints.create({ + url, + enabled_events: ENABLED_EVENTS, + description, + api_version: '2023-10-16', + }); + + signingSecret = created.secret; + if (!signingSecret) { + console.error('Stripe did not return a signing secret for the new webhook endpoint.'); + process.exit(1); + } + + console.log(`Created Stripe webhook endpoint ${created.id} (${url})`); + console.log( + 'Save this signing secret as your GitHub STRIPE_WEBHOOK_* secret for future CI runs (Dashboard can also reveal it).' + ); + + emitResolvedSecret(signingSecret); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/scripts/lib/setup-manifest.mjs b/scripts/lib/setup-manifest.mjs index eca331c5..1cae278f 100644 --- a/scripts/lib/setup-manifest.mjs +++ b/scripts/lib/setup-manifest.mjs @@ -57,6 +57,25 @@ export const GITHUB_SECRETS = [ envKeys: ['STAGING_SUPABASE_DB_PASSWORD'], group: 'staging', }, + { + type: 'secret', + name: 'STAGING_STRIPE_SECRET_KEY', + envKeys: ['STAGING_STRIPE_SECRET_KEY'], + group: 'staging', + }, + { + type: 'secret', + name: 'STAGING_STRIPE_WEBHOOK_SECRET', + envKeys: ['STAGING_STRIPE_WEBHOOK_SECRET'], + group: 'staging', + }, + { + type: 'secret', + name: 'STAGING_BILLING_ALLOWED_ORIGINS', + envKeys: ['STAGING_BILLING_ALLOWED_ORIGINS'], + optional: true, + group: 'staging', + }, { type: 'secret', name: 'PRODUCTION_SUPABASE_URL', @@ -81,6 +100,25 @@ export const GITHUB_SECRETS = [ envKeys: ['PRODUCTION_SUPABASE_DB_PASSWORD'], group: 'production', }, + { + type: 'secret', + name: 'PRODUCTION_STRIPE_SECRET_KEY', + envKeys: ['PRODUCTION_STRIPE_SECRET_KEY'], + group: 'production', + }, + { + type: 'secret', + name: 'PRODUCTION_STRIPE_WEBHOOK_SECRET', + envKeys: ['PRODUCTION_STRIPE_WEBHOOK_SECRET'], + group: 'production', + }, + { + type: 'secret', + name: 'PRODUCTION_BILLING_ALLOWED_ORIGINS', + envKeys: ['PRODUCTION_BILLING_ALLOWED_ORIGINS'], + optional: true, + group: 'production', + }, { type: 'secret', name: 'PREVIEW_SUPABASE_URL', @@ -111,6 +149,25 @@ export const GITHUB_SECRETS = [ envKeys: ['SUPABASE_PREVIEW_DB_URL'], group: 'preview', }, + { + type: 'secret', + name: 'PREVIEW_STRIPE_SECRET_KEY', + envKeys: ['PREVIEW_STRIPE_SECRET_KEY'], + group: 'preview', + }, + { + type: 'secret', + name: 'PREVIEW_STRIPE_WEBHOOK_SECRET', + envKeys: ['PREVIEW_STRIPE_WEBHOOK_SECRET'], + group: 'preview', + }, + { + type: 'secret', + name: 'PREVIEW_BILLING_ALLOWED_ORIGINS', + envKeys: ['PREVIEW_BILLING_ALLOWED_ORIGINS'], + optional: true, + group: 'preview', + }, { type: 'secret', name: 'PR_PREVIEW_CERTIFICATE_ARN', diff --git a/scripts/merge-coverage.js b/scripts/merge-coverage.js index 4556bbbd..1e1172c4 100755 --- a/scripts/merge-coverage.js +++ b/scripts/merge-coverage.js @@ -5,6 +5,7 @@ * - apps/web/coverage (Vitest) * - apps/mobile/coverage (Jest) * - packages/shared-tests/coverage (Jest) + * - packages/billing/coverage (Vitest) */ const fs = require('fs'); @@ -14,6 +15,7 @@ const coverageDirs = [ { name: 'web', path: 'apps/web/coverage' }, { name: 'mobile', path: 'apps/mobile/coverage' }, { name: 'shared', path: 'packages/shared-tests/coverage' }, + { name: 'billing', path: 'packages/billing/coverage' }, ]; const outputDir = path.join(__dirname, '..', 'coverage'); diff --git a/scripts/sync-billing-stripe.mjs b/scripts/sync-billing-stripe.mjs new file mode 100644 index 00000000..87acd1b1 --- /dev/null +++ b/scripts/sync-billing-stripe.mjs @@ -0,0 +1,151 @@ +#!/usr/bin/env node +/** + * Idempotent Stripe product/price sync + updates public.billing_plans stripe price columns + * (stripe_price_id_monthly, stripe_price_id_annual). + * + * Usage: + * STRIPE_SECRET_KEY=sk_test_... SUPABASE_URL=... SUPABASE_SERVICE_ROLE_KEY=... \ + * node scripts/sync-billing-stripe.mjs --config apps/web/src/billing/billing-sync.json + * + * Free plans are skipped (no Stripe price). Each paid plan should list `prices` with + * one `interval: "month"` and one `interval: "year"`. + */ +import { createClient } from '@supabase/supabase-js'; +import fs from 'node:fs'; +import path from 'node:path'; +import process from 'node:process'; +import Stripe from 'stripe'; + +function parseArgs(argv) { + const out = { config: null }; + for (let i = 2; i < argv.length; i++) { + if (argv[i] === '--config' && argv[i + 1]) { + out.config = argv[++i]; + } + } + return out; +} + +async function ensurePrice(stripe, stripeProductId, productId, planId, def) { + const { unitAmount, currency, interval, lookupKey } = def; + const prices = await stripe.prices.list({ + product: stripeProductId, + active: true, + limit: 50, + }); + const key = lookupKey || `${planId}_${interval}`; + let price = prices.data.find( + (x) => x.lookup_key === key || x.metadata?.billing_plan_cadence === key + ); + if (!price) { + price = await stripe.prices.create({ + product: stripeProductId, + unit_amount: unitAmount, + currency: currency || 'usd', + recurring: { interval: interval || 'month' }, + lookup_key: key, + metadata: { + billing_plan_id: planId, + billing_product_id: productId, + billing_plan_cadence: key, + }, + }); + } + return price.id; +} + +async function main() { + const { config: configPath } = parseArgs(process.argv); + const sk = process.env.STRIPE_SECRET_KEY; + const url = process.env.SUPABASE_URL; + const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY; + if (!sk || !url || !serviceKey) { + console.error('Missing STRIPE_SECRET_KEY, SUPABASE_URL, or SUPABASE_SERVICE_ROLE_KEY'); + process.exit(1); + } + if (!configPath) { + console.error('Usage: node scripts/sync-billing-stripe.mjs --config '); + process.exit(1); + } + + const abs = path.resolve(process.cwd(), configPath); + const raw = JSON.parse(fs.readFileSync(abs, 'utf8')); + const { productId, stripeProductName, plans } = raw; + if (!productId || !Array.isArray(plans)) { + console.error('Invalid config: need productId and plans[]'); + process.exit(1); + } + + const stripe = new Stripe(sk, { apiVersion: '2023-10-16' }); + const supabase = createClient(url, serviceKey); + + let stripeProductId; + const existing = await stripe.products.list({ active: true, limit: 100 }); + const found = existing.data.find((p) => p.metadata?.billing_product_id === productId); + if (found) { + stripeProductId = found.id; + } else { + const p = await stripe.products.create({ + name: stripeProductName || productId, + metadata: { billing_product_id: productId }, + }); + stripeProductId = p.id; + } + + for (const plan of plans) { + const { planId, unitAmount, currency, interval, prices: priceList } = plan; + if (!planId) continue; + + /** @type {Array<{ unitAmount: number, currency?: string, interval: string, lookupKey?: string }>} */ + let prices = priceList; + if (!prices && unitAmount != null) { + prices = [ + { unitAmount, currency: currency || 'usd', interval: interval || 'month' }, + ]; + } + if (!prices || prices.length === 0) { + continue; + } + + let monthlyId = null; + let annualId = null; + + for (const pdef of prices) { + if (!pdef || pdef.unitAmount == null) continue; + const id = await ensurePrice( + stripe, + stripeProductId, + productId, + planId, + pdef + ); + if (pdef.interval === 'year') { + annualId = id; + } else { + monthlyId = id; + } + console.log('Price', planId, pdef.interval, '→', id); + } + + const { error } = await supabase + .from('billing_plans') + .update({ + stripe_price_id_monthly: monthlyId, + stripe_price_id_annual: annualId, + stripe_product_id: stripeProductId, + updated_at: new Date().toISOString(), + }) + .eq('id', planId) + .eq('product_id', productId); + if (error) { + console.error('Supabase update failed', planId, error); + process.exit(1); + } + console.log('Updated plan', planId, { monthlyId, annualId }); + } +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/supabase/config.toml b/supabase/config.toml index df808001..5f097ca4 100644 --- a/supabase/config.toml +++ b/supabase/config.toml @@ -355,6 +355,12 @@ deno_version = 2 # [edge_runtime.secrets] # secret_key = "env(SECRET_VALUE)" +[functions.stripe-webhook] +verify_jwt = false + +[functions.billing-stripe] +verify_jwt = true + [analytics] enabled = true port = 55327 diff --git a/supabase/functions/README.md b/supabase/functions/README.md new file mode 100644 index 00000000..8d19c056 --- /dev/null +++ b/supabase/functions/README.md @@ -0,0 +1,53 @@ +# Supabase Edge Functions (billing) + +## Functions + +| Name | JWT | Purpose | +| ---------------- | ----------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | +| `stripe-webhook` | **Disabled** (`verify_jwt = false`) | Stripe webhook endpoint; verifies `Stripe-Signature`; writes `billing_webhook_events` and updates `billing_subscriptions`. | +| `billing-stripe` | **Required** | Authenticated checkout session, customer portal, cancel/downgrade helpers. | + +## Secrets (set per Supabase project) + +Configure in **Supabase Dashboard → Project Settings → Edge Functions → Secrets** (or `supabase secrets set` when linked): + +- `STRIPE_SECRET_KEY` — Stripe secret API key (`sk_test_…` / `sk_live_…`). +- `STRIPE_WEBHOOK_SECRET` — Signing secret from Stripe Dashboard (or from `stripe listen` for local dev). +- `SUPABASE_URL` / `SUPABASE_ANON_KEY` / `SUPABASE_SERVICE_ROLE_KEY` — Injected on hosted Edge when allowed; **`supabase secrets set` rejects names starting with `SUPABASE_`**, so CI sets `BILLING_SUPABASE_URL`, `BILLING_SUPABASE_ANON_KEY`, and `BILLING_SUPABASE_SERVICE_ROLE_KEY` instead (functions accept either for URL/anon/service role where implemented). +- For local `.env.local`, `SUPABASE_SERVICE_ROLE_KEY` (and optional `SUPABASE_URL`) remain the usual names. +- `BILLING_ALLOWED_ORIGINS` — Comma-separated allowed **origins** for `successUrl`, `cancelUrl`, and Customer Portal `returnUrl` (e.g. `https://app.example.com,https://staging.example.com`). Must include the web app origin (scheme + host + port) used for billing redirect links. Custom URL schemes used for native deep links are allowed if listed as a full origin (e.g. `myapp://`). For a **hosted** project, this is **required** so CORS and redirect allowlists are not empty. **Local dev:** Edge Functions see `SUPABASE_URL` as `http://kong:8000` (Docker), not `http://127.0.0.1:54321` — `billing-origins.ts` still merges common dev origins (`http://localhost:5173`, etc.) when the stack is local. Add a LAN IP origin (e.g. `http://192.168.0.12:8081`) when testing on a physical device from Expo. +- **Optional:** `BILLING_WEBHOOK_TARGET` — Overrides the deploy label used in Checkout metadata (`billing_deploy_target`) and enforced by `stripe-webhook`. If unset, the value is derived from `SUPABASE_URL` / `BILLING_SUPABASE_URL`: the subdomain of `*.supabase.co` (project ref), or `local` for non-hosted URLs. Set the same override on **both** `billing-stripe` and `stripe-webhook` for a given project if the Supabase URL is not a standard `*.supabase.co` host. + +`billing-stripe` uses the **caller’s JWT** (anon + Authorization) for `auth.getUser()` and the **service role** for trusted DB reads/writes after auth. + +### Live Stripe keys + +With `sk_live_…`, `success` / `cancel` / `return` URLs must not use plain `http:` (only `https:` or non-`http` schemes that you allowlisted). + +## Local + +```bash +supabase start +supabase functions serve --no-verify-jwt --env-file supabase/.env.local +``` + +Example `supabase/.env.local` (do not commit): + +``` +STRIPE_SECRET_KEY=sk_test_... +STRIPE_WEBHOOK_SECRET=whsec_... +SUPABASE_SERVICE_ROLE_KEY=... +# Optional when using hosted Supabase in .env.local; omitted for pure local stack +# BILLING_ALLOWED_ORIGINS=https://my-app.example +``` + +Serve `stripe-webhook` on the URL Stripe CLI forwards to (see `apps/web/docs/billing-testing.md`). + +## CI + +`deploy-staging.yml`, `deploy-production.yml`, and `pr-preview-environment.yml` run **`npm run billing:sync-stripe`** after database migrations (preview: after the preview DB prepare step) so `billing_plans` has `stripe_price_id_*` for checkout, then **`npm run stripe:ensure-webhook`** ([`scripts/ensure-stripe-webhook-endpoint.mjs`](../../scripts/ensure-stripe-webhook-endpoint.mjs)) so Stripe has a webhook endpoint for `…/functions/v1/stripe-webhook`, then deploy `stripe-webhook` and `billing-stripe`. + +- Staging/production workflows set billing secrets from environment-scoped GitHub secrets before deploy (`STAGING_BILLING_ALLOWED_ORIGINS`, `PRODUCTION_BILLING_ALLOWED_ORIGINS`). +- PR preview workflow sets preview billing secrets on the shared preview Supabase project before deploy (`PREVIEW_BILLING_ALLOWED_ORIGINS`). + +Ensure the corresponding Stripe and Supabase service-role secrets are configured in GitHub Actions. diff --git a/supabase/functions/_shared/billing-deploy-target.ts b/supabase/functions/_shared/billing-deploy-target.ts new file mode 100644 index 00000000..70dac186 --- /dev/null +++ b/supabase/functions/_shared/billing-deploy-target.ts @@ -0,0 +1,22 @@ +/** + * Stable per-deployment label for Stripe Checkout metadata and webhook filtering. + * Hosted Supabase: project ref from `*.supabase.co` hostname. Local Docker / 127.0.0.1: `local`. + * Override with `BILLING_WEBHOOK_TARGET` when the URL is not a standard supabase.co host. + */ +export function getBillingDeployTarget(): string { + const override = Deno.env.get('BILLING_WEBHOOK_TARGET')?.trim(); + if (override) return override; + + const raw = + Deno.env.get('SUPABASE_URL') ?? Deno.env.get('BILLING_SUPABASE_URL') ?? ''; + if (!raw) return 'local'; + + try { + const host = new URL(raw).hostname; + const m = /^([^.]+)\.supabase\.co$/i.exec(host); + if (m) return m[1]; + } catch { + // invalid URL — fall through to local + } + return 'local'; +} diff --git a/supabase/functions/_shared/billing-origins.ts b/supabase/functions/_shared/billing-origins.ts new file mode 100644 index 00000000..a31ab9c7 --- /dev/null +++ b/supabase/functions/_shared/billing-origins.ts @@ -0,0 +1,108 @@ +/** Origins commonly used when SUPABASE_URL points at local `supabase start`. */ +const LOCAL_DEV_ORIGINS = [ + 'http://localhost:5173', + 'http://127.0.0.1:5173', + 'http://[::1]:5173', + 'http://localhost:8081', + 'http://127.0.0.1:8081', + 'http://[::1]:8081', + 'http://localhost:3000', + 'http://127.0.0.1:3000', + 'http://[::1]:3000', +] as const; + +function isLocalSupabaseApiHostname(hostname: string): boolean { + return ( + hostname === '127.0.0.1' || + hostname === 'localhost' || + hostname === '[::1]' || + hostname === '::1' + ); +} + +function isLocalSupabaseApiUrl(url: string | undefined): boolean { + if (!url) return false; + try { + const u = new URL(url); + return isLocalSupabaseApiHostname(u.hostname); + } catch { + return false; + } +} + +/** + * Edge Functions run in Docker for `supabase start`; `SUPABASE_URL` is often an internal + * hostname (e.g. `kong`) not `127.0.0.1`, so {@link isLocalSupabaseApiUrl} alone would skip + * merging {@link LOCAL_DEV_ORIGINS} and every portal/checkout redirect would fail with + * `invalid_redirect_url` even for `http://localhost:5173`. + */ +function isLocalSupabaseStack(supabaseUrl: string | undefined): boolean { + if (isLocalSupabaseApiUrl(supabaseUrl)) return true; + if (!supabaseUrl) return false; + try { + const u = new URL(supabaseUrl); + // Default local API port from supabase/config.toml [api].port (not used by hosted projects). + if (u.port === '54321') return true; + // Typical internal API gateway hostname in local Docker Compose. + if (u.hostname === 'kong') return true; + } catch { + return false; + } + return false; +} + +function mergeEnvOrigins(set: Set): void { + const raw = Deno.env.get('BILLING_ALLOWED_ORIGINS'); + if (!raw) return; + for (const part of raw.split(',')) { + const t = part.trim(); + if (!t) continue; + try { + const u = new URL(t); + if (u.protocol === 'http:' || u.protocol === 'https:') { + set.add(u.origin); + } else { + set.add(t); + } + } catch { + set.add(t); + } + } +} + +export function getBillingAllowedOrigins(): Set { + const set = new Set(); + mergeEnvOrigins(set); + if (isLocalSupabaseStack(Deno.env.get('SUPABASE_URL'))) { + for (const o of LOCAL_DEV_ORIGINS) set.add(o); + } + return set; +} + +/** Validates Stripe checkout / portal redirect URLs against the billing origin allowlist. */ +export function assertRedirectUrlAllowed(urlString: string): void { + let url: URL; + try { + url = new URL(urlString); + } catch { + throw new RedirectValidationError(); + } + const origin = url.origin; + const allowed = getBillingAllowedOrigins(); + if (!allowed.has(origin)) { + throw new RedirectValidationError(); + } + const isLive = (Deno.env.get('STRIPE_SECRET_KEY') ?? '').startsWith( + 'sk_live_' + ); + if (isLive && url.protocol === 'http:') { + throw new RedirectValidationError(); + } +} + +export class RedirectValidationError extends Error { + constructor() { + super('invalid_redirect_url'); + this.name = 'RedirectValidationError'; + } +} diff --git a/supabase/functions/_shared/cors.ts b/supabase/functions/_shared/cors.ts new file mode 100644 index 00000000..1a52467b --- /dev/null +++ b/supabase/functions/_shared/cors.ts @@ -0,0 +1,61 @@ +import { getBillingAllowedOrigins } from './billing-origins.ts'; + +export const corsAllowHeaders = + 'authorization, x-client-info, apikey, content-type, x-supabase-client'; + +export function corsHeadersForRequest(req: Request): HeadersInit { + const origin = req.headers.get('Origin'); + const allowed = getBillingAllowedOrigins(); + + if (!origin) { + return { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': corsAllowHeaders, + }; + } + + if (allowed.has(origin)) { + return { + 'Access-Control-Allow-Origin': origin, + 'Access-Control-Allow-Headers': corsAllowHeaders, + Vary: 'Origin', + }; + } + + return { + 'Access-Control-Allow-Headers': corsAllowHeaders, + Vary: 'Origin', + }; +} + +function mergeIntoHeaders(init: HeadersInit): Headers { + const h = new Headers(); + if (init instanceof Headers) { + init.forEach((v, k) => h.set(k, v)); + return h; + } + if (Array.isArray(init)) { + for (const [k, v] of init) h.set(k, v); + return h; + } + for (const [k, v] of Object.entries(init)) { + h.set(k, String(v)); + } + return h; +} + +export function jsonResponse( + body: unknown, + status = 200, + req?: Request +): Response { + const base: HeadersInit = req + ? corsHeadersForRequest(req) + : { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': corsAllowHeaders, + }; + const headers = mergeIntoHeaders(base); + headers.set('Content-Type', 'application/json'); + return new Response(JSON.stringify(body), { status, headers }); +} diff --git a/supabase/functions/billing-stripe/index.ts b/supabase/functions/billing-stripe/index.ts new file mode 100644 index 00000000..ed6d317a --- /dev/null +++ b/supabase/functions/billing-stripe/index.ts @@ -0,0 +1,439 @@ +import Stripe from 'npm:stripe@14.21.0'; +import { createClient } from 'npm:@supabase/supabase-js@2.45.0'; +import { + assertRedirectUrlAllowed, + RedirectValidationError, +} from '../_shared/billing-origins.ts'; +import { getBillingDeployTarget } from '../_shared/billing-deploy-target.ts'; +import { corsHeadersForRequest, jsonResponse } from '../_shared/cors.ts'; + +/** REQ-031: module reads publishable key from env (client UIs use it; Edge does not call Stripe with it). */ +void Deno.env.get('STRIPE_PUBLISHABLE_KEY'); + +const stripe = new Stripe(Deno.env.get('STRIPE_SECRET_KEY') ?? '', { + apiVersion: '2023-10-16', + httpClient: Stripe.createFetchHttpClient(), +}); + +type Body = { + action: + | 'checkout' + | 'portal' + | 'schedule_cancel_to_free' + | 'cancel_immediately' + | 'update_subscription' + | 'resume_subscription'; + productId?: string; + planId?: string; + /** Which Stripe recurring price to use; default monthly. */ + cadence?: 'monthly' | 'annual'; + successUrl?: string; + cancelUrl?: string; + returnUrl?: string; + /** Optional; capped to plan's `trial_period_days` (REQ-015 / QA). */ + trialDays?: number; +}; + +Deno.serve(async req => { + if (req.method === 'OPTIONS') { + return new Response('ok', { headers: corsHeadersForRequest(req) }); + } + + const supabaseUrl = + Deno.env.get('SUPABASE_URL') ?? Deno.env.get('BILLING_SUPABASE_URL'); + /** Hosted Edge injects `SUPABASE_ANON_KEY`; CI cannot set `SUPABASE_*` via CLI, so workflows set `BILLING_SUPABASE_ANON_KEY`. */ + const anonKey = + Deno.env.get('SUPABASE_ANON_KEY') ?? + Deno.env.get('BILLING_SUPABASE_ANON_KEY'); + const serviceKey = + Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? + Deno.env.get('BILLING_SUPABASE_SERVICE_ROLE_KEY'); + + if (!supabaseUrl || !anonKey || !serviceKey) { + return jsonResponse({ error: 'server_misconfigured' }, 500, req); + } + + const authHeader = req.headers.get('Authorization') ?? ''; + const authClient = createClient(supabaseUrl, anonKey, { + global: { headers: { Authorization: authHeader } }, + }); + const { + data: { user }, + error: userErr, + } = await authClient.auth.getUser(); + if (userErr || !user) { + return jsonResponse({ error: 'unauthenticated' }, 401, req); + } + + const admin = createClient(supabaseUrl, serviceKey); + let body: Body; + try { + body = (await req.json()) as Body; + } catch { + return jsonResponse({ error: 'invalid_json' }, 400, req); + } + + try { + switch (body.action) { + case 'checkout': + return await handleCheckout( + admin, + user.id, + user.email ?? '', + body, + req + ); + case 'portal': + return await handlePortal(admin, user.id, body, req); + case 'schedule_cancel_to_free': + return await handleScheduleCancelToFree(admin, user.id, body, req); + case 'cancel_immediately': + return await handleCancel(admin, user.id, body, req); + case 'resume_subscription': + return await handleResumeSubscription(admin, user.id, body, req); + case 'update_subscription': + return await handleUpdateSubscription(admin, user.id, body, req); + default: + return jsonResponse({ error: 'unknown_action' }, 400, req); + } + } catch (e) { + if (e instanceof RedirectValidationError) { + return jsonResponse( + { + error: 'invalid_redirect_url', + hint: 'Add the origin of successUrl/cancelUrl/returnUrl to BILLING_ALLOWED_ORIGINS (PR previews often need https://deploy.beakerstack.com). Trailing slashes in the secret are normalized for https URLs.', + }, + 400, + req + ); + } + console.error( + 'billing-stripe', + e instanceof Error ? (e.stack ?? e.message) : e + ); + return jsonResponse({ error: 'stripe_error' }, 400, req); + } +}); + +async function handleCheckout( + admin: ReturnType, + userId: string, + email: string, + body: Body, + req: Request +): Promise { + const { + productId, + planId, + successUrl, + cancelUrl, + cadence = 'monthly', + trialDays: trialDaysOverride, + } = body; + if (!productId || !planId || !successUrl || !cancelUrl) { + return jsonResponse({ error: 'missing_fields' }, 400, req); + } + assertRedirectUrlAllowed(successUrl); + assertRedirectUrlAllowed(cancelUrl); + + const { data: plan, error: planErr } = await admin + .from('billing_plans') + .select( + 'id, stripe_price_id_monthly, stripe_price_id_annual, price_cents, trial_period_days' + ) + .eq('id', planId) + .eq('product_id', productId) + .maybeSingle(); + const priceId = + cadence === 'annual' + ? plan?.stripe_price_id_annual + : plan?.stripe_price_id_monthly; + if (planErr || !priceId) { + return jsonResponse( + { + error: 'plan_not_checkout_ready', + hint: 'Run sync-billing-stripe to set stripe_price_id_monthly / _annual for paid plans', + }, + 400, + req + ); + } + + const planTrialDays = Math.max( + 0, + Math.floor(Number(plan?.trial_period_days ?? 0)) + ); + let effectiveTrialDays = planTrialDays; + if ( + typeof trialDaysOverride === 'number' && + !Number.isNaN(trialDaysOverride) + ) { + const capped = Math.min( + Math.max(0, Math.floor(trialDaysOverride)), + planTrialDays + ); + effectiveTrialDays = capped; + } + + const deployTarget = getBillingDeployTarget(); + + const subscriptionData: { + metadata: Record; + trial_period_days?: number; + } = { + metadata: { + supabase_user_id: userId, + product_id: productId, + plan_id: planId, + billing_deploy_target: deployTarget, + }, + }; + if (effectiveTrialDays > 0) { + subscriptionData.trial_period_days = effectiveTrialDays; + } + + const session = await stripe.checkout.sessions.create({ + mode: 'subscription', + customer_email: email, + line_items: [{ price: priceId, quantity: 1 }], + success_url: successUrl, + cancel_url: cancelUrl, + metadata: { + supabase_user_id: userId, + product_id: productId, + plan_id: planId, + billing_deploy_target: deployTarget, + }, + subscription_data: subscriptionData, + }); + + return jsonResponse( + { checkoutUrl: session.url, sessionId: session.id }, + 200, + req + ); +} + +async function handlePortal( + admin: ReturnType, + userId: string, + body: Body, + req: Request +): Promise { + const returnUrl = body.returnUrl; + if (!returnUrl) return jsonResponse({ error: 'missing_returnUrl' }, 400, req); + const productId = body.productId; + if (!productId) return jsonResponse({ error: 'missing_productId' }, 400, req); + + assertRedirectUrlAllowed(returnUrl); + + const { data: sub, error } = await admin + .from('billing_subscriptions') + .select('stripe_customer_id') + .eq('user_id', userId) + .eq('product_id', productId) + .maybeSingle(); + if (error || !sub?.stripe_customer_id) { + return jsonResponse({ error: 'no_stripe_customer' }, 400, req); + } + + const portal = await stripe.billingPortal.sessions.create({ + customer: sub.stripe_customer_id, + return_url: returnUrl, + }); + return jsonResponse({ url: portal.url }, 200, req); +} + +async function handleUpdateSubscription( + admin: ReturnType, + userId: string, + body: Body, + req: Request +): Promise { + const { productId, planId, cadence = 'monthly' } = body; + if (!productId || !planId) { + return jsonResponse({ error: 'missing_fields' }, 400, req); + } + const { data: sub, error: subErr } = await admin + .from('billing_subscriptions') + .select('stripe_subscription_id') + .eq('user_id', userId) + .eq('product_id', productId) + .maybeSingle(); + if (subErr || !sub?.stripe_subscription_id) { + return jsonResponse({ error: 'no_active_subscription' }, 400, req); + } + const { data: plan, error: planErr } = await admin + .from('billing_plans') + .select('stripe_price_id_monthly, stripe_price_id_annual, price_cents') + .eq('id', planId) + .eq('product_id', productId) + .maybeSingle(); + if (planErr || !plan) { + return jsonResponse({ error: 'invalid_plan' }, 400, req); + } + const newPriceId = + cadence === 'annual' + ? plan.stripe_price_id_annual + : plan.stripe_price_id_monthly; + if (!newPriceId) { + return jsonResponse({ error: 'plan_not_priced' }, 400, req); + } + const stripeSub = await stripe.subscriptions.retrieve( + sub.stripe_subscription_id + ); + const subItem = stripeSub.items.data[0]; + if (!subItem?.id) { + return jsonResponse({ error: 'no_subscription_item' }, 400, req); + } + if (subItem.price?.id === newPriceId) { + return jsonResponse({ ok: true, noOp: true }, 200, req); + } + await stripe.subscriptions.update(sub.stripe_subscription_id, { + items: [{ id: subItem.id, price: newPriceId }], + proration_behavior: 'create_prorations', + }); + return jsonResponse({ ok: true }, 200, req); +} + +async function resolveFreePlanIdForProduct( + admin: ReturnType, + productId: string +): Promise { + const { data } = await admin + .from('billing_plans') + .select('id') + .eq('product_id', productId) + .eq('price_cents', 0) + .order('display_order', { ascending: true }) + .limit(1) + .maybeSingle(); + return data?.id ?? null; +} + +async function handleScheduleCancelToFree( + admin: ReturnType, + userId: string, + body: Body, + req: Request +): Promise { + const productId = body.productId; + if (!productId) return jsonResponse({ error: 'missing_productId' }, 400, req); + + const { data: sub, error } = await admin + .from('billing_subscriptions') + .select('stripe_subscription_id') + .eq('user_id', userId) + .eq('product_id', productId) + .maybeSingle(); + if (error || !sub?.stripe_subscription_id) { + return jsonResponse({ error: 'no_active_subscription' }, 400, req); + } + + const freePlanId = await resolveFreePlanIdForProduct(admin, productId); + if (!freePlanId) { + return jsonResponse({ error: 'no_free_plan' }, 400, req); + } + + await stripe.subscriptions.update(sub.stripe_subscription_id, { + cancel_at_period_end: true, + }); + + const { error: upErr } = await admin + .from('billing_subscriptions') + .update({ + cancel_at_period_end: true, + pending_target_plan_id: freePlanId, + updated_at: new Date().toISOString(), + }) + .eq('user_id', userId) + .eq('product_id', productId); + if (upErr) { + console.error( + 'schedule_cancel_to_free: failed to set pending_target_plan_id', + upErr + ); + return jsonResponse({ error: 'db_update_failed' }, 500, req); + } + + return jsonResponse({ ok: true }, 200, req); +} + +async function handleResumeSubscription( + admin: ReturnType, + userId: string, + body: Body, + req: Request +): Promise { + const productId = body.productId; + if (!productId) return jsonResponse({ error: 'missing_productId' }, 400, req); + + const { data: sub, error } = await admin + .from('billing_subscriptions') + .select('stripe_subscription_id') + .eq('user_id', userId) + .eq('product_id', productId) + .maybeSingle(); + if (error || !sub?.stripe_subscription_id) { + return jsonResponse({ error: 'no_active_subscription' }, 400, req); + } + + await stripe.subscriptions.update(sub.stripe_subscription_id, { + cancel_at_period_end: false, + }); + + const { error: upErr } = await admin + .from('billing_subscriptions') + .update({ + cancel_at_period_end: false, + pending_target_plan_id: null, + updated_at: new Date().toISOString(), + }) + .eq('user_id', userId) + .eq('product_id', productId); + if (upErr) { + console.error('resume_subscription: failed to clear pending state', upErr); + return jsonResponse({ error: 'db_update_failed' }, 500, req); + } + + return jsonResponse({ ok: true }, 200, req); +} + +async function handleCancel( + admin: ReturnType, + userId: string, + body: Body, + req: Request +): Promise { + const productId = body.productId; + if (!productId) return jsonResponse({ error: 'missing_productId' }, 400, req); + + const { data: sub, error } = await admin + .from('billing_subscriptions') + .select('stripe_subscription_id') + .eq('user_id', userId) + .eq('product_id', productId) + .maybeSingle(); + if (error || !sub?.stripe_subscription_id) { + return jsonResponse({ error: 'no_active_subscription' }, 400, req); + } + + await stripe.subscriptions.cancel(sub.stripe_subscription_id); + + const { error: upErr } = await admin + .from('billing_subscriptions') + .update({ + pending_target_plan_id: null, + updated_at: new Date().toISOString(), + }) + .eq('user_id', userId) + .eq('product_id', productId); + if (upErr) { + console.error( + 'cancel_immediately: failed to clear pending_target_plan_id', + upErr + ); + } + + return jsonResponse({ ok: true }, 200, req); +} diff --git a/supabase/functions/deno-shim.d.ts b/supabase/functions/deno-shim.d.ts new file mode 100644 index 00000000..8be31918 --- /dev/null +++ b/supabase/functions/deno-shim.d.ts @@ -0,0 +1,18 @@ +/** + * Ambient typings for the Deno global in Supabase Edge Functions. + * The runtime provides the real Deno object; this file exists so the workspace TypeScript + * language service (Node-style projects) can typecheck supabase/functions sources without + * requiring the Deno VS Code extension for this folder. + */ +declare namespace Deno { + interface Env { + get(key: string): string | undefined; + } + + const env: Env; + + function serve(handler: (request: Request) => Response | Promise): { + shutdown(): Promise; + finished: Promise; + }; +} diff --git a/supabase/functions/stripe-webhook/index.ts b/supabase/functions/stripe-webhook/index.ts new file mode 100644 index 00000000..c49a5e0a --- /dev/null +++ b/supabase/functions/stripe-webhook/index.ts @@ -0,0 +1,502 @@ +import Stripe from 'npm:stripe@14.21.0'; +import { createClient } from 'npm:@supabase/supabase-js@2.45.0'; +import { corsHeadersForRequest, jsonResponse } from '../_shared/cors.ts'; +import { getBillingDeployTarget } from '../_shared/billing-deploy-target.ts'; + +type ProcessResult = + | { status: 'processed' } + | { status: 'ignored'; reason: string }; + +/** Postgrest errors are plain objects; throwing them logs as `[object Object]`. */ +function asErrorFromSupabase(error: { + message?: string; + code?: string; + details?: string; + hint?: string; +}): Error { + const parts = [ + error.code, + error.message ?? 'supabase_error', + error.details, + error.hint, + ].filter((p): p is string => Boolean(p && String(p).trim())); + return new Error(parts.join(' | ')); +} + +function formatCaught(e: unknown): string { + if (e instanceof Error) return e.message; + if ( + e && + typeof e === 'object' && + 'message' in e && + typeof (e as { message: unknown }).message === 'string' + ) { + const o = e as { + message: string; + code?: string; + details?: string; + hint?: string; + }; + return [o.code, o.message, o.details, o.hint].filter(Boolean).join(' | '); + } + try { + return JSON.stringify(e); + } catch { + return String(e); + } +} + +void Deno.env.get('STRIPE_PUBLISHABLE_KEY'); + +const stripe = new Stripe(Deno.env.get('STRIPE_SECRET_KEY') ?? '', { + apiVersion: '2023-10-16', + httpClient: Stripe.createFetchHttpClient(), +}); + +/** Stripe webhook payloads may omit or type-shift timestamps; never throw from Date. */ +function stripeUnixToIso(seconds: unknown): string | null { + if (seconds == null) return null; + const n = typeof seconds === 'number' ? seconds : Number(seconds); + if (!Number.isFinite(n) || n <= 0) return null; + const d = new Date(n * 1000); + return Number.isNaN(d.getTime()) ? null : d.toISOString(); +} + +function extractSubscriptionPeriod(stripeSub: Stripe.Subscription): { + periodStart: string | null; + periodEnd: string | null; +} { + const subAny = stripeSub as unknown as Record; + const items = + (subAny.items as { data?: Array> } | undefined) + ?.data ?? []; + const firstItem = items[0]; + + const periodStart = + stripeUnixToIso(subAny.current_period_start) ?? + stripeUnixToIso(firstItem?.current_period_start) ?? + stripeUnixToIso(subAny.start_date); + + const periodEnd = + stripeUnixToIso(subAny.current_period_end) ?? + stripeUnixToIso(firstItem?.current_period_end) ?? + stripeUnixToIso(subAny.cancel_at); + + return { periodStart, periodEnd }; +} + +function mapStripeStatus(status: Stripe.Subscription.Status): string { + switch (status) { + case 'trialing': + return 'trialing'; + case 'active': + return 'active'; + case 'past_due': + return 'past_due'; + case 'paused': + return 'paused'; + case 'canceled': + case 'unpaid': + return status; + case 'incomplete': + case 'incomplete_expired': + return status === 'incomplete_expired' ? 'canceled' : 'incomplete'; + default: + return 'active'; + } +} + +Deno.serve(async req => { + if (req.method === 'OPTIONS') { + return new Response('ok', { headers: corsHeadersForRequest(req) }); + } + + const webhookSecret = Deno.env.get('STRIPE_WEBHOOK_SECRET'); + const supabaseUrl = + Deno.env.get('SUPABASE_URL') ?? Deno.env.get('BILLING_SUPABASE_URL'); + const serviceKey = + Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? + Deno.env.get('BILLING_SUPABASE_SERVICE_ROLE_KEY'); + + if (!webhookSecret || !supabaseUrl || !serviceKey) { + console.error( + 'Missing STRIPE_WEBHOOK_SECRET and Supabase URL/service key (SUPABASE_* or BILLING_SUPABASE_*)' + ); + return jsonResponse({ error: 'server_misconfigured' }, 500, req); + } + + const supabase = createClient(supabaseUrl, serviceKey); + const signature = req.headers.get('stripe-signature'); + const body = await req.text(); + + let event: Stripe.Event; + try { + // Deno / Edge uses async SubtleCrypto; synchronous constructEvent throws. + event = await stripe.webhooks.constructEventAsync( + body, + signature ?? '', + webhookSecret + ); + } catch (err) { + console.error('Webhook signature verification failed', err); + return jsonResponse({ error: 'invalid_signature' }, 400, req); + } + + const { error: logErr } = await supabase + .from('billing_webhook_events') + .insert({ + stripe_event_id: event.id, + event_type: event.type, + payload: event as unknown as Record, + processed: false, + }); + + if (logErr) { + if (logErr.code === '23505') { + // If this event failed previously (processed=false), allow reprocessing on resend. + const { data: existing } = await supabase + .from('billing_webhook_events') + .select('processed') + .eq('stripe_event_id', event.id) + .maybeSingle(); + if (existing?.processed) { + return jsonResponse({ received: true, duplicate: true }, 200, req); + } + console.warn('Retrying previously failed webhook event', event.id); + } else { + console.error('Failed to log webhook', formatCaught(logErr)); + return jsonResponse({ error: 'log_failed' }, 500, req); + } + } + + try { + const result = await processStripeEvent(supabase, event); + if (result.status === 'ignored') { + await supabase + .from('billing_webhook_events') + .update({ + processed: true, + processed_at: new Date().toISOString(), + error: `ignored: ${result.reason}`, + }) + .eq('stripe_event_id', event.id); + return jsonResponse({ received: true, ignored: true }, 200, req); + } + await supabase + .from('billing_webhook_events') + .update({ + processed: true, + processed_at: new Date().toISOString(), + error: null, + }) + .eq('stripe_event_id', event.id); + } catch (e) { + const msg = formatCaught(e); + console.error('Webhook processing error', msg); + await supabase + .from('billing_webhook_events') + .update({ processed: false, error: msg }) + .eq('stripe_event_id', event.id); + return jsonResponse({ error: 'processing_failed' }, 500, req); + } + + return jsonResponse({ received: true }, 200, req); +}); + +async function processStripeEvent( + supabase: ReturnType, + event: Stripe.Event +): Promise { + switch (event.type) { + case 'checkout.session.completed': { + const session = event.data.object as Stripe.Checkout.Session; + const expectedTarget = getBillingDeployTarget(); + const metaTarget = session.metadata?.billing_deploy_target?.trim(); + if ( + metaTarget !== undefined && + metaTarget !== '' && + metaTarget !== expectedTarget + ) { + console.warn( + 'checkout.session.completed ignored: billing_deploy_target mismatch', + { sessionId: session.id, expected: expectedTarget, got: metaTarget } + ); + return { + status: 'ignored', + reason: 'billing_deploy_target_mismatch', + }; + } + const userId = session.metadata?.supabase_user_id; + const productId = session.metadata?.product_id; + const planId = session.metadata?.plan_id; + const subId = + typeof session.subscription === 'string' + ? session.subscription + : session.subscription?.id; + const customerId = + typeof session.customer === 'string' + ? session.customer + : session.customer?.id; + if (!userId || !productId || !planId || !subId) { + console.warn('checkout.session.completed missing metadata', session.id); + return { status: 'processed' }; + } + const stripeSub = await stripe.subscriptions.retrieve(subId); + const { periodStart, periodEnd } = extractSubscriptionPeriod(stripeSub); + const priceId = stripeSub.items.data[0]?.price?.id; + const resolvedPlanId = await resolvePlanId(supabase, planId, priceId); + const { error } = await supabase.from('billing_subscriptions').upsert( + { + user_id: userId, + product_id: productId, + plan_id: resolvedPlanId, + stripe_customer_id: customerId ?? null, + stripe_subscription_id: subId, + stripe_price_id: priceId ?? null, + status: mapStripeStatus(stripeSub.status), + current_period_start: periodStart, + current_period_end: periodEnd, + cancel_at_period_end: stripeSub.cancel_at_period_end, + pending_target_plan_id: null, + trial_start: stripeUnixToIso(stripeSub.trial_start), + trial_end: stripeUnixToIso(stripeSub.trial_end), + updated_at: new Date().toISOString(), + }, + { onConflict: 'user_id,product_id' } + ); + if (error) throw asErrorFromSupabase(error); + return { status: 'processed' }; + } + case 'customer.subscription.updated': + case 'customer.subscription.deleted': { + const stripeSub = event.data.object as Stripe.Subscription; + const priceId = stripeSub.items.data[0]?.price?.id; + const { data: row } = await supabase + .from('billing_subscriptions') + .select( + 'user_id, product_id, plan_id, current_period_start, current_period_end, pending_target_plan_id' + ) + .eq('stripe_subscription_id', stripeSub.id) + .maybeSingle(); + + if (!row) { + console.warn( + 'No local subscription for stripe subscription', + stripeSub.id + ); + return { status: 'processed' }; + } + + const resolvedPlanId = await resolvePlanId( + supabase, + row.plan_id, + priceId + ); + const { periodStart, periodEnd } = extractSubscriptionPeriod(stripeSub); + const mappedStatus = + event.type === 'customer.subscription.deleted' + ? 'canceled' + : mapStripeStatus(stripeSub.status); + const isCanceled = mappedStatus === 'canceled'; + const finalPlanId = isCanceled + ? await resolveFreePlanId(supabase, row.product_id, row.plan_id) + : resolvedPlanId; + const finalStatus = isCanceled ? 'free' : mappedStatus; + + const cancelAtEnd = isCanceled ? false : stripeSub.cancel_at_period_end; + const pendingTarget = + isCanceled || !cancelAtEnd + ? null + : ((row as { pending_target_plan_id?: string | null }) + .pending_target_plan_id ?? null); + + const { error } = await supabase + .from('billing_subscriptions') + .update({ + plan_id: finalPlanId, + status: finalStatus, + stripe_subscription_id: isCanceled ? null : stripeSub.id, + stripe_price_id: isCanceled ? null : (priceId ?? null), + current_period_start: periodStart ?? row.current_period_start, + current_period_end: periodEnd ?? row.current_period_end, + cancel_at_period_end: cancelAtEnd, + pending_target_plan_id: pendingTarget, + canceled_at: stripeUnixToIso(stripeSub.canceled_at), + trial_start: stripeUnixToIso(stripeSub.trial_start), + trial_end: stripeUnixToIso(stripeSub.trial_end), + updated_at: new Date().toISOString(), + }) + .eq('stripe_subscription_id', stripeSub.id); + if (error) throw asErrorFromSupabase(error); + return { status: 'processed' }; + } + case 'customer.subscription.trial_will_end': { + // Product apps handle email; optionally touch row for observability + return { status: 'processed' }; + } + case 'invoice.payment_failed': { + const invoice = event.data.object as Stripe.Invoice; + const subRef = invoice.subscription; + const subId = typeof subRef === 'string' ? subRef : subRef?.id; + if (subId) { + const { error } = await supabase + .from('billing_subscriptions') + .update({ status: 'past_due', updated_at: new Date().toISOString() }) + .eq('stripe_subscription_id', subId); + if (error) throw asErrorFromSupabase(error); + } + await syncInvoiceRow(supabase, invoice); + return { status: 'processed' }; + } + case 'invoice.paid': + case 'invoice.payment_succeeded': { + const invoice = event.data.object as Stripe.Invoice; + const subRef = invoice.subscription; + const subId = typeof subRef === 'string' ? subRef : subRef?.id; + if (subId) { + const { error } = await supabase + .from('billing_subscriptions') + .update({ status: 'active', updated_at: new Date().toISOString() }) + .eq('stripe_subscription_id', subId); + if (error) throw asErrorFromSupabase(error); + } + await syncInvoiceRow(supabase, invoice); + return { status: 'processed' }; + } + case 'invoice.created': + case 'invoice.finalized': + case 'invoice.voided': { + const inv = event.data.object as Stripe.Invoice; + await syncInvoiceRow(supabase, inv); + return { status: 'processed' }; + } + default: + return { status: 'processed' }; + } +} + +async function resolvePlanId( + supabase: ReturnType, + fallbackPlanId: string, + stripePriceId: string | undefined +): Promise { + if (!stripePriceId) return fallbackPlanId; + + const { data: monthly } = await supabase + .from('billing_plans') + .select('id') + .eq('stripe_price_id_monthly', stripePriceId) + .maybeSingle(); + if (monthly?.id) return monthly.id; + + const { data: annual } = await supabase + .from('billing_plans') + .select('id') + .eq('stripe_price_id_annual', stripePriceId) + .maybeSingle(); + return annual?.id ?? fallbackPlanId; +} + +function stripeCustomerIdString( + customer: string | Stripe.Customer | Stripe.DeletedCustomer | null +): string | null { + if (typeof customer === 'string') return customer; + if (customer && 'deleted' in customer && customer.deleted) return null; + if (customer && 'id' in customer) return customer.id; + return null; +} + +async function resolveUserIdByStripeCustomer( + supabase: ReturnType, + customerId: string +): Promise { + const { data } = await supabase + .from('billing_subscriptions') + .select('user_id') + .eq('stripe_customer_id', customerId) + .limit(1) + .maybeSingle(); + return data?.user_id ?? null; +} + +function firstLineDescription(invoice: Stripe.Invoice): string | null { + const first = invoice.lines?.data[0]; + if (!first) return invoice.description ?? null; + return first.description ?? invoice.description ?? null; +} + +async function syncInvoiceRow( + supabase: ReturnType, + invoice: Stripe.Invoice +): Promise { + const customerId = stripeCustomerIdString( + invoice.customer as Stripe.Invoice['customer'] + ); + if (!customerId) { + console.warn('Invoice missing customer', invoice.id); + return; + } + const userId = await resolveUserIdByStripeCustomer(supabase, customerId); + if (!userId) { + // Rare race: invoice before checkout links stripe_customer_id on our row. + // Spec: log and reconcile on a later event — do not fail the webhook (REQ-046). + console.warn( + 'syncInvoiceRow: no subscription row for customer yet; skipping invoice upsert', + { customerId, invoiceId: invoice.id } + ); + return; + } + + const subRef = invoice.subscription; + const subId = typeof subRef === 'string' ? subRef : (subRef?.id ?? null); + const inv = invoice as unknown as Record; + const st = (inv['status_transitions'] ?? {}) as Record; + + const paidAtSec = st['paid_at'] as number | null | undefined; + const finAtSec = st['finalized_at'] as number | null | undefined; + const createdSec = + (inv['created'] as number | undefined) ?? Math.floor(Date.now() / 1000); + + const row = { + user_id: userId, + stripe_invoice_id: invoice.id, + stripe_customer_id: customerId, + stripe_subscription_id: subId, + amount_due: invoice.amount_due, + amount_paid: invoice.amount_paid, + currency: (invoice.currency || 'usd').toLowerCase(), + status: invoice.status ?? 'draft', + description: firstLineDescription(invoice), + hosted_invoice_url: invoice.hosted_invoice_url ?? null, + invoice_pdf_url: + (invoice as { invoice_pdf?: string | null }).invoice_pdf ?? null, + period_start: invoice.period_start + ? stripeUnixToIso(invoice.period_start) + : null, + period_end: invoice.period_end ? stripeUnixToIso(invoice.period_end) : null, + created_at: stripeUnixToIso(createdSec) ?? new Date().toISOString(), + finalized_at: finAtSec && finAtSec > 0 ? stripeUnixToIso(finAtSec) : null, + paid_at: paidAtSec && paidAtSec > 0 ? stripeUnixToIso(paidAtSec) : null, + }; + + const { error } = await supabase.from('billing_invoices').upsert(row, { + onConflict: 'stripe_invoice_id', + }); + if (error) throw asErrorFromSupabase(error); +} + +async function resolveFreePlanId( + supabase: ReturnType, + productId: string, + fallbackPlanId: string +): Promise { + const { data } = await supabase + .from('billing_plans') + .select('id') + .eq('product_id', productId) + .eq('price_cents', 0) + .order('display_order', { ascending: true }) + .limit(1) + .maybeSingle(); + return data?.id ?? fallbackPlanId; +} diff --git a/supabase/functions/tsconfig.json b/supabase/functions/tsconfig.json new file mode 100644 index 00000000..51f0050a --- /dev/null +++ b/supabase/functions/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "noEmit": true, + "strict": true, + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "skipLibCheck": true, + "types": [], + "allowImportingTsExtensions": true, + "noUnusedLocals": false, + "noUnusedParameters": false + }, + "include": ["./deno-shim.d.ts", "./_shared/**/*.ts"] +} diff --git a/supabase/migrations/20250424120000_billing_v1.sql b/supabase/migrations/20250424120000_billing_v1.sql new file mode 100644 index 00000000..3f7f5070 --- /dev/null +++ b/supabase/migrations/20250424120000_billing_v1.sql @@ -0,0 +1,456 @@ +-- BeakerStack Billing v1: products, plans, subscriptions, usage, webhooks, RPCs + +-- --------------------------------------------------------------------------- +-- Tables +-- --------------------------------------------------------------------------- + +CREATE TABLE IF NOT EXISTS public.billing_products ( + id text PRIMARY KEY, + display_name text NOT NULL, + description text, + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS public.billing_plans ( + id text PRIMARY KEY, + product_id text NOT NULL REFERENCES public.billing_products (id) ON DELETE CASCADE, + display_name text NOT NULL, + description text, + price_cents integer NOT NULL DEFAULT 0, + billing_period text NOT NULL, + stripe_price_id text, + stripe_product_id text, + features jsonb NOT NULL DEFAULT '{}'::jsonb, + usage_limits jsonb NOT NULL DEFAULT '{}'::jsonb, + trial_period_days integer NOT NULL DEFAULT 0, + is_public boolean NOT NULL DEFAULT true, + display_order integer NOT NULL DEFAULT 0, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_billing_plans_product ON public.billing_plans (product_id); + +CREATE TABLE IF NOT EXISTS public.billing_subscriptions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES auth.users (id) ON DELETE CASCADE, + product_id text NOT NULL REFERENCES public.billing_products (id) ON DELETE CASCADE, + plan_id text NOT NULL REFERENCES public.billing_plans (id) ON DELETE RESTRICT, + stripe_customer_id text, + stripe_subscription_id text, + status text NOT NULL, + current_period_start timestamptz, + current_period_end timestamptz, + cancel_at_period_end boolean NOT NULL DEFAULT false, + canceled_at timestamptz, + trial_start timestamptz, + trial_end timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (user_id, product_id) +); + +CREATE INDEX IF NOT EXISTS idx_billing_subscriptions_user_product ON public.billing_subscriptions (user_id, product_id); + +CREATE TABLE IF NOT EXISTS public.billing_usage_events ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES auth.users (id) ON DELETE CASCADE, + product_id text NOT NULL REFERENCES public.billing_products (id) ON DELETE CASCADE, + event_type text NOT NULL, + quantity integer NOT NULL DEFAULT 1, + metadata jsonb NOT NULL DEFAULT '{}'::jsonb, + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_billing_usage_events_lookup ON public.billing_usage_events (user_id, product_id, event_type, created_at); + +CREATE TABLE IF NOT EXISTS public.billing_usage_aggregates ( + user_id uuid NOT NULL REFERENCES auth.users (id) ON DELETE CASCADE, + product_id text NOT NULL REFERENCES public.billing_products (id) ON DELETE CASCADE, + event_type text NOT NULL, + period_start timestamptz NOT NULL, + period_end timestamptz NOT NULL, + count integer NOT NULL DEFAULT 0, + PRIMARY KEY (user_id, product_id, event_type, period_start) +); + +CREATE INDEX IF NOT EXISTS idx_billing_usage_aggregates_lookup ON public.billing_usage_aggregates (user_id, product_id, event_type, period_start); + +CREATE TABLE IF NOT EXISTS public.billing_webhook_events ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + stripe_event_id text NOT NULL UNIQUE, + event_type text NOT NULL, + payload jsonb NOT NULL, + processed boolean NOT NULL DEFAULT false, + processed_at timestamptz, + error text, + created_at timestamptz NOT NULL DEFAULT now() +); + +-- Server-side demo flag (defense in depth with client env). Local seed may set true. +CREATE TABLE IF NOT EXISTS public.billing_system_flags ( + key text PRIMARY KEY, + value boolean NOT NULL DEFAULT false +); + +INSERT INTO public.billing_system_flags (key, value) +VALUES ('demo_billing_mode', false) +ON CONFLICT (key) DO NOTHING; + +CREATE TRIGGER billing_plans_updated_at + BEFORE UPDATE ON public.billing_plans + FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column(); + +CREATE TRIGGER billing_subscriptions_updated_at + BEFORE UPDATE ON public.billing_subscriptions + FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column(); + +-- --------------------------------------------------------------------------- +-- RLS +-- --------------------------------------------------------------------------- + +ALTER TABLE public.billing_products ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.billing_plans ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.billing_subscriptions ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.billing_usage_events ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.billing_usage_aggregates ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.billing_webhook_events ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.billing_system_flags ENABLE ROW LEVEL SECURITY; + +-- Products & plans: readable by any authenticated user +CREATE POLICY "billing_products_select_authenticated" + ON public.billing_products FOR SELECT TO authenticated USING (true); + +CREATE POLICY "billing_plans_select_authenticated" + ON public.billing_plans FOR SELECT TO authenticated USING (true); + +-- Subscriptions: own rows only, select only (mutations via Edge / SECURITY DEFINER RPCs) +CREATE POLICY "billing_subscriptions_select_own" + ON public.billing_subscriptions FOR SELECT TO authenticated USING (user_id = auth.uid()); + +-- Usage: own select only +CREATE POLICY "billing_usage_events_select_own" + ON public.billing_usage_events FOR SELECT TO authenticated USING (user_id = auth.uid()); + +CREATE POLICY "billing_usage_aggregates_select_own" + ON public.billing_usage_aggregates FOR SELECT TO authenticated USING (user_id = auth.uid()); + +-- Webhook log: service role only (no policies for authenticated = deny) +-- System flags: deny direct access for authenticated (RPCs use SECURITY DEFINER) + +-- --------------------------------------------------------------------------- +-- Helper: usage period window for a subscription row +-- --------------------------------------------------------------------------- + +CREATE OR REPLACE FUNCTION public.billing_usage_period(p_sub public.billing_subscriptions) +RETURNS TABLE (period_start timestamptz, period_end timestamptz) +LANGUAGE sql +STABLE +AS $$ + SELECT + CASE + WHEN p_sub.stripe_subscription_id IS NULL + OR lower(p_sub.status) = 'free' THEN date_trunc('month', now() AT TIME ZONE 'utc') + ELSE coalesce(p_sub.current_period_start, date_trunc('month', now() AT TIME ZONE 'utc')) + END AS period_start, + CASE + WHEN p_sub.stripe_subscription_id IS NULL + OR lower(p_sub.status) = 'free' THEN (date_trunc('month', now() AT TIME ZONE 'utc') + interval '1 month') + ELSE coalesce(p_sub.current_period_end, (date_trunc('month', now() AT TIME ZONE 'utc') + interval '1 month')) + END AS period_end; +$$; + +-- --------------------------------------------------------------------------- +-- RPC: ensure free-tier subscription (idempotent) +-- --------------------------------------------------------------------------- + +CREATE OR REPLACE FUNCTION public.ensure_billing_subscription(p_product_id text) +RETURNS public.billing_subscriptions +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_uid uuid := auth.uid(); + v_free_plan_id text; + r public.billing_subscriptions; +BEGIN + IF v_uid IS NULL THEN + RAISE EXCEPTION 'not authenticated'; + END IF; + + SELECT p.id INTO v_free_plan_id + FROM public.billing_plans p + WHERE p.product_id = p_product_id + AND p.price_cents = 0 + ORDER BY p.display_order NULLS LAST, p.id + LIMIT 1; + + IF v_free_plan_id IS NULL THEN + RAISE EXCEPTION 'no free plan for product %', p_product_id; + END IF; + + INSERT INTO public.billing_subscriptions ( + user_id, product_id, plan_id, status, + stripe_customer_id, stripe_subscription_id, + current_period_start, current_period_end + ) + VALUES ( + v_uid, p_product_id, v_free_plan_id, 'free', + NULL, NULL, + date_trunc('month', now() AT TIME ZONE 'utc'), + date_trunc('month', now() AT TIME ZONE 'utc') + interval '1 month' + ) + ON CONFLICT (user_id, product_id) DO NOTHING; + + SELECT * INTO r FROM public.billing_subscriptions s + WHERE s.user_id = v_uid AND s.product_id = p_product_id; + + RETURN r; +END; +$$; + +REVOKE ALL ON FUNCTION public.ensure_billing_subscription(text) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.ensure_billing_subscription(text) TO authenticated; + +-- --------------------------------------------------------------------------- +-- RPC: record usage (insert event + bump aggregate) +-- --------------------------------------------------------------------------- + +CREATE OR REPLACE FUNCTION public.billing_record_usage_event( + p_product_id text, + p_event_type text, + p_quantity integer DEFAULT 1, + p_metadata jsonb DEFAULT '{}'::jsonb +) +RETURNS void +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_uid uuid := auth.uid(); + sub public.billing_subscriptions; + w record; +BEGIN + IF v_uid IS NULL THEN + RAISE EXCEPTION 'not authenticated'; + END IF; + + SELECT * INTO sub FROM public.billing_subscriptions s + WHERE s.user_id = v_uid AND s.product_id = p_product_id; + + IF NOT FOUND THEN + PERFORM public.ensure_billing_subscription(p_product_id); + SELECT * INTO sub FROM public.billing_subscriptions s + WHERE s.user_id = v_uid AND s.product_id = p_product_id; + END IF; + + SELECT * INTO w FROM public.billing_usage_period(sub); + + INSERT INTO public.billing_usage_events (user_id, product_id, event_type, quantity, metadata) + VALUES (v_uid, p_product_id, p_event_type, coalesce(p_quantity, 1), coalesce(p_metadata, '{}'::jsonb)); + + INSERT INTO public.billing_usage_aggregates (user_id, product_id, event_type, period_start, period_end, count) + VALUES (v_uid, p_product_id, p_event_type, w.period_start, w.period_end, coalesce(p_quantity, 1)) + ON CONFLICT (user_id, product_id, event_type, period_start) + DO UPDATE SET count = public.billing_usage_aggregates.count + excluded.count, + period_end = excluded.period_end; +END; +$$; + +REVOKE ALL ON FUNCTION public.billing_record_usage_event(text, text, integer, jsonb) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.billing_record_usage_event(text, text, integer, jsonb) TO authenticated; + +-- --------------------------------------------------------------------------- +-- RPC: remaining usage (used / limit / remaining / period_end) +-- --------------------------------------------------------------------------- + +CREATE OR REPLACE FUNCTION public.billing_get_remaining_usage(p_product_id text, p_event_type text) +RETURNS jsonb +LANGUAGE plpgsql +STABLE +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_uid uuid := auth.uid(); + sub public.billing_subscriptions; + w record; + lim int; + used int := 0; + plan_limits jsonb; +BEGIN + IF v_uid IS NULL THEN + RETURN jsonb_build_object('error', 'unauthenticated'); + END IF; + + SELECT * INTO sub FROM public.billing_subscriptions s + WHERE s.user_id = v_uid AND s.product_id = p_product_id; + + IF NOT FOUND THEN + PERFORM public.ensure_billing_subscription(p_product_id); + SELECT * INTO sub FROM public.billing_subscriptions s + WHERE s.user_id = v_uid AND s.product_id = p_product_id; + END IF; + + SELECT pl.usage_limits INTO plan_limits + FROM public.billing_plans pl + WHERE pl.id = sub.plan_id; + + IF plan_limits ? p_event_type THEN + lim := (plan_limits ->> p_event_type)::integer; + ELSE + lim := NULL; -- unlimited + END IF; + + IF lim IS NOT NULL AND lim < 0 THEN + lim := NULL; -- convention: negative = unlimited + END IF; + + SELECT * INTO w FROM public.billing_usage_period(sub); + + SELECT coalesce(a.count, 0) INTO used + FROM public.billing_usage_aggregates a + WHERE a.user_id = v_uid + AND a.product_id = p_product_id + AND a.event_type = p_event_type + AND a.period_start = w.period_start; + + RETURN jsonb_build_object( + 'used', used, + 'limit', lim, + 'remaining', CASE WHEN lim IS NULL THEN NULL ELSE greatest(lim - used, 0) END, + 'periodEnd', w.period_end, + 'periodStart', w.period_start + ); +END; +$$; + +REVOKE ALL ON FUNCTION public.billing_get_remaining_usage(text, text) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.billing_get_remaining_usage(text, text) TO authenticated; + +CREATE OR REPLACE FUNCTION public.billing_has_exceeded_limit(p_product_id text, p_event_type text) +RETURNS boolean +LANGUAGE sql +STABLE +SECURITY DEFINER +SET search_path = public +AS $$ + WITH j AS ( + SELECT public.billing_get_remaining_usage(p_product_id, p_event_type) AS u + ) + SELECT CASE + WHEN (j.u ->> 'error') IS NOT NULL THEN false + WHEN (j.u -> 'limit') IS NULL OR jsonb_typeof(j.u -> 'limit') = 'null' THEN false + ELSE (j.u ->> 'used')::integer >= (j.u ->> 'limit')::integer + END + FROM j; +$$; + +REVOKE ALL ON FUNCTION public.billing_has_exceeded_limit(text, text) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.billing_has_exceeded_limit(text, text) TO authenticated; + +-- --------------------------------------------------------------------------- +-- Demo RPCs (gated by billing_system_flags.demo_billing_mode) +-- --------------------------------------------------------------------------- + +CREATE OR REPLACE FUNCTION public.billing_demo_mode_enabled() +RETURNS boolean +LANGUAGE sql +STABLE +SECURITY DEFINER +SET search_path = public +AS $$ + SELECT coalesce( + (SELECT value FROM public.billing_system_flags WHERE key = 'demo_billing_mode'), + false + ); +$$; + +REVOKE ALL ON FUNCTION public.billing_demo_mode_enabled() FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.billing_demo_mode_enabled() TO authenticated; + +CREATE OR REPLACE FUNCTION public.billing_demo_simulate_upgrade(p_product_id text, p_plan_id text) +RETURNS public.billing_subscriptions +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_uid uuid := auth.uid(); + r public.billing_subscriptions; +BEGIN + IF NOT public.billing_demo_mode_enabled() THEN + RAISE EXCEPTION 'demo billing disabled'; + END IF; + IF v_uid IS NULL THEN + RAISE EXCEPTION 'not authenticated'; + END IF; + + IF NOT EXISTS (SELECT 1 FROM public.billing_plans pl WHERE pl.id = p_plan_id AND pl.product_id = p_product_id) THEN + RAISE EXCEPTION 'invalid plan'; + END IF; + + PERFORM public.ensure_billing_subscription(p_product_id); + + UPDATE public.billing_subscriptions s + SET plan_id = p_plan_id, + status = CASE + WHEN (SELECT price_cents FROM public.billing_plans pl WHERE pl.id = p_plan_id) = 0 THEN 'free' + ELSE 'active' + END, + stripe_subscription_id = NULL, + stripe_customer_id = NULL, + current_period_start = CASE + WHEN (SELECT price_cents FROM public.billing_plans pl WHERE pl.id = p_plan_id) = 0 THEN date_trunc('month', now() AT TIME ZONE 'utc') + ELSE now() AT TIME ZONE 'utc' + END, + current_period_end = CASE + WHEN (SELECT price_cents FROM public.billing_plans pl WHERE pl.id = p_plan_id) = 0 THEN date_trunc('month', now() AT TIME ZONE 'utc') + interval '1 month' + ELSE now() AT TIME ZONE 'utc' + interval '1 month' + END, + updated_at = now() + WHERE s.user_id = v_uid AND s.product_id = p_product_id + RETURNING * INTO r; + + RETURN r; +END; +$$; + +REVOKE ALL ON FUNCTION public.billing_demo_simulate_upgrade(text, text) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.billing_demo_simulate_upgrade(text, text) TO authenticated; + +CREATE OR REPLACE FUNCTION public.billing_demo_reset_usage(p_product_id text, p_event_type text) +RETURNS void +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_uid uuid := auth.uid(); +BEGIN + IF NOT public.billing_demo_mode_enabled() THEN + RAISE EXCEPTION 'demo billing disabled'; + END IF; + IF v_uid IS NULL THEN + RAISE EXCEPTION 'not authenticated'; + END IF; + + DELETE FROM public.billing_usage_events e + WHERE e.user_id = v_uid AND e.product_id = p_product_id AND e.event_type = p_event_type; + + DELETE FROM public.billing_usage_aggregates a + WHERE a.user_id = v_uid AND a.product_id = p_product_id AND a.event_type = p_event_type; +END; +$$; + +REVOKE ALL ON FUNCTION public.billing_demo_reset_usage(text, text) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.billing_demo_reset_usage(text, text) TO authenticated; + +-- --------------------------------------------------------------------------- +-- Realtime +-- --------------------------------------------------------------------------- + +ALTER PUBLICATION supabase_realtime ADD TABLE public.billing_subscriptions; +ALTER TABLE public.billing_subscriptions REPLICA IDENTITY FULL; diff --git a/supabase/migrations/20260426000100_billing_demo_collections.sql b/supabase/migrations/20260426000100_billing_demo_collections.sql new file mode 100644 index 00000000..cb1e2e0d --- /dev/null +++ b/supabase/migrations/20260426000100_billing_demo_collections.sql @@ -0,0 +1,117 @@ +-- Billing demo persistence for collections/items counters (template-only). +-- Keeps the demo "numeric caps" surface persistent across reloads. + +CREATE TABLE IF NOT EXISTS public.billing_demo_collections ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES auth.users (id) ON DELETE CASCADE, + product_id text NOT NULL REFERENCES public.billing_products (id) ON DELETE CASCADE, + item_count integer NOT NULL DEFAULT 0, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_billing_demo_collections_lookup + ON public.billing_demo_collections (user_id, product_id, created_at); + +CREATE TRIGGER billing_demo_collections_updated_at + BEFORE UPDATE ON public.billing_demo_collections + FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column(); + +ALTER TABLE public.billing_demo_collections ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "billing_demo_collections_select_own" + ON public.billing_demo_collections FOR SELECT TO authenticated USING (user_id = auth.uid()); + +-- Mutations happen through SECURITY DEFINER RPCs. + +CREATE OR REPLACE FUNCTION public.billing_demo_get_collections(p_product_id text) +RETURNS TABLE (id uuid, item_count integer) +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_uid uuid := auth.uid(); +BEGIN + IF NOT public.billing_demo_mode_enabled() THEN + RAISE EXCEPTION 'demo billing disabled'; + END IF; + IF v_uid IS NULL THEN + RAISE EXCEPTION 'not authenticated'; + END IF; + + RETURN QUERY + SELECT c.id, c.item_count + FROM public.billing_demo_collections c + WHERE c.user_id = v_uid + AND c.product_id = p_product_id + ORDER BY c.created_at; +END; +$$; + +REVOKE ALL ON FUNCTION public.billing_demo_get_collections(text) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.billing_demo_get_collections(text) TO authenticated; + +CREATE OR REPLACE FUNCTION public.billing_demo_add_collection(p_product_id text) +RETURNS uuid +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_uid uuid := auth.uid(); + v_id uuid; +BEGIN + IF NOT public.billing_demo_mode_enabled() THEN + RAISE EXCEPTION 'demo billing disabled'; + END IF; + IF v_uid IS NULL THEN + RAISE EXCEPTION 'not authenticated'; + END IF; + + INSERT INTO public.billing_demo_collections (user_id, product_id, item_count) + VALUES (v_uid, p_product_id, 0) + RETURNING id INTO v_id; + + RETURN v_id; +END; +$$; + +REVOKE ALL ON FUNCTION public.billing_demo_add_collection(text) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.billing_demo_add_collection(text) TO authenticated; + +CREATE OR REPLACE FUNCTION public.billing_demo_add_item(p_product_id text, p_collection_id uuid) +RETURNS integer +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_uid uuid := auth.uid(); + v_count integer; +BEGIN + IF NOT public.billing_demo_mode_enabled() THEN + RAISE EXCEPTION 'demo billing disabled'; + END IF; + IF v_uid IS NULL THEN + RAISE EXCEPTION 'not authenticated'; + END IF; + + UPDATE public.billing_demo_collections c + SET item_count = c.item_count + 1, + updated_at = now() + WHERE c.id = p_collection_id + AND c.user_id = v_uid + AND c.product_id = p_product_id + RETURNING c.item_count INTO v_count; + + IF NOT FOUND THEN + RAISE EXCEPTION 'collection not found'; + END IF; + + RETURN v_count; +END; +$$; + +REVOKE ALL ON FUNCTION public.billing_demo_add_item(text, uuid) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.billing_demo_add_item(text, uuid) TO authenticated; diff --git a/supabase/migrations/20260427120000_billing_ui_v1.sql b/supabase/migrations/20260427120000_billing_ui_v1.sql new file mode 100644 index 00000000..5f5e27b6 --- /dev/null +++ b/supabase/migrations/20260427120000_billing_ui_v1.sql @@ -0,0 +1,58 @@ +-- BeakerStack Billing UI v1: split Stripe prices by cadence, store subscription price id, invoice history + +-- --------------------------------------------------------------------------- +-- billing_plans: monthly + annual Stripe price ids +-- --------------------------------------------------------------------------- + +ALTER TABLE public.billing_plans + ADD COLUMN IF NOT EXISTS stripe_price_id_monthly text, + ADD COLUMN IF NOT EXISTS stripe_price_id_annual text; + +UPDATE public.billing_plans +SET stripe_price_id_monthly = stripe_price_id +WHERE stripe_price_id IS NOT NULL; + +ALTER TABLE public.billing_plans + DROP COLUMN IF EXISTS stripe_price_id; + +-- --------------------------------------------------------------------------- +-- billing_subscriptions: which Stripe price the user is on (cadence) +-- --------------------------------------------------------------------------- + +ALTER TABLE public.billing_subscriptions + ADD COLUMN IF NOT EXISTS stripe_price_id text; + +-- --------------------------------------------------------------------------- +-- billing_invoices: Stripe invoice mirror (webhook-only writes) +-- --------------------------------------------------------------------------- + +CREATE TABLE IF NOT EXISTS public.billing_invoices ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES auth.users (id) ON DELETE CASCADE, + stripe_invoice_id text NOT NULL UNIQUE, + stripe_customer_id text NOT NULL, + stripe_subscription_id text, + amount_due integer NOT NULL, + amount_paid integer NOT NULL, + currency text NOT NULL, + status text NOT NULL, + description text, + hosted_invoice_url text, + invoice_pdf_url text, + period_start timestamptz, + period_end timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + finalized_at timestamptz, + paid_at timestamptz +); + +CREATE INDEX IF NOT EXISTS idx_billing_invoices_user_id_created + ON public.billing_invoices (user_id, created_at DESC); + +ALTER TABLE public.billing_invoices ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "billing_invoices_select_own" + ON public.billing_invoices FOR SELECT TO authenticated + USING (user_id = auth.uid()); + +COMMENT ON TABLE public.billing_invoices IS 'Stripe invoice rows; insert/update only via service role (webhooks).'; diff --git a/supabase/migrations/20260427123000_billing_demo_delete_collection.sql b/supabase/migrations/20260427123000_billing_demo_delete_collection.sql new file mode 100644 index 00000000..b6da0348 --- /dev/null +++ b/supabase/migrations/20260427123000_billing_demo_delete_collection.sql @@ -0,0 +1,30 @@ +-- Delete a billing demo collection row (same guards as add/add_item). + +CREATE OR REPLACE FUNCTION public.billing_demo_delete_collection( + p_product_id text, + p_collection_id uuid +) +RETURNS void +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_uid uuid := auth.uid(); +BEGIN + IF NOT public.billing_demo_mode_enabled() THEN + RAISE EXCEPTION 'demo billing disabled'; + END IF; + IF v_uid IS NULL THEN + RAISE EXCEPTION 'not authenticated'; + END IF; + + DELETE FROM public.billing_demo_collections c + WHERE c.id = p_collection_id + AND c.user_id = v_uid + AND c.product_id = p_product_id; +END; +$$; + +REVOKE ALL ON FUNCTION public.billing_demo_delete_collection(text, uuid) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.billing_demo_delete_collection(text, uuid) TO authenticated; diff --git a/supabase/migrations/20260428120000_billing_pending_target_and_usage_realtime.sql b/supabase/migrations/20260428120000_billing_pending_target_and_usage_realtime.sql new file mode 100644 index 00000000..7471184d --- /dev/null +++ b/supabase/migrations/20260428120000_billing_pending_target_and_usage_realtime.sql @@ -0,0 +1,16 @@ +-- Scheduled downgrade target (set by schedule_cancel_to_free Edge path; cleared on reactivate / period sync). +ALTER TABLE public.billing_subscriptions + ADD COLUMN IF NOT EXISTS pending_target_plan_id text REFERENCES public.billing_plans (id) ON DELETE SET NULL; + +COMMENT ON COLUMN public.billing_subscriptions.pending_target_plan_id IS + 'When cancel_at_period_end is true, optional plan user will move to (e.g. free). Set by app Edge; NULL for portal-only cancels (cancelled_pending vs downgrade_pending).'; + +-- Realtime for usage aggregates so meters refresh without subscription-only events. +DO $$ +BEGIN + ALTER PUBLICATION supabase_realtime ADD TABLE public.billing_usage_aggregates; +EXCEPTION + WHEN duplicate_object THEN NULL; +END +$$; +ALTER TABLE public.billing_usage_aggregates REPLICA IDENTITY FULL; diff --git a/supabase/seed.sql b/supabase/seed.sql index b25be369..4c603b4d 100644 --- a/supabase/seed.sql +++ b/supabase/seed.sql @@ -1,6 +1,72 @@ -- Seed data for local development -- This file contains basic test data for the Beaker Stack --- For now, just a comment since we don't have tables yet --- Tables will be created in future migrations -SELECT 'Seed data loaded successfully' as status; \ No newline at end of file +-- Billing template product + plans (ids must match apps/web billing config) +INSERT INTO public.billing_products (id, display_name, description) +VALUES ( + 'beakerstack', + 'BeakerStack', + 'Template billing demo product' +) +ON CONFLICT (id) DO NOTHING; + +INSERT INTO public.billing_plans ( + id, product_id, display_name, description, price_cents, billing_period, + stripe_price_id_monthly, stripe_price_id_annual, stripe_product_id, features, usage_limits, trial_period_days, + is_public, display_order +) +VALUES +( + 'beakerstack_free', + 'beakerstack', + 'Free', + 'Starter', + 0, + 'free', + NULL, + NULL, + NULL, + '{"containers_per_account_max": 2, "items_per_container_max": 3, "feature_a": false, "feature_b": false}'::jsonb, + '{"ai_summarize": 30}'::jsonb, + 0, + true, + 1 +), +( + 'beakerstack_pro', + 'beakerstack', + 'Pro', + 'More capacity', + 1900, + 'monthly', + NULL, + NULL, + NULL, + '{"containers_per_account_max": -1, "items_per_container_max": 25, "feature_a": true, "feature_b": false}'::jsonb, + '{"ai_summarize": 500}'::jsonb, + 0, + true, + 2 +), +( + 'beakerstack_max', + 'beakerstack', + 'Max', + 'Everything', + 4900, + 'monthly', + NULL, + NULL, + NULL, + '{"containers_per_account_max": -1, "items_per_container_max": -1, "feature_a": true, "feature_b": true, "feature_c": true}'::jsonb, + '{"ai_summarize": -1}'::jsonb, + 5, + true, + 3 +) +ON CONFLICT (id) DO NOTHING; + +-- Local dev: allow template demo RPCs (never enable in production revenue DB) +UPDATE public.billing_system_flags SET value = true WHERE key = 'demo_billing_mode'; + +SELECT 'Seed data loaded successfully' AS status; \ No newline at end of file diff --git a/supabase/tests/billing.test.sql b/supabase/tests/billing.test.sql new file mode 100644 index 00000000..2e9193d8 --- /dev/null +++ b/supabase/tests/billing.test.sql @@ -0,0 +1,55 @@ +-- pgTAP: billing tables and RLS presence +BEGIN; +SELECT plan(9); + +SELECT ok( + EXISTS ( + SELECT 1 FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = 'public' AND c.relname = 'billing_subscriptions' AND c.relrowsecurity = true + ), + 'RLS enabled on billing_subscriptions' +); + +SELECT ok( + EXISTS (SELECT 1 FROM pg_policies WHERE tablename = 'billing_subscriptions' AND policyname = 'billing_subscriptions_select_own'), + 'billing_subscriptions select policy exists' +); + +SELECT ok( + EXISTS (SELECT 1 FROM information_schema.routines WHERE routine_schema = 'public' AND routine_name = 'ensure_billing_subscription'), + 'ensure_billing_subscription exists' +); + +SELECT ok( + EXISTS (SELECT 1 FROM information_schema.routines WHERE routine_schema = 'public' AND routine_name = 'billing_record_usage_event'), + 'billing_record_usage_event exists' +); + +SELECT ok( + EXISTS (SELECT 1 FROM information_schema.routines WHERE routine_schema = 'public' AND routine_name = 'billing_get_remaining_usage'), + 'billing_get_remaining_usage exists' +); + +SELECT ok( + EXISTS (SELECT 1 FROM information_schema.routines WHERE routine_schema = 'public' AND routine_name = 'billing_demo_simulate_upgrade'), + 'billing_demo_simulate_upgrade exists' +); + +SELECT ok( + EXISTS (SELECT 1 FROM information_schema.routines WHERE routine_schema = 'public' AND routine_name = 'billing_demo_delete_collection'), + 'billing_demo_delete_collection exists' +); + +SELECT ok( + EXISTS (SELECT 1 FROM pg_publication_tables WHERE pubname = 'supabase_realtime' AND tablename = 'billing_subscriptions'), + 'billing_subscriptions in supabase_realtime publication' +); + +SELECT ok( + EXISTS (SELECT 1 FROM pg_publication_tables WHERE pubname = 'supabase_realtime' AND tablename = 'billing_usage_aggregates'), + 'billing_usage_aggregates in supabase_realtime publication' +); + +SELECT * FROM finish(); +ROLLBACK; diff --git a/tests/e2e/shared/test-data.ts b/tests/e2e/shared/test-data.ts index 88cdccf9..eb0c588b 100644 --- a/tests/e2e/shared/test-data.ts +++ b/tests/e2e/shared/test-data.ts @@ -3,6 +3,7 @@ * Provides reusable test data that can be used across E2E test flows */ +import { randomUUID } from 'node:crypto'; import { generateTestPassword } from '../../utils/test-helpers'; /** Email for the seeded “valid login” E2E user (password from {@link getResolvedTestPassword}). */ @@ -36,10 +37,11 @@ export function getPredefinedValidLoginUser(): TestUser { } /** - * Generate a unique test email + * Generate a unique test email. + * Uses crypto.randomUUID() to avoid collisions in parallel test runs. */ export function generateTestEmail(): string { - return `e2e-test-${Date.now()}-${Math.random().toString(36).substring(7)}@example.com`; + return `e2e-test-${randomUUID()}@example.com`; } /** @@ -61,13 +63,28 @@ export function createTestUser(): TestUser { } /** - * Predefined test users for different scenarios + * Predefined test users for different scenarios. + * + * The previous combined `invalid` entry has been split into three named + * scenarios so each negative-path test can target a specific validation + * failure rather than relying on a single ambiguous fixture. */ export const TestUsers = { get valid(): TestUser { return getPredefinedValidLoginUser(); }, - invalid: { + /** Malformed email format with an otherwise acceptable password. */ + invalidEmail: { + email: 'invalid-email', + password: getResolvedTestPassword(), + }, + /** Valid email format with a weak password value. */ + weakPassword: { + email: PREDEFINED_VALID_USER_EMAIL, + password: 'weak', + }, + /** Both email and password are invalid. */ + invalidBoth: { email: 'invalid-email', password: 'weak', }, diff --git a/tests/utils/supabase-cli-defaults.ts b/tests/utils/supabase-cli-defaults.ts new file mode 100644 index 00000000..ca2a4a79 --- /dev/null +++ b/tests/utils/supabase-cli-defaults.ts @@ -0,0 +1,63 @@ +/** + * Public Supabase CLI demo JWTs for local-only test workflows. + * + * The constants below are NOT secrets. They are the canonical, + * documented JWTs that every `npx supabase start` instance emits: + * + * - Header: `{"alg":"HS256","typ":"JWT"}` + * - Payload: `{"iss":"supabase-demo","role":"anon"|"service_role","exp":1983812996}` + * - Signing secret: `super-secret-jwt-token-with-at-least-32-characters-long` + * (the well-known default baked into the Supabase CLI source). + * + * Source of truth: https://github.com/supabase/cli (jwt secret + demo + * token defaults emitted by `supabase status`). They cannot authenticate + * against any cloud Supabase project because every cloud project uses a + * unique signing secret. + * + * They are committed here so the test suite and CI can run with zero + * configuration against a local `supabase start` instance. Production + * code paths must never touch this file: any non-loopback Supabase URL + * or `NODE_ENV=production` will trip the guard below. + */ + +const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '0.0.0.0', '::1']); + +/** Public Supabase CLI anon JWT. See file banner. */ +export const LOCAL_SUPABASE_DEMO_ANON_KEY = + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0'; + +/** Public Supabase CLI service-role JWT. See file banner. */ +export const LOCAL_SUPABASE_DEMO_SERVICE_ROLE_KEY = + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU'; + +function isLoopbackSupabaseUrl(rawUrl: string): boolean { + try { + const { hostname } = new URL(rawUrl); + if (LOOPBACK_HOSTS.has(hostname)) return true; + if (hostname.endsWith('.localhost')) return true; + if (hostname.startsWith('supabase_internal_')) return true; + return false; + } catch { + return false; + } +} + +/** + * Refuses to use the demo keys outside a local environment. + * Call this once per test client/session creation. + */ +export function assertLocalSupabaseEnvironment(supabaseUrl: string): void { + if (process.env['NODE_ENV'] === 'production') { + throw new Error( + 'Refusing to use Supabase CLI demo keys: NODE_ENV is "production". ' + + 'Set SUPABASE_URL and SUPABASE_ANON_KEY (and SUPABASE_SERVICE_ROLE_KEY if needed) explicitly.' + ); + } + + if (!isLoopbackSupabaseUrl(supabaseUrl)) { + throw new Error( + `Refusing to use Supabase CLI demo keys against a non-local URL: ${supabaseUrl}. ` + + 'Set SUPABASE_ANON_KEY/SUPABASE_SERVICE_ROLE_KEY in the environment to override.' + ); + } +} diff --git a/tests/utils/test-clients.ts b/tests/utils/test-clients.ts index 0bcba4d1..26434b54 100644 --- a/tests/utils/test-clients.ts +++ b/tests/utils/test-clients.ts @@ -5,6 +5,10 @@ import { createClient, SupabaseClient } from '@supabase/supabase-js'; import { getTestSupabaseConfig } from './test-database'; +import { + LOCAL_SUPABASE_DEMO_SERVICE_ROLE_KEY, + assertLocalSupabaseEnvironment, +} from './supabase-cli-defaults'; /** * Create a Supabase client for web testing @@ -49,7 +53,11 @@ export function createServiceRoleClient(): SupabaseClient { const supabaseUrl = process.env.SUPABASE_URL || 'http://127.0.0.1:54321'; const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY || - 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU'; + LOCAL_SUPABASE_DEMO_SERVICE_ROLE_KEY; + + if (serviceRoleKey === LOCAL_SUPABASE_DEMO_SERVICE_ROLE_KEY) { + assertLocalSupabaseEnvironment(supabaseUrl); + } return createClient(supabaseUrl, serviceRoleKey, { auth: { diff --git a/tests/utils/test-database.ts b/tests/utils/test-database.ts index 1e59b0ca..addba24d 100644 --- a/tests/utils/test-database.ts +++ b/tests/utils/test-database.ts @@ -3,17 +3,29 @@ * Provides helpers for database operations, cleanup, and test data management */ +import { randomUUID } from 'node:crypto'; import { SupabaseClient } from '@supabase/supabase-js'; +import { + LOCAL_SUPABASE_DEMO_ANON_KEY, + assertLocalSupabaseEnvironment, +} from './supabase-cli-defaults'; /** - * Get Supabase URL and anon key from environment variables - * Falls back to local defaults if not set + * Get Supabase URL and anon key from environment variables. + * Falls back to the public Supabase CLI demo keys when running against a + * local `supabase start` instance (see ./supabase-cli-defaults.ts). */ export function getTestSupabaseConfig() { const supabaseUrl = process.env['SUPABASE_URL'] || 'http://127.0.0.1:54321'; const supabaseAnonKey = - process.env['SUPABASE_ANON_KEY'] || - 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0'; + process.env['SUPABASE_ANON_KEY'] || LOCAL_SUPABASE_DEMO_ANON_KEY; + + // Run the local guard whenever the resolved key is the public CLI demo JWT, + // including when someone copies it into the environment alongside a + // non-local SUPABASE_URL (which would otherwise bypass the check). + if (supabaseAnonKey === LOCAL_SUPABASE_DEMO_ANON_KEY) { + assertLocalSupabaseEnvironment(supabaseUrl); + } return { supabaseUrl, supabaseAnonKey }; } @@ -54,17 +66,24 @@ export async function cleanupTestUser( } /** - * Generate a unique test email + * Generate a unique test email. + * Uses crypto.randomUUID() to avoid collisions in parallel test runs. */ export function generateTestEmail(): string { - return `test-${Date.now()}-${Math.random().toString(36).substring(7)}@example.com`; + return `test-${randomUUID()}@example.com`; } /** - * Generate a unique test username + * Generate a unique test username. + * Uses random hex from a UUID so parallel runs rarely collide. The value is + * capped at 30 characters to satisfy DB / profile schema limits (see + * packages/shared/src/validation/profileSchema.ts). */ export function generateTestUsername(): string { - return `testuser_${Date.now()}_${Math.random().toString(36).substring(7)}`; + const prefix = 'testuser_'; + const hex = randomUUID().replace(/-/g, ''); + const suffix = hex.slice(0, 30 - prefix.length); + return `${prefix}${suffix}`; } /**