Skip to content

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.

ColumnTypeDescription
idUUIDFK to auth.users
emailTEXTUser email
full_nameTEXTDisplay name
roleTEXT’user’ or β€˜admin’
stripe_customer_idTEXTStripe Customer ID
created_atTIMESTAMPTZAccount creation

credit_balances

Current credit balance per user.

ColumnTypeDescription
user_idUUIDPK, FK to auth.users
balanceINTEGERCurrent credits (β‰₯0)
updated_atTIMESTAMPTZLast update

credit_transactions

Audit log of all credit changes.

ColumnTypeDescription
idUUIDPK
user_idUUIDFK to auth.users
typeTEXTpurchase, usage, refund, grant, expiration, adjustment
amountINTEGER+/- credits
balance_afterINTEGERBalance after transaction
descriptionTEXTHuman-readable description
metadataJSONBStripe IDs, admin info, etc.
file_idTEXTFor usage: which file
page_numberINTEGERFor usage: which page
expires_atTIMESTAMPTZOptional expiration
created_byUUIDAdmin who made the change
created_atTIMESTAMPTZTransaction time

credit_packages

Purchasable credit bundles.

ColumnTypeDescription
idUUIDPK
nameTEXTPackage name
descriptionTEXTDescription
creditsINTEGERCredits included
price_centsINTEGERPrice in cents
currencyTEXTDefault β€˜usd’
stripe_price_idTEXTFor Stripe Checkout
activeBOOLEANAvailable for purchase
featuredBOOLEANHighlight in UI
sort_orderINTEGERDisplay order

customer_pricing

Per-user pricing overrides.

ColumnTypeDescription
user_idUUIDPK, FK to auth.users
price_per_credit_centsINTEGERCustom per-credit price
discount_percentINTEGERPercentage discount (0-100)
notesTEXTAdmin notes
created_byUUIDAdmin 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

  1. User selects package β†’ POST /api/credits/checkout
  2. Redirect to Stripe Checkout
  3. Payment succeeds β†’ Stripe webhook fires
  4. Webhook adds credits via add_credits()
  5. User redirected to /settings?tab=billing&success=true

Conversion Integration

Credits are checked and deducted in the conversion flow:

// Before conversion starts
const creditCheck = await checkCredits(env, userId, estimatedPages);
if (!creditCheck.hasCredits) {
return error(c, 'INSUFFICIENT_CREDITS', '...', 402);
}
// After successful conversion
await 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

Terminal window
# Supabase
SUPABASE_URL=https://xxx.supabase.co
SUPABASE_SERVICE_ROLE_KEY=eyJ...
SUPABASE_JWT_SECRET=...
# Stripe
STRIPE_SECRET_KEY=sk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...
STRIPE_PUBLISHABLE_KEY=pk_live_... # for frontend if needed

Deployment Checklist

  1. Apply migration:

    supabase/migrations/20250213_001_credits_system.sql
    supabase db push
  2. Set Cloudflare secrets:

    Terminal window
    wrangler secret put SUPABASE_URL
    wrangler secret put SUPABASE_SERVICE_ROLE_KEY
    wrangler secret put STRIPE_SECRET_KEY
    wrangler secret put STRIPE_WEBHOOK_SECRET
  3. Create Stripe webhook:

    • URL: https://api.yourapp.com/api/stripe/webhook
    • Events: checkout.session.completed
  4. Make yourself admin:

    UPDATE profiles SET role = 'admin' WHERE email = 'you@example.com';
  5. Create Stripe Products/Prices (optional):

    • If using Stripe dashboard prices, update stripe_price_id in packages
    • Otherwise, checkout creates ad-hoc prices from package data

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)