Credits System
The credits system enables usage-based billing for PDF conversions. Users purchase credits, and 1 credit = 1 page converted.
Overview
- Credit = Page: Each page processed consumes 1 credit
- No Expiration by Default: Credits donβt expire unless manually set
- Custom Pricing: Per-user discount or pricing overrides
- Stripe Integration: One-time credit pack purchases (subscriptions planned)
Architecture
βββββββββββββββββββ βββββββββββββββββββ ββββββββββββββββββββ Frontend ββββββΆβ API Worker ββββββΆβ Supabase ββ /settings β β /api/credits β β PostgreSQL ββ /admin β β /api/stripe β β ββββββββββββββββββββ βββββββββββββββββββ βββββββββββββββββββ β βΌ βββββββββββββββββββ β Stripe β β Checkout β βββββββββββββββββββDatabase Schema
Tables
profiles
Extends Supabase auth.users with app-specific data.
| Column | Type | Description |
|---|---|---|
| id | UUID | FK to auth.users |
| TEXT | User email | |
| full_name | TEXT | Display name |
| role | TEXT | βuserβ or βadminβ |
| stripe_customer_id | TEXT | Stripe Customer ID |
| created_at | TIMESTAMPTZ | Account creation |
credit_balances
Current credit balance per user.
| Column | Type | Description |
|---|---|---|
| user_id | UUID | PK, FK to auth.users |
| balance | INTEGER | Current credits (β₯0) |
| updated_at | TIMESTAMPTZ | Last update |
credit_transactions
Audit log of all credit changes.
| Column | Type | Description |
|---|---|---|
| id | UUID | PK |
| user_id | UUID | FK to auth.users |
| type | TEXT | purchase, usage, refund, grant, expiration, adjustment |
| amount | INTEGER | +/- credits |
| balance_after | INTEGER | Balance after transaction |
| description | TEXT | Human-readable description |
| metadata | JSONB | Stripe IDs, admin info, etc. |
| file_id | TEXT | For usage: which file |
| page_number | INTEGER | For usage: which page |
| expires_at | TIMESTAMPTZ | Optional expiration |
| created_by | UUID | Admin who made the change |
| created_at | TIMESTAMPTZ | Transaction time |
credit_packages
Purchasable credit bundles.
| Column | Type | Description |
|---|---|---|
| id | UUID | PK |
| name | TEXT | Package name |
| description | TEXT | Description |
| credits | INTEGER | Credits included |
| price_cents | INTEGER | Price in cents |
| currency | TEXT | Default βusdβ |
| stripe_price_id | TEXT | For Stripe Checkout |
| active | BOOLEAN | Available for purchase |
| featured | BOOLEAN | Highlight in UI |
| sort_order | INTEGER | Display order |
customer_pricing
Per-user pricing overrides.
| Column | Type | Description |
|---|---|---|
| user_id | UUID | PK, FK to auth.users |
| price_per_credit_cents | INTEGER | Custom per-credit price |
| discount_percent | INTEGER | Percentage discount (0-100) |
| notes | TEXT | Admin notes |
| created_by | UUID | Admin who set it |
Database Functions
deduct_credits(user_id, amount, description, file_id, page_number)
Atomically deduct credits. Returns false if insufficient balance.
add_credits(user_id, amount, type, description, metadata, expires_at, created_by)
Add credits and log transaction. Returns new balance.
is_admin(user_id)
Check if user has admin role.
API Endpoints
User Endpoints
GET /api/credits
Get current balance and recent transactions.
{ "success": true, "data": { "balance": 150, "lastUpdated": "2025-02-13T10:00:00Z", "transactions": [ { "id": "tx_123", "type": "purchase", "amount": 200, "balance_after": 200, "description": "Purchased 200 credits", "created_at": "2025-02-13T09:00:00Z" } ] }}GET /api/credits/packages
List available packages (with custom pricing applied).
{ "success": true, "data": { "packages": [ { "id": "pkg_123", "name": "Standard", "description": "Most popular", "credits": 200, "price_cents": 2999, "originalPrice": 2999, "hasDiscount": false, "featured": true } ] }}POST /api/credits/checkout
Create Stripe Checkout session.
// Request{ "packageId": "pkg_123" }
// Response{ "success": true, "data": { "checkoutUrl": "https://checkout.stripe.com/...", "sessionId": "cs_123" }}GET /api/credits/history
Paginated transaction history.
Admin Endpoints
All admin endpoints require role = 'admin' in profiles.
GET /api/admin/stats
System overview.
{ "success": true, "data": { "totalUsers": 1250, "totalCreditsInCirculation": 45000, "creditsPurchasedToday": 2500, "creditsUsedToday": 1800 }}GET /api/admin/users
List users with balances. Supports ?search= and pagination.
GET /api/admin/users/:userId
User details with transaction history and custom pricing.
PATCH /api/admin/users/:userId
Update user role.
{ "role": "admin" }POST /api/admin/credits/grant
Give free credits.
{ "userId": "user_123", "amount": 50, "reason": "Beta tester bonus", "expiresAt": "2025-12-31T23:59:59Z" // optional}POST /api/admin/credits/refund
Refund used credits.
{ "userId": "user_123", "amount": 10, "reason": "Conversion quality issue"}POST /api/admin/credits/adjust
Arbitrary adjustment (requires reason).
{ "userId": "user_123", "amount": -5, // can be negative "reason": "Duplicate charge correction"}POST /api/admin/credits/expire
Manually expire credits.
{ "userId": "user_123", "amount": 100, "reason": "Account cleanup"}PUT /api/admin/users/:userId/pricing
Set custom pricing.
{ "discountPercent": 20, // OR "pricePerCreditCents": 10, "notes": "Enterprise contract"}DELETE /api/admin/users/:userId/pricing
Remove custom pricing.
GET /api/admin/packages
List all packages (including inactive).
POST /api/admin/packages
Create package.
PATCH /api/admin/packages/:packageId
Update package.
Stripe Integration
Webhook Events
Configure webhook at /api/stripe/webhook for:
checkout.session.completed- Add credits after payment
Checkout Flow
- User selects package β
POST /api/credits/checkout - Redirect to Stripe Checkout
- Payment succeeds β Stripe webhook fires
- Webhook adds credits via
add_credits() - User redirected to
/settings?tab=billing&success=true
Conversion Integration
Credits are checked and deducted in the conversion flow:
// Before conversion startsconst creditCheck = await checkCredits(env, userId, estimatedPages);if (!creditCheck.hasCredits) { return error(c, 'INSUFFICIENT_CREDITS', '...', 402);}
// After successful conversionawait deductCredits(env, userId, pagesProcessed, fileId, description);Page Counting
- PDF: Counted using pdf-lib before processing
- Images: 1 credit each
- DOCX: 1 credit (page count not reliable pre-conversion)
Frontend
User: /settings β Billing Tab
- Current balance display
- Package selection with βBuy Nowβ buttons
- Transaction history table
- Success/canceled messages from Stripe redirect
Admin: /admin
- Overview: Stats cards (users, credits in circulation, daily activity)
- Users: Search, role management, credit operations
- Packages: Enable/disable, featured flag, pricing
Environment Variables
# SupabaseSUPABASE_URL=https://xxx.supabase.coSUPABASE_SERVICE_ROLE_KEY=eyJ...SUPABASE_JWT_SECRET=...
# StripeSTRIPE_SECRET_KEY=sk_live_...STRIPE_WEBHOOK_SECRET=whsec_...STRIPE_PUBLISHABLE_KEY=pk_live_... # for frontend if neededDeployment Checklist
-
Apply migration:
supabase/migrations/20250213_001_credits_system.sql supabase db push -
Set Cloudflare secrets:
Terminal window wrangler secret put SUPABASE_URLwrangler secret put SUPABASE_SERVICE_ROLE_KEYwrangler secret put STRIPE_SECRET_KEYwrangler secret put STRIPE_WEBHOOK_SECRET -
Create Stripe webhook:
- URL:
https://api.yourapp.com/api/stripe/webhook - Events:
checkout.session.completed
- URL:
-
Make yourself admin:
-
Create Stripe Products/Prices (optional):
- If using Stripe dashboard prices, update
stripe_price_idin packages - Otherwise, checkout creates ad-hoc prices from package data
- If using Stripe dashboard prices, update
Future Enhancements
- Subscription tiers (monthly credit allowance)
- Credit expiration cron job
- Usage analytics dashboard
- Bulk credit operations
- API key authentication for programmatic access
- Credit transfer between accounts
- Refund to Stripe (not just credit refund)