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 checkoutGET /api/v1/billing/checkout-session-status/?session_id=...— confirms outcomePOST /api/v1/billing/portal-session/— Stripe customer portalPOST /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):
CreditLedgerEntryrows — 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:
- Looks up the active
Meterfor theevent_name(cached 5 min) - Calls
meter.compute_credit_cost(value, dimensions)to get the tenant-facing charge - Creates the
InternalMeterEventrow withcredit_costand a FK to theMeter - If
BILLING_USAGE_MODE != "internal", enqueuessync_meter_event_to_stripeCelery 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
- Go to Django Admin > Payments > Meters > Add
- 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
- event_name:
- 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 withINSUFFICIENT_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
InternalMeterEventby ID - Resolves the tenant's Stripe customer
- Calls
stripe.billing.MeterEvent.create() - Marks event as
sentorfailed - 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
- 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), ...},
)
Create a Meter in Django Admin with
event_name="my_feature"and your desired pricing strategy.(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")
- The billing API endpoints will automatically include the new event type in usage summaries and records.
Granting Credits to a Tenant
Via Django Admin
- Go to
/admin/payments/creditledgerentry/add/ - Select the tenant
- Set entry type (e.g.
subscription_grant), amount, description - 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