This commit is contained in:
Nikita
2025-12-21 13:37:19 -08:00
commit 8cd4b943a5
173 changed files with 44266 additions and 0 deletions

View File

@@ -0,0 +1,330 @@
# How Flowglad is Integrated
This document explains the Flowglad billing integration in this codebase.
## Overview
Flowglad handles usage-based billing with metered subscriptions. Users can:
- Use the app for free with limited requests
- Subscribe to a paid plan for more usage
- Top up credits when needed
## Architecture
```
┌─────────────────────────────────────────────────────────────────┐
│ Frontend │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ useBilling() │ │ Pricing UI │ │
│ │ (Flowglad hook) │ │ (checkout) │ │
│ └────────┬────────┘ └────────┬────────┘ │
└───────────┼──────────────────────┼──────────────────────────────┘
│ │
▼ ▼
┌───────────────────────┐ ┌───────────────────────┐
│ /api/flowglad/* │ │ /api/usage-events │
│ (billing endpoints) │ │ (record usage) │
└───────────┬───────────┘ └───────────┬───────────┘
│ │
▼ ▼
┌─────────────────────────────────────────────────────────────────┐
│ Flowglad API │
│ - Customer management │
│ - Subscriptions │
│ - Usage metering │
│ - Checkout sessions │
└─────────────────────────────────────────────────────────────────┘
```
## File Structure
```
packages/web/src/
├── lib/
│ ├── flowglad.ts # FlowgladServer initialization
│ ├── billing.ts # Usage checking & recording logic
│ └── billing-helpers.ts # Utility functions for pricing/usage
└── routes/api/
├── flowglad/
│ └── $.ts # Catch-all route for Flowglad API
└── usage-events.ts # Record usage events
```
## Core Files
### 1. `lib/flowglad.ts` - Server Initialization
Creates a FlowgladServer instance for a specific user:
```typescript
import { FlowgladServer } from "@flowglad/server"
import { db } from "@/db/connection"
import { users } from "@/db/schema"
import { eq } from "drizzle-orm"
export const flowglad = (customerExternalId: string) => {
const env = getEnv()
if (!env.FLOWGLAD_SECRET_KEY) {
return null
}
return new FlowgladServer({
apiKey: env.FLOWGLAD_SECRET_KEY,
customerExternalId, // Maps to user.id
getCustomerDetails: async (externalId: string) => {
// Fetch user details from database
const user = await db().query.users.findFirst({
where: eq(users.id, externalId),
})
if (!user) {
throw new Error(`User not found: ${externalId}`)
}
return {
email: user.email,
name: user.name ?? undefined,
}
},
})
}
```
**Key Points:**
- Takes `customerExternalId` which is the user's ID from better-auth
- Fetches customer details (email, name) from database when needed
- Returns `null` if `FLOWGLAD_SECRET_KEY` is not configured
### 2. `routes/api/flowglad/$.ts` - API Route Handler
Proxies requests to Flowglad for billing operations:
```typescript
export const Route = createFileRoute("/api/flowglad/$")({
server: {
handlers: {
GET: async ({ request, params }) => {
const userId = await getUserId(request)
if (!userId) {
return json({ error: "Unauthorized" }, 401)
}
const flowgladServer = flowglad(userId)
// ... handle request
},
POST: async ({ request, params }) => {
// Same pattern for POST
},
},
},
})
```
**Endpoints available:**
- `GET /api/flowglad/billing` - Get user's billing info
- `POST /api/flowglad/checkout` - Create checkout session
- etc.
### 3. `routes/api/usage-events.ts` - Record Usage
Records usage events after AI requests:
```typescript
// POST /api/usage-events
// Body: { usageMeterSlug: "ai_requests", amount: 1 }
const usageEvent = await flowgladServer.createUsageEvent({
subscriptionId: currentSubscription.id,
priceSlug: usagePrice.slug,
amount,
transactionId: finalTransactionId, // For idempotency
})
```
### 4. `lib/billing.ts` - Usage Logic
Implements billing business logic:
```typescript
// Usage limits by tier
const GUEST_FREE_REQUESTS = 5 // No auth required
const AUTH_FREE_REQUESTS_DAILY = 20 // Authenticated, no subscription
const PAID_PLAN_REQUESTS = 1000 // Pro plan per billing period
// Meter slug (must match Flowglad dashboard)
export const AI_REQUESTS_METER = "ai_requests"
// Key functions:
export async function checkUsageAllowed(request: Request): Promise<UsageCheckResult>
export async function recordUsage(request: Request, amount?: number): Promise<void>
export async function getBillingSummary(request: Request): Promise<BillingSummary>
```
### 5. `lib/billing-helpers.ts` - Utilities
Helper functions for working with Flowglad data:
```typescript
// Find usage price by meter slug
findUsagePriceByMeterSlug(usageMeterSlug, pricingModel)
// Compute total usage credits from subscription
computeUsageTotal(usageMeterSlug, currentSubscription, pricingModel)
// Check if plan is default/free
isDefaultPlanBySlug(pricingModel, priceSlug)
```
## Usage Flow
### 1. User Makes AI Request
```typescript
// In /api/chat/ai.ts
const usage = await checkUsageAllowed(request)
if (!usage.allowed) {
return new Response("Usage limit reached", { status: 429 })
}
// ... process AI request ...
// Record usage after success
await recordUsage(request, 1)
```
### 2. Frontend Checks Billing
```typescript
// Using Flowglad React hook
import { useBilling } from "@flowglad/react" // or @flowglad/nextjs
function Dashboard() {
const billing = useBilling()
if (!billing.loaded) return <Loading />
const usage = billing.checkUsageBalance("ai_requests")
const remaining = usage?.availableBalance ?? 0
return <div>Remaining: {remaining}</div>
}
```
### 3. User Upgrades
```typescript
// Create checkout session
const handleUpgrade = async () => {
await billing.createCheckoutSession({
priceSlug: "pro_monthly",
successUrl: `${window.location.origin}/`,
cancelUrl: window.location.href,
quantity: 1,
autoRedirect: true,
})
}
```
## Flowglad Dashboard Setup
### 1. Create Usage Meter
In Flowglad dashboard, create a usage meter:
- **Slug**: `ai_requests`
- **Name**: "AI Requests"
- **Type**: Sum
- **Reset**: Per billing period
### 2. Create Products & Prices
**Free Plan (default):**
- Default: Yes
- Price: $0/month
**Pro Plan:**
- Name: "Pro"
- Price slug: `pro_monthly`
- Amount: $7.99/month
- Add usage price for `ai_requests` meter with 1000 included credits
### 3. Get API Key
1. Go to Settings → API Keys
2. Create a secret key (starts with `sk_`)
3. Add to environment:
```bash
# Local development
echo "FLOWGLAD_SECRET_KEY=sk_test_xxx" >> packages/web/.env
# Production (Cloudflare)
wrangler secret put FLOWGLAD_SECRET_KEY
```
## Environment Variables
```bash
# Required for Flowglad
FLOWGLAD_SECRET_KEY=sk_live_xxx # or sk_test_xxx for testing
```
## Testing Locally
1. Set up Flowglad account and get test API key
2. Add to `.env`:
```
FLOWGLAD_SECRET_KEY=sk_test_xxx
```
3. Create products/prices in Flowglad dashboard
4. Run the app and test:
- Sign up → user becomes Flowglad customer
- Make AI requests → usage is tracked
- Hit limit → upgrade prompt shown
- Subscribe → checkout flow
- More requests → usage deducted from subscription
## Debugging
### Check if Flowglad is configured
```bash
curl 'http://localhost:3000/api/flowglad/billing' -H 'Cookie: ...'
# Error: "Flowglad not configured" → FLOWGLAD_SECRET_KEY not set
# Error: "Unauthorized" → User not logged in
# Success: Returns billing data with subscriptions, usage, etc.
```
### Check usage balance
```typescript
const billing = await flowglad(userId).getBilling()
const usage = billing.checkUsageBalance("ai_requests")
console.log("Available:", usage?.availableBalance)
```
### Record test usage
```bash
curl -X POST 'http://localhost:3000/api/usage-events' \
-H 'Content-Type: application/json' \
-H 'Cookie: ...' \
-d '{"usageMeterSlug": "ai_requests", "amount": 1}'
```
## Common Issues
### "Customer not found"
The user doesn't have a Flowglad customer record yet. This is created automatically when:
- User first accesses billing endpoints
- `getCustomerDetails` is called
### "No active subscription found"
User needs to subscribe to a plan. Show them the pricing page.
### "Usage price not found for meter"
The `ai_requests` meter exists but no usage price is attached to it in your pricing model. Add a usage price in Flowglad dashboard.

87
docs/production-setup.md Normal file
View File

@@ -0,0 +1,87 @@
# Production Setup (Cloudflare Workers)
This app deploys two Workers:
- `@linsa/web`: SSR app + API routes + Electric proxy
- `@linsa/worker`: standalone API bound in `@linsa/web` as `WORKER_RPC`
The stack expects Postgres over HTTP (Neon or a Postgres behind a Neon HTTP proxy) and ElectricSQL for sync.
## Prerequisites
- Cloudflare account with Workers enabled and `wrangler` logged in
- Production Postgres reachable over HTTP (Neon recommended, or a Postgres behind a Neon HTTP proxy)
- Electric Cloud account (or self-hosted Electric instance)
- Domain for cookies (`APP_BASE_URL`)
- OpenRouter API key (optional, for AI responses)
## 1) Database (Postgres)
- Create a Neon database (recommended) and copy the `postgresql://...neon.tech/...` connection string.
- Ensure logical replication (`wal_level=logical`) is enabled. Neon enables it by default; for other Postgres, enable it and allow replication access.
- Electric needs replication on these tables: `users`, `sessions`, `accounts`, `verifications`, `chat_threads`, `chat_messages`.
- If not using Neon, expose your Postgres through a Neon HTTP proxy; Cloudflare Workers cannot talk to raw TCP Postgres.
## 2) Electric Cloud / Self-hosted Electric
1. Sign up at [Electric Cloud](https://electric-sql.com/product/cloud) or point to your own Electric instance.
2. Create a source connected to your Postgres.
3. Note:
- `ELECTRIC_URL` Electric endpoint (shape API)
- `ELECTRIC_SOURCE_ID` and `ELECTRIC_SOURCE_SECRET` only if Electric Cloud auth is enabled
## 3) Cloudflare Worker configuration
- Optional: rename the `name` fields in `packages/worker/wrangler.jsonc` and `packages/web/wrangler.jsonc`. If you rename the worker, also update `services[0].service` in `packages/web/wrangler.jsonc` so the `WORKER_RPC` binding still points to the right script.
- Set secrets for `@linsa/web` (run inside `packages/web`):
```bash
cd packages/web
wrangler secret put DATABASE_URL # Neon/Postgres HTTP URL
wrangler secret put BETTER_AUTH_SECRET # generate with: openssl rand -hex 32
wrangler secret put ELECTRIC_URL # e.g., https://your-electric-host/v1/shape
wrangler secret put ELECTRIC_SOURCE_ID # only if Electric Cloud auth is on
wrangler secret put ELECTRIC_SOURCE_SECRET # only if Electric Cloud auth is on
wrangler secret put OPENROUTER_API_KEY # optional, for real AI replies
```
- Set non-secret vars:
```bash
wrangler vars set APP_BASE_URL https://your-domain.com # exact origin for cookies
wrangler vars set OPENROUTER_MODEL anthropic/claude-sonnet-4 # optional override
```
- Prefer `pnpm` wrappers if you want to stay in the monorepo context:
```bash
pnpm --filter @linsa/web exec wrangler whoami
```
You can also run `f deploy-setup` from the repo root for an interactive secret setup.
## 4) Deploy
From the repo root:
```bash
pnpm deploy:worker # deploy @linsa/worker
pnpm deploy:web # build + deploy @linsa/web
# or
pnpm deploy # deploy both
# Flow shortcut
f deploy
```
## 5) Verify
1. Open your production URL and confirm auth flows (sign up / sign in).
2. Create a chat thread/message; check Electric sync across two tabs.
3. Hit `/api/chat/ai` to confirm OpenRouter responses (or expect the demo reply when no key is set).
4. Tail logs if needed: `pnpm --filter @linsa/web exec wrangler tail`.
## Environment Variables
| Variable | Required | Description |
|----------|----------|-------------|
| `DATABASE_URL` | Yes | Postgres URL reachable over HTTP (Neon or Postgres behind Neon proxy) |
| `BETTER_AUTH_SECRET` | Yes | Secret for auth/session signing (32+ chars) |
| `ELECTRIC_URL` | Yes | Electric Cloud/self-host URL (shape endpoint) |
| `ELECTRIC_SOURCE_ID` | Conditional | Needed when Electric Cloud auth is enabled |
| `ELECTRIC_SOURCE_SECRET` | Conditional | Needed with `ELECTRIC_SOURCE_ID` |
| `APP_BASE_URL` | Yes | Production origin for cookies/CORS (e.g., https://app.example.com) |
| `OPENROUTER_API_KEY` | No | Enables real AI responses |
| `OPENROUTER_MODEL` | No | AI model id (default: `anthropic/claude-sonnet-4`) |
## Troubleshooting
- Auth: `APP_BASE_URL` must match your deployed origin; rotate `BETTER_AUTH_SECRET` only when you intend to invalidate sessions.
- Database: use an HTTP-capable connection string; ensure logical replication is on and tables exist; allow Cloudflare egress to the DB host.
- Electric: confirm the source is healthy and credentials are set; verify `where` filters in logs if shapes look empty.
- AI chat: set `OPENROUTER_API_KEY`; without it youll see the demo reply instead of model output.