Backend Payments & Usage Metering

Backend Payments & Usage Metering

Architecture Overview

Tenant signs up
  │
  ├─► Stripe Customer created (handle_stripe_signup task)
  ├─► Subscription created (Starter / Pro)
  │
  ├─► CreditLedgerEntry created (grant for the billing period)
  │
  └─► Usage occurs (AI chat, AEO prompt run, etc.)
        │
        ├─► InternalMeterEvent.objects.record_event()
        │     ├─► Looks up Meter for the event_name
        │     ├─► Computes credit_cost from Meter pricing strategy
        │     ├─► Saves InternalMeterEvent row with credit_cost
        │     └─► If BILLING_USAGE_MODE != "internal" → enqueues Stripe sync
        │
        └─► Credit gate checks: sum(ledger grants) > sum(credit_cost)
              via CreditLedgerEntry vs InternalMeterEvent

Key Files

Purpose File
Settings backend/config/settings.py (STRIPE_*, DJSTRIPE_*, BILLING_*)
Plan definitions backend/apps/payments/constants.py
Meters + Events backend/apps/payments/models/metering.py
Credit Ledger + View backend/apps/payments/models/ledger.py
Subscription mixin backend/apps/payments/models/mixins.py (SubscriptionPaymentsMixin)
Billing API backend/apps/payments/api/billing.py (tag billing)
Admin backend/apps/payments/admin.py
Stripe sync task backend/apps/payments/tasks.py
Credit gate backend/apps/common/decorators.py
Checkout service backend/apps/payments/services/payment_session.py

Subscriptions

Plans

Two products configured in Stripe, mapped via env vars:

Plan Product ID env var Monthly Price ID Yearly Price ID
Starter STRIPE_STARTER_PRODUCT_ID STRIPE_STARTER_MONTHLY_PRICE_ID STRIPE_STARTER_YEARLY_PRICE_ID
Pro STRIPE_PRO_PRODUCT_ID STRIPE_PRO_MONTHLY_PRICE_ID STRIPE_PRO_YEARLY_PRICE_ID

Plan metadata (features list) is defined in backend/apps/payments/constants.py.

Entitlement

Tenant entitlement is computed in Tenant.subscription_summary() (mixins.py):

  • Has active Stripe subscription, OR
  • tenant.state == active (seed/demo flows)

The can_create_project flag is derived from this.

Checkout Flow (Embedded)

Frontend                           Backend                          Stripe
   │                                  │                               │
   ├─ POST /billing/checkout-session/ ─►                              │
   │                                  ├─ create_checkout_session() ──►│
   │  ◄── { session_id, client_secret } ──┤                          │
   │                                  │                               │
   │  (user completes in Stripe UI)   │                               │
   │                                  │                               │
   ├─ GET /billing/checkout-session-status/?session_id=... ──►        │
   │                                  ├─ confirm_checkout_session() ─►│
   │  ◄── { status, plan } ──────────┤                               │

Endpoints:

  • POST /api/v1/billing/checkout-session/ — creates Stripe embedded checkout
  • GET /api/v1/billing/checkout-session-status/?session_id=... — confirms outcome
  • POST /api/v1/billing/portal-session/ — Stripe customer portal
  • POST /api/v1/billing/cancel-checkout/ — cancels pending checkout

Webhooks

Stripe webhook endpoint: POST /api/v1/billing/webhooks/stripe/{uuid}/

Uses dj-stripe's ProcessWebhookView. Local testing with Stripe CLI:

docker compose --profile stripe up stripe-cli

Credit Ledger (Grants & Balance)

The credit system works as a double-entry ledger:

  • Debits (additions): CreditLedgerEntry rows — subscription grants, credit purchases, admin grants, refunds
  • Credits (deductions): InternalMeterEvent.credit_cost — computed from Meter pricing when usage events are recorded
  • Balance: a Postgres VIEW (payments_credit_balance_monthly) that joins both sides per tenant per month

CreditLedgerEntry Model

Lives in backend/apps/payments/models/ledger.py.

Field Type Purpose
entry_type CharField (choices) subscription_grant, purchase, admin_grant, adjustment, refund, expiry
amount Decimal(12,6) Positive = grant, negative = expiry/clawback
description TextField Human-readable note (e.g. "Pro plan monthly grant")
effective_at DateTime When the credits become available
expires_at DateTime (nullable) When unused credits expire (null = never)
reference_id CharField External reference (e.g. Stripe invoice ID)
tenant FK → Tenant Tenant-scoped via TenantModelMixin

Creating Grants

Use the manager method:

from apps.payments.models import CreditLedgerEntry

CreditLedgerEntry.objects.grant(
    amount=1000,
    entry_type="subscription_grant",
    tenant=tenant,
    description="Pro plan monthly grant",
    reference_id="inv_abc123",
)

For negative adjustments (expiry, clawback):

CreditLedgerEntry.objects.grant(
    amount=-200,
    entry_type="expiry",
    tenant=tenant,
    description="Unused credits expired",
)

Grants can also be created via Django Admin (/admin/payments/creditledgerentry/).

Postgres Balance View

payments_credit_balance_monthly is a Postgres VIEW (not a materialized view) created in migration 0007. It provides one row per (tenant, month) with:

Column Meaning
tenant_id Tenant FK
month First day of the month (timestamptz)
total_granted SUM of CreditLedgerEntry.amount effective that month
total_consumed SUM of InternalMeterEvent.credit_cost occurred that month
net_balance total_granted - total_consumed

The VIEW uses a UNION of both tables' (tenant_id, month) pairs, so months with only grants or only consumption still appear.

Django model: CreditBalanceMonthly (unmanaged, managed = False).

from apps.payments.models import CreditBalanceMonthly

# Current month's balance for a tenant
row = CreditBalanceMonthly.objects.filter(
    tenant=tenant,
    month__month=now.month,
    month__year=now.year,
).first()

if row:
    print(f"Granted: {row.total_granted}")
    print(f"Consumed: {row.total_consumed}")
    print(f"Remaining: {row.net_balance}")

How get_max_credits() Works

Tenant.get_max_credits() no longer returns a hardcoded value. It queries the ledger:

CreditLedgerEntry.objects.filter(
    tenant=self,
    effective_at__month=now.month,
    effective_at__year=now.year,
).aggregate(total=Sum("amount"))["total"] or Decimal("0")

Returns 0 if no grants exist for the current month.

How get_monthly_credits_usage() Works

Tenant.get_monthly_credits_usage() now queries the CreditBalanceMonthly Postgres view, returning up to 12 months of history with total (granted), usage (consumed), and remaining (net balance) per month.


Usage Metering

Two Models

Meter — admin-configurable pricing rule (one per event type).

InternalMeterEvent — every recorded usage event, with a computed credit_cost.

Both live in backend/apps/payments/models/metering.py.

How Events Are Recorded

Feature code calls InternalMeterEvent.objects.record_event():

from apps.payments.models import InternalMeterEvent

InternalMeterEvent.objects.record_event(
    event_name="aeo_prompt_run",
    value=total_tokens,               # raw usage (int, required by Stripe)
    dimensions={                       # breakdown data, includes provider costs
        "total_cost": str(total_cost), # raw provider spend
        "source_costs": [...],         # per-source breakdown
        "project_id": "...",
    },
)

record_event() does the following:

  1. Looks up the active Meter for the event_name (cached 5 min)
  2. Calls meter.compute_credit_cost(value, dimensions) to get the tenant-facing charge
  3. Creates the InternalMeterEvent row with credit_cost and a FK to the Meter
  4. If BILLING_USAGE_MODE != "internal", enqueues sync_meter_event_to_stripe Celery task

Current Event Types

event_name Recorded in dimensions includes
aeo_prompt_run backend/apps/aeo/tasks.py total_cost, input_tokens, output_tokens, total_tokens, source_costs (per-platform), project_id, prompt_id, run_id, execution_time_ms
ai_tokens backend/apps/ai/handlers/utils.py request_tokens, response_tokens, model, thread_id

Meter Pricing Strategies

Meters are created in Django Admin (/admin/payments/meter/).

Strategy Formula Use case
margin credit_cost = dimensions[cost_dimension_key] * margin_multiplier AEO runs where you know the provider cost and want to add margin
fixed_per_event credit_cost = fixed_amount Flat fee per AI chat message
per_unit credit_cost = value * unit_price Per-token pricing

Meter fields:

Field Purpose
event_name Must match the event_name used in record_event()
display_name Shown in admin and billing UI
pricing_strategy One of margin, fixed_per_event, per_unit
margin_multiplier For margin strategy (default 1.5x)
fixed_amount For fixed_per_event strategy
unit_price For per_unit strategy
cost_dimension_key Key in dimensions JSON holding raw provider cost (default "total_cost")
is_active Inactive meters skip pricing (credit_cost = 0)

If no Meter exists for an event_name, credit_cost stays 0. The event is still recorded but does not consume credits.

Example: Setting Up AEO Metering

  1. Go to Django Admin > Payments > Meters > Add
  2. Set:
    • event_name: aeo_prompt_run
    • display_name: AEO Prompt Run
    • pricing_strategy: margin
    • margin_multiplier: 1.500 (50% margin over provider cost)
    • cost_dimension_key: total_cost
  3. Save. Next AEO prompt run will have credit_cost = provider_total_cost * 1.5.

Credit Gating

How It Works

tenant_has_sufficient_credits() in backend/apps/common/decorators.py compares the two sides of the ledger for the current calendar month:

total_granted = CreditLedgerEntry.objects.filter(
    tenant=tenant,
    effective_at__month=now.month,
    effective_at__year=now.year,
).aggregate(total=Sum("amount"))["total"] or Decimal("0")

total_consumed = InternalMeterEvent.objects.filter(
    tenant=tenant,
    occurred_at__month=now.month,
    occurred_at__year=now.year,
).aggregate(total=Sum("credit_cost"))["total"] or Decimal("0")

return total_granted > total_consumed

If no grants exist for the current month, the tenant has no credits and cannot proceed.

Where It's Applied

  • AEO endpoint: POST /api/v1/aeo/prompts/{id}/run/ checks credits before enqueuing (returns 403 with INSUFFICIENT_CREDITS)
  • Decorator: @sufficient_credits_required() can be applied to any view
  • Function: tenant_has_sufficient_credits(tenant) can be called from any code

Monthly Usage Display

Tenant.get_monthly_credits_usage() reads from the CreditBalanceMonthly Postgres view and returns up to 12 months of history, used by subscription_summary(extended=True) for the billing UI.


Billing API Endpoints

All under /api/v1/billing/ (tag: billing).

Subscription

Method Path Operation ID Purpose
GET /subscription-summary/ subscriptionSummary Full subscription + usage summary
GET /active-plans/ activePlans List available plans
GET /active-plans/{id}/ activePlan Single plan details
POST /checkout-session/ createCheckoutSession Create embedded checkout
GET /checkout-session-status/ checkoutSessionStatus Confirm checkout outcome
POST /portal-session/ createPortalSession Stripe customer portal
POST /cancel-checkout/ cancelCheckout Cancel pending checkout

Usage & Metering

Method Path Operation ID Purpose
GET /usage/summary/ tenantUsageSummary Credit usage for billing period (sums credit_cost)
GET /usage/records/ tenantUsageRecords Paginated list of meter events with credit_cost

Credit Ledger & Balance

Method Path Operation ID Purpose
GET /credits/balance/ tenantCreditBalance Stripe Billing Credits balance
GET /credits/ledger/ tenantCreditLedger Paginated list of ledger entries (grants, purchases, adjustments)
GET /credits/monthly/ tenantCreditBalanceMonthly 12-month credit balance history (from Postgres view)
GET /credits/current/ tenantCreditBalanceCurrent Current month's balance summary (granted, consumed, remaining)

Config & Webhooks

Method Path Operation ID Purpose
GET /config/ billingConfig Feature flags for frontend
POST /webhooks/stripe/{uuid}/ stripeWebhook dj-stripe webhook handler

Billing Modes

Controlled by BILLING_USAGE_MODE env var (default: internal).

Mode Behavior
internal Events recorded in DB only. No Stripe emission.
stripe_fixed_overage Fixed subscription fee + overage sent to Stripe Billing Meters.
stripe_payg Pure pay-as-you-go via Stripe Billing Meters API.
stripe_credits Prepaid credit burndown via Stripe Billing Credits (preview).

Per-event routing overrides are possible via BILLING_EVENT_ROUTING (JSON env var):

BILLING_EVENT_ROUTING='{"ai_tokens": "stripe_payg", "aeo_prompt_run": "internal"}'

Stripe Sync Task

sync_meter_event_to_stripe in backend/apps/payments/tasks.py:

  • Fetches InternalMeterEvent by ID
  • Resolves the tenant's Stripe customer
  • Calls stripe.billing.MeterEvent.create()
  • Marks event as sent or failed
  • Retries up to 3 times with exponential backoff for rate limit / connection errors

Settings Reference

Stripe Keys

STRIPE_LIVE_MODE = False                    # Toggle live/test mode
STRIPE_TEST_SECRET_KEY = ""
STRIPE_TEST_PUBLIC_KEY = ""
STRIPE_LIVE_SECRET_KEY = ""
STRIPE_LIVE_PUBLIC_KEY = ""

dj-stripe

DJSTRIPE_FOREIGN_KEY_TO_FIELD = "id"
DJSTRIPE_USE_NATIVE_JSONFIELD = True
DJSTRIPE_WEBHOOK_SECRET = ""
DJSTRIPE_WEBHOOK_VALIDATION = "retrieve_event"

Subscription Behavior

STRIPE_SUBSCRIPTIONS_ENABLED = True         # Enable subscription checkout
STRIPE_CREATE_SUBSCRIPTION_ON_SIGNUP = True  # Auto-create on tenant signup
STRIPE_TRIAL_PERIOD = 14                    # Trial days
STRIPE_AUTOMATIC_TAX_ENABLED = True
STRIPE_PER_SEAT_BILLING_ENABLED = False

Usage Metering

BILLING_USAGE_MODE = "internal"             # internal | stripe_payg | stripe_fixed_overage | stripe_credits
BILLING_EVENT_ROUTING = {}                  # Per-event overrides: {"event_name": "billing_mode"}
BILLING_CREDITS_ENABLED = False             # Stripe Billing Credits (preview)

Database Tables

Table Model Purpose
payments_credit_ledger_entry CreditLedgerEntry Credit grants, purchases, adjustments
payments_credit_balance_monthly CreditBalanceMonthly Postgres VIEW — monthly balance (grants vs consumption)
payments_meter Meter Pricing rules per event type
payments_internal_meter_event InternalMeterEvent Every recorded usage event
payments_checkoutsession CheckoutSession Checkout session tracking
djstripe_* dj-stripe models Stripe data mirror (customers, subscriptions, invoices)

Admin

Django Admin registrations (backend/apps/payments/admin.py):

  • Meter — create/edit pricing rules with fieldsets for strategy configuration
  • InternalMeterEvent — read-only view of all events with filtering by event_name, emission_status, and date hierarchy
  • CreditLedgerEntry — create/edit credit grants with fieldsets for tenant, type, amount, timing, and reference
  • CreditBalanceMonthly — read-only view of monthly balances per tenant (from the Postgres VIEW)
  • ProductMetadata — sync Stripe products

Adding a New Metered Feature

  1. Record the event in your feature code:
from apps.payments.models import InternalMeterEvent

InternalMeterEvent.objects.record_event(
    event_name="my_feature",
    value=quantity,
    dimensions={"total_cost": str(provider_cost), ...},
)
  1. Create a Meter in Django Admin with event_name="my_feature" and your desired pricing strategy.

  2. (Optional) Add a credit gate before the feature runs:

from apps.common.decorators import tenant_has_sufficient_credits

if not tenant_has_sufficient_credits(tenant):
    raise PermissionDenied("Insufficient credits")
  1. The billing API endpoints will automatically include the new event type in usage summaries and records.

Granting Credits to a Tenant

Via Django Admin

  1. Go to /admin/payments/creditledgerentry/add/
  2. Select the tenant
  3. Set entry type (e.g. subscription_grant), amount, description
  4. Save

Via Code (e.g. webhook handler, management command)

from apps.payments.models import CreditLedgerEntry

CreditLedgerEntry.objects.grant(
    amount=1000,
    entry_type="subscription_grant",
    tenant=tenant,
    description="Pro plan monthly grant - March 2026",
    reference_id="inv_stripe_abc123",
)

Automation (Future)

To auto-grant credits on subscription renewal, hook into the Stripe invoice.paid webhook and call CreditLedgerEntry.objects.grant() with the tenant and reference to the invoice ID. This ensures the ledger stays in sync with billing cycles.

Last updated June 21, 2026