Skip to main content

04 — Billing & Credit Infrastructure

Ring: 1 (Launch Blocker) Dependency: R1-1 (Auth — can’t bill without knowing who the user is) Handbook: Ch. 68-84 (pricing & credit economy)

Problem

  • No payment infrastructure exists.
  • No usage limits — everyone can use everything without restriction.
  • AI costs cannot be controlled (per user).
  • Revenue mechanism is zero.

Decisions

D1: 4 Plans (2 active + 2 planned)

PlanPriceTargetStatus
Free$0Trial — WOW moment (discovery open, contact gated)To be implemented (launch)
Pro49/mo(49/mo (39 annual)Individual exporterTo be implemented (launch)
Team149/mo(149/mo (119 annual)Export teamsDefined in DB, UI/billing later
EnterpriseCustomLarge companies, multi-orgDefined in DB, UI/billing later
Only Free + Pro at launch. Team/Enterprise plan records are created in DB (no UI, manual upgrade).

D2: Payment Model — Hybrid (Subscription + Credit)

SUBSCRIPTION (fixed monthly):
  → Plan limits included (X discoveries/mo, Y contact reveals/mo)
  → Managed via payment provider subscription (Lemon Squeezy)

CREDIT PACK (pay-as-you-go):
  → For users who exceed plan limits
  → Payment provider one-time transaction
  → Added to org wallet
  → Decreases as used
PackCreditsPrice
Small50~$15
Medium200~$50
Large1000~$200
Prices to be finalized post-beta. Only the infrastructure is built now.

D3: Plan Limits

FeatureFreeProTeamEnterprise
Discoveries/mo550200Unlimited
Companies/search10252550
Contact reveals/mo0100500Unlimited
Saved leads10UnlimitedUnlimitedUnlimited
Org creation111Unlimited
Member count1110Unlimited
Batch operations
API access
Auto-headhunt
AI company analysis
Follow-up reminders
Shared leads workspace
Multi-org
These limits are stored in the export_ai_plan_limits table (NOT hardcoded).
Monthly Usage Cycle: Based on billing period (relative to the user’s subscription date). Example: subscribed March 15 → March 15 - April 14 is 1 period. usage_monthly table uses a period_start DATE column (matches the subscription’s current_period_start). checkUsage() reads the row for the active billing period; if none exists, assumes 0. pg_cron reset is UNNECESSARY — a new row is automatically created for each new period, previous periods remain as archive. Free plan users: registration date is used as the billing anchor.

D4: Credit Costs

OperationCreditsHandbook Ref
Discovery search1Ch. 76
Contact reveal1Ch. 76
Deep company analysis (enrichment)2Ch. 76
Market intelligence report3Ch. 76
Batch operation (per company)0.5
Usage within plan limits does not consume credits. Credits are only used when limits are exceeded.

D5: Beta Period

Beta (until launch):
  → All invited users are on the FREE plan
  → Free limits: 5 discoveries/mo, 0 reveals (contacts fully gated), 10 leads
  → Cost display is ACTIVE (users see their consumption)
  → Payment provider is INACTIVE (no payments)
  → "Beta" badge visible in UI
  → Feedback collection active
  → Bonus credits can be given to beta users (for reveal testing)

Launch:
  → Activate payment provider (Lemon Squeezy)
  → Enable Pro plan purchase ($49/mo)
  → Enable credit pack purchase

D6: Payment Provider Integration (Provider-Agnostic)

Recommended Provider: Lemon Squeezy
  • MoR (Merchant of Record): MoR service built on Stripe infrastructure. Tax/invoicing automatic, global sales from Turkey.
  • Direct registration from Turkey: Lemon Squeezy accepts Turkey-based companies.
  • Subscription + one-time support: Both monthly plan and credit pack purchases supported.
Alternatives:
  • Paddle: Alternative MoR option (similar features, larger ecosystem).
  • US LLC + Stripe: Post-scale (if a US LLC is established). Stripe is not a MoR — tax/invoice responsibility falls on us.
Eliminated Options:
  • PayPal: Payment receiving has been closed in Turkey since 2016. Not usable.
  • iyzico: Not suitable for global B2B — TRY-based, not a MoR, limited international card support.
Note: Decided: Lemon Squeezy. Provider-agnostic interfaces will be used in the codebase to keep switching cost low.
Provider Entities (Lemon Squeezy example):
  → Customer = export_ai_organizations (1:1)
  → Subscription = org's active plan
  → Order = credit pack purchase (one-time)
  → Webhook = subscription status, payment success/failure

Flow:
  Owner clicks "Upgrade to Pro"
  → Provider Checkout is created (Lemon Squeezy Checkout)
  → User pays via provider
  → Webhook → update subscription record
  → Plan limits activate instantly

Data Model

New Tables

-- Plan definitions (managed by admin)
CREATE TABLE export_ai_plan_limits (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  plan TEXT NOT NULL UNIQUE,  -- 'free' | 'pro' | 'team' | 'enterprise'
  limits JSONB NOT NULL,
  -- {
  --   "discoveries_per_month": 3,
  --   "companies_per_search": 10,
  --   "contact_reveals_per_month": 1,
  --   "saved_leads": 20,
  --   "max_members": 1,
  --   "max_orgs": 1,
  --   "batch_operations": false,
  --   "api_access": false,
  --   "auto_headhunt": false,
  --   "ai_analysis": false,
  --   "shared_workspace": false,
  --   "multi_org": false
  -- }
  created_at TIMESTAMPTZ DEFAULT now(),
  updated_at TIMESTAMPTZ DEFAULT now()
);

-- Org credit wallet
CREATE TABLE export_ai_credit_wallets (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  organization_id UUID NOT NULL UNIQUE REFERENCES export_ai_organizations(id),
  balance INT NOT NULL DEFAULT 0,     -- current credits
  lifetime_earned INT DEFAULT 0,       -- total earned
  lifetime_spent INT DEFAULT 0,        -- total spent
  created_at TIMESTAMPTZ DEFAULT now(),
  updated_at TIMESTAMPTZ DEFAULT now()
);

-- Credit transactions
CREATE TABLE export_ai_credit_transactions (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  organization_id UUID NOT NULL REFERENCES export_ai_organizations(id),
  amount INT NOT NULL,                 -- positive: addition, negative: consumption
  balance_after INT NOT NULL,          -- balance after transaction
  transaction_type TEXT NOT NULL,      -- 'purchase' | 'consume' | 'refund' | 'bonus'
  description TEXT,                    -- "Discovery search: Germany + chemicals"
  reference_type TEXT,                 -- 'discovery' | 'headhunt' | 'enrichment' | 'credit_pack'
  reference_id UUID,                   -- ID of the related record
  created_by UUID REFERENCES auth.users(id),
  created_at TIMESTAMPTZ DEFAULT now()
);

-- Monthly usage counters (billing period based)
CREATE TABLE export_ai_usage_monthly (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  organization_id UUID NOT NULL REFERENCES export_ai_organizations(id),
  period_start DATE NOT NULL,          -- billing period start (subscription.current_period_start)
  period_end DATE NOT NULL,            -- billing period end (subscription.current_period_end)
  discoveries INT DEFAULT 0,
  contact_reveals INT DEFAULT 0,
  enrichments INT DEFAULT 0,
  batch_operations INT DEFAULT 0,
  created_at TIMESTAMPTZ DEFAULT now(),
  UNIQUE(organization_id, period_start)
);

-- Payment provider subscription record (provider-agnostic)
CREATE TABLE export_ai_subscriptions (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  organization_id UUID NOT NULL UNIQUE REFERENCES export_ai_organizations(id),
  provider TEXT NOT NULL DEFAULT 'lemonsqueezy',  -- 'lemonsqueezy' | 'paddle' | 'stripe'
  provider_subscription_id TEXT,
  provider_customer_id TEXT,
  plan TEXT NOT NULL DEFAULT 'free',
  status TEXT NOT NULL DEFAULT 'active',  -- 'active' | 'past_due' | 'canceled' | 'trialing'
  current_period_start TIMESTAMPTZ,
  current_period_end TIMESTAMPTZ,
  cancel_at_period_end BOOLEAN DEFAULT false,
  created_at TIMESTAMPTZ DEFAULT now(),
  updated_at TIMESTAMPTZ DEFAULT now()
);

-- Webhook idempotency table (duplicate event prevention)
CREATE TABLE export_ai_webhook_events (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  provider TEXT NOT NULL,                  -- 'lemonsqueezy' | 'paddle' | 'stripe'
  provider_event_id TEXT NOT NULL UNIQUE,  -- event ID given by the provider
  event_type TEXT NOT NULL,                -- 'subscription_created' | 'order_created' etc.
  payload JSONB,                           -- raw event data (for debugging)
  processed_at TIMESTAMPTZ DEFAULT now(),
  created_at TIMESTAMPTZ DEFAULT now()
);

-- RLS on all tables
ALTER TABLE export_ai_plan_limits ENABLE ROW LEVEL SECURITY;
ALTER TABLE export_ai_credit_wallets ENABLE ROW LEVEL SECURITY;
ALTER TABLE export_ai_credit_transactions ENABLE ROW LEVEL SECURITY;
ALTER TABLE export_ai_usage_monthly ENABLE ROW LEVEL SECURITY;
ALTER TABLE export_ai_subscriptions ENABLE ROW LEVEL SECURITY;
ALTER TABLE export_ai_webhook_events ENABLE ROW LEVEL SECURITY;

-- Indexes
CREATE INDEX idx_credit_tx_org ON export_ai_credit_transactions(organization_id, created_at DESC);
CREATE INDEX idx_usage_monthly_org ON export_ai_usage_monthly(organization_id, period_start DESC);
CREATE UNIQUE INDEX idx_webhook_events_provider_id ON export_ai_webhook_events(provider_event_id);

Architecture

Usage Check Flow

User clicks "Discovery"
  → Frontend: GET /api/usage/check?action=discovery
    → Backend:
      1. Get org's plan + billing period (subscriptions table → current_period_start/end)
      2. Get plan limits (plan_limits table)
      3. Get active billing period usage (usage_monthly WHERE period_start = current_period_start)
      4. Check subscription status (past_due/canceled → block)
      5. Compare:
         - Within limit? → { allowed: true, remaining: 47, planLimit: 50 }
         - Limit exceeded but has credits? → { allowed: true, creditCost: 1, creditBalance: 23 }
         - No credits either? → { allowed: false, reason: "limit_exceeded", upgradeUrl: "/pricing" }
  → Frontend: Show confirmation dialog (05-usage-confirmation)
  → User confirms → operation starts
  → On completion: usage_monthly++ and/or credit_transactions insert

Payment Provider Webhook Flow

Provider Event → POST /api/webhooks/payment
  → Generic event type:
    - subscription.activated    → create/update subscription
    - subscription.updated      → reflect plan/period change
    - subscription.canceled     → status: canceled
    - subscription.past_due     → status: past_due
    - transaction.completed     → add credits to wallet for credit pack
    - transaction.payment_failed → log failed payment
Provider Event Mapping:
Generic EventLemon Squeezy EventPaddle EventStripe Event
subscription.activatedsubscription_createdsubscription.activatedcheckout.session.completed
subscription.updatedsubscription_updatedsubscription.updatedcustomer.subscription.updated
subscription.canceledsubscription_cancelledsubscription.canceledcustomer.subscription.deleted
subscription.past_duesubscription_payment_failedsubscription.past_dueinvoice.payment_failed
transaction.completedorder_createdtransaction.completedpayment_intent.succeeded
transaction.payment_failedorder_refundedtransaction.payment_failedinvoice.payment_failed
Idempotency: Every webhook event is saved to the export_ai_webhook_events table. If the same provider_event_id arrives again, it is not processed (duplicate protection).

Security Architecture

5-Layer Security

LayerContentDetail
1. NetworkWebhook endpoint IP whitelistOnly the provider’s IP ranges are accepted (Lemon Squeezy/Paddle/Stripe IP list)
2. APIWebhook signature verificationEvery incoming webhook’s signature is verified. Invalid signature → 401. Mandatory — cannot be skipped.
3. ApplicationIdempotency + event dedupprovider_event_id is UNIQUE in export_ai_webhook_events. Same event is not processed twice.
4. DatabaseRLS + INSERT-only audit trailcredit_transactions table is INSERT-only — UPDATE/DELETE PROHIBITED. Immutable audit trail.
5. ReconciliationPeriodic reconciliationWeekly cron: subscription statuses in the provider are compared with DB; inconsistencies generate alerts.

Atomic Credit Operations

Credit consumption flow:
  1. Acquire advisory lock → pg_advisory_xact_lock(org_id_hash)
  2. Read current balance (SELECT ... FOR UPDATE)
  3. Is balance >= consumption? → If no, abort
  4. credit_transactions INSERT (amount: -N, balance_after: new balance)
  5. credit_wallets UPDATE (balance -= N)
  6. Lock auto-releases (transaction commit)

→ Double-spend is PREVENTED: if 2 operations arrive simultaneously, one waits.

Provider-Agnostic Design Principle

lib/billing/
  providers/
    lemonsqueezy.ts → ILemonSqueezyProvider implements IPaymentProvider
    paddle.ts       → IPaddleProvider implements IPaymentProvider (alternative)
    stripe.ts       → IStripeProvider implements IPaymentProvider (future)
  payment-provider.ts → IPaymentProvider interface + factory
  webhook-handler.ts  → Generic handler, dispatches by provider
All provider-specific code is isolated under providers/. The business logic layer (check-usage.ts, consume-credit.ts) imports no provider SDK. A provider change only affects the adapter file.

Subscription Status and Access Control

StatusLoginNew AI OperationBehavior
activeNormal usage
trialingNormal usage (beta)
past_dueLogin OK, new AI ops BLOCKED. Warning banner: “Payment failed, please update your payment method.”
canceledRead-only mode. No new operations, existing data remains accessible.
pausedLogin OK, new AI ops BLOCKED. Subscription paused.
checkUsage() includes subscription status check. For statuses other than active and trialing, it returns \{ allowed: false, reason: 'subscription_inactive' \}.
Page Access Blocking (CRITICAL):
  • In past_due / canceled / paused status, access to AI pages (discovery, headhunt, batch operations) is BLOCKED via proxy.ts or layout-level guard.
  • Even if the user types the URL directly, the page does not open — they are redirected to a billing warning page.
  • Blocked pages: /discovery, /operations, headhunt modal
  • Not blocked: /companies, /contacts, /leads (read), /org/billing (update payment), /pricing
  • UI: Layout banner (non-dismissible) + disabled buttons + tooltip

Current Code Impact

New Files

FileContent
lib/billing/check-usage.tscheckUsage(orgId, action) → allowed/remaining/creditCost
lib/billing/consume-credit.tsconsumeCredit(orgId, amount, ref) → transaction record
lib/billing/plan-limits.tsgetPlanLimits(plan) → limits object
lib/billing/types.tsIUsageCheck, ICreditTransaction, ISubscription, IPlanLimits
app/api/usage/check/route.tsGET — for frontend confirmation dialog
lib/billing/providers/lemonsqueezy.tsLemon Squeezy provider adapter (ILemonSqueezyProvider)
lib/billing/payment-provider.tsIPaymentProvider interface + factory
lib/billing/webhook-handler.tsGeneric webhook handler, provider dispatch
app/api/webhooks/payment/route.tsPOST — Payment provider webhook handler
app/pricing/page.tsxPlan comparison + upgrade page
app/org/billing/page.tsxOrg billing management (current plan, credit balance, invoice history)

Files to Change

FileChange
All AI-calling routesPre-operation checkUsage(), post-operation incrementUsage() + consumeCredit()
components/Sidebar.tsxCredit balance display
export_ai_organizationsplan column already exists (added in 01-auth)

Future Decisions

FD-1: Real Pricing (Post-Beta)

Pro: 39or39 or 79? Team: 99or99 or 199? Decision based on beta user behavior.

FD-2: Annual Billing (Ring 3+)

Annual payment discount (20%). Separate Price/Plan object in the payment provider.

FD-3: Usage-Based Billing (Ring 4+)

Metered billing via provider for usage-based invoicing. For Enterprise plans.

FD-4: Credit Expiry (Ring 3+)

Should purchased credits expire? (e.g. 12 months). Anti-hoarding.

Atomic Tasks

#TaskRingSize
BILL-0export_ai_webhook_events table + RLS + unique index (idempotency)R1Migration
BILL-1export_ai_plan_limits table + 4 plan seed dataR1Migration
BILL-2export_ai_credit_wallets table + RLSR1Migration
BILL-3export_ai_credit_transactions table + RLS + index (INSERT-only policy)R1Migration
BILL-4export_ai_usage_monthly table + RLS + indexR1Migration
BILL-5export_ai_subscriptions table + RLS (provider-agnostic columns)R1Migration
BILL-6lib/billing/types.ts — all interfaces (including IPaymentProvider)R1Small
BILL-7lib/billing/plan-limits.ts — getPlanLimits()R1Small
BILL-8lib/billing/check-usage.ts — checkUsage() (plan limit + credit check)R1Medium
BILL-9lib/billing/consume-credit.ts — consumeCredit() + incrementUsage() (advisory lock)R1Medium
BILL-10GET /api/usage/check — for frontend confirmation dialogR1Small
BILL-11Usage check + consume integration in all AI routesR1Large
BILL-12Sidebar credit balance displayR1Small
BILL-13Payment Provider Checkout integration (Pro plan purchase — Lemon Squeezy)R1Large
BILL-14POST /api/webhooks/payment — provider-agnostic webhook handlerR1Medium
BILL-15/pricing page — plan comparisonR1Medium
BILL-16/org/billing page — billing managementR1Medium
BILL-17Credit pack purchase (payment provider one-time transaction)R1Medium
BILL-18Beta config — Free plan limits (5 discoveries, 0 reveals, 10 leads). Updated per R0-8 decision.R1Small
BILL-19bFree tier limit change — DB migration (reveal 0, leads 10) + UI updateR1Small
BILL-21Failed headhunt credit check — do NOT consume if no resultsR1Medium
BILL-22Headhunt hit rate analysis — measure 50-70% success rate, improvement planR2Medium