Add Stripe payment integration for AI subscriptions

Implement subscription-based AI access with 250 generations/month at $5/month or $50/year.

Changes:
- Backend: Stripe service, payment routes, webhook handlers, generation tracking
- Frontend: Upgrade page with pricing, payment success/cancel pages, UI prompts
- Database: Add subscription fields to users, payments table, migrations
- Config: Stripe env vars to .env.example, docker-compose.prod.yml, PRODUCTION.md
- Tests: Payment route tests, component tests, subscription hook tests

Users without AI access see upgrade prompts; subscribers see remaining generation count.
This commit is contained in:
Joey Yakimowich-Payne 2026-01-21 16:11:03 -07:00
commit 2e12edc249
No known key found for this signature in database
GPG key ID: DDF6AF5B21B407D4
22 changed files with 2866 additions and 21 deletions

View file

@ -0,0 +1,416 @@
import { renderHook, act, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
const mockAuthFetch = vi.fn();
const mockAuth = {
isAuthenticated: true,
};
vi.mock('../../hooks/useAuthenticatedFetch', () => ({
useAuthenticatedFetch: () => ({
authFetch: mockAuthFetch,
isAuthenticated: mockAuth.isAuthenticated,
}),
}));
vi.mock('react-hot-toast', () => ({
default: {
success: vi.fn(),
error: vi.fn(),
},
}));
describe('useUserPreferences - subscription data', () => {
let useUserPreferences: typeof import('../../hooks/useUserPreferences').useUserPreferences;
beforeEach(async () => {
vi.clearAllMocks();
vi.resetModules();
mockAuth.isAuthenticated = true;
const module = await import('../../hooks/useUserPreferences');
useUserPreferences = module.useUserPreferences;
});
afterEach(() => {
vi.resetAllMocks();
});
describe('subscription info fetching', () => {
it('fetches subscription status along with preferences', async () => {
mockAuthFetch
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({
colorScheme: 'blue',
aiProvider: 'gemini',
hasAIAccess: true,
}),
})
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({
hasAccess: true,
accessType: 'subscription',
generationCount: 10,
generationLimit: 250,
generationsRemaining: 240,
}),
});
const { result } = renderHook(() => useUserPreferences());
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.subscription).toEqual({
hasAccess: true,
accessType: 'subscription',
generationCount: 10,
generationLimit: 250,
generationsRemaining: 240,
});
});
it('sets subscription info for group access users', async () => {
mockAuthFetch
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({
colorScheme: 'blue',
aiProvider: 'gemini',
hasAIAccess: true,
}),
})
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({
hasAccess: true,
accessType: 'group',
generationCount: null,
generationLimit: null,
generationsRemaining: null,
}),
});
const { result } = renderHook(() => useUserPreferences());
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.subscription).toEqual({
hasAccess: true,
accessType: 'group',
generationCount: null,
generationLimit: null,
generationsRemaining: null,
});
});
it('sets subscription info for users without access', async () => {
mockAuthFetch
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({
colorScheme: 'blue',
aiProvider: 'gemini',
hasAIAccess: false,
}),
})
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({
hasAccess: false,
accessType: 'none',
generationCount: 0,
generationLimit: 250,
generationsRemaining: 0,
}),
});
const { result } = renderHook(() => useUserPreferences());
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.subscription).toEqual({
hasAccess: false,
accessType: 'none',
generationCount: 0,
generationLimit: 250,
generationsRemaining: 0,
});
});
it('handles subscription status fetch failure gracefully', async () => {
mockAuthFetch
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({
colorScheme: 'purple',
aiProvider: 'openrouter',
hasAIAccess: false,
}),
})
.mockRejectedValueOnce(new Error('Network error'));
const { result } = renderHook(() => useUserPreferences());
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.subscription).toBeNull();
expect(result.current.preferences.colorScheme).toBe('purple');
});
it('handles subscription status non-ok response gracefully', async () => {
mockAuthFetch
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({
colorScheme: 'blue',
aiProvider: 'gemini',
hasAIAccess: true,
}),
})
.mockResolvedValueOnce({
ok: false,
status: 503,
});
const { result } = renderHook(() => useUserPreferences());
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.subscription).toBeNull();
});
});
describe('hasAIAccess from preferences', () => {
it('sets hasAIAccess true when user has AI access', async () => {
mockAuthFetch
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({
colorScheme: 'blue',
aiProvider: 'gemini',
hasAIAccess: true,
}),
})
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({
hasAccess: true,
accessType: 'group',
}),
});
const { result } = renderHook(() => useUserPreferences());
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.hasAIAccess).toBe(true);
});
it('sets hasAIAccess false when user does not have AI access', async () => {
mockAuthFetch
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({
colorScheme: 'blue',
aiProvider: 'gemini',
hasAIAccess: false,
}),
})
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({
hasAccess: false,
accessType: 'none',
}),
});
const { result } = renderHook(() => useUserPreferences());
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.hasAIAccess).toBe(false);
});
it('defaults hasAIAccess to false when not present', async () => {
mockAuthFetch
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({
colorScheme: 'blue',
aiProvider: 'gemini',
}),
})
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({
hasAccess: false,
accessType: 'none',
}),
});
const { result } = renderHook(() => useUserPreferences());
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.hasAIAccess).toBe(false);
});
});
describe('when not authenticated', () => {
beforeEach(() => {
mockAuth.isAuthenticated = false;
});
it('does not fetch subscription status', async () => {
const { result } = renderHook(() => useUserPreferences());
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(mockAuthFetch).not.toHaveBeenCalled();
expect(result.current.subscription).toBeNull();
});
});
describe('refetching subscription', () => {
it('can refetch subscription info via fetchPreferences', async () => {
mockAuthFetch
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({
colorScheme: 'blue',
aiProvider: 'gemini',
hasAIAccess: false,
}),
})
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({
hasAccess: false,
accessType: 'none',
generationCount: 0,
generationLimit: 250,
generationsRemaining: 0,
}),
});
const { result } = renderHook(() => useUserPreferences());
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.subscription?.hasAccess).toBe(false);
mockAuthFetch
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({
colorScheme: 'blue',
aiProvider: 'gemini',
hasAIAccess: true,
}),
})
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({
hasAccess: true,
accessType: 'subscription',
generationCount: 0,
generationLimit: 250,
generationsRemaining: 250,
}),
});
await act(async () => {
await result.current.fetchPreferences();
});
expect(result.current.subscription?.hasAccess).toBe(true);
expect(result.current.subscription?.accessType).toBe('subscription');
});
});
describe('generation tracking', () => {
it('tracks generation count correctly', async () => {
mockAuthFetch
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({
colorScheme: 'blue',
aiProvider: 'gemini',
hasAIAccess: true,
}),
})
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({
hasAccess: true,
accessType: 'subscription',
generationCount: 50,
generationLimit: 250,
generationsRemaining: 200,
}),
});
const { result } = renderHook(() => useUserPreferences());
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.subscription?.generationCount).toBe(50);
expect(result.current.subscription?.generationsRemaining).toBe(200);
});
it('shows zero remaining when limit reached', async () => {
mockAuthFetch
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({
colorScheme: 'blue',
aiProvider: 'gemini',
hasAIAccess: true,
}),
})
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({
hasAccess: true,
accessType: 'subscription',
generationCount: 250,
generationLimit: 250,
generationsRemaining: 0,
}),
});
const { result } = renderHook(() => useUserPreferences());
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.subscription?.generationsRemaining).toBe(0);
});
});
});