mirror of
https://github.com/linsa-io/linsa.git
synced 2026-01-11 22:40:32 +01:00
.
This commit is contained in:
330
docs/how-flowglad-is-integrated.md
Normal file
330
docs/how-flowglad-is-integrated.md
Normal 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
87
docs/production-setup.md
Normal 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 you’ll see the demo reply instead of model output.
|
||||
Reference in New Issue
Block a user