Help
Billing
GovAI billing (Stripe + Postgres)
This document describes the minimal production-safe billing path: ledger tenant ↔ Stripe customer/subscription, webhooks, Checkout, usage traces, metered reporting, and optional enforcement.
Tenant ↔ Stripe mapping#
- Ledger
tenant_idis the value fromGOVAI_API_KEYS_JSONfor the caller’s API key (seerust/src/audit_api_key.rs). It is not derived fromX-GovAI-Project. tenant_billing_accounts(migration0014_tenant_stripe_billing.sql) stores one row pertenant_idwith:stripe_customer_id,stripe_subscription_id, optionalstripe_subscription_item_idsubscription_status(Stripe subscriptionstatus, ornoneif no row / not yet linked)current_period_start/current_period_endwhen known from subscription webhooksbilling_invoice_status(paid/failed) wheninvoice.paid/invoice.payment_failedmatch a known customer
Rows are created/updated by:
POST /billing/checkout-session— does not insert DB rows by itself; Stripe Checkout creates the customer/subscription.- Stripe webhooks —
checkout.session.completedupserts usingclient_reference_idormetadata.tenant_id;customer.subscription.*upserts usingsubscription.metadata.tenant_idor, if missing, an existing row matched bystripe_customer_id.
Checkout flow#
Endpoint: POST /billing/checkout-session (Bearer API key required; not subject to billing enforcement.)
Body:
price_id is optional. When omitted, the server uses GOVAI_STRIPE_PRICE_PRO (or legacy GOVAI_STRIPE_PRICE_TEAM). If neither is set → 503 STRIPE_PRICE_NOT_CONFIGURED.
Production redirects (govbase.dev): use absolute URLs such as https://govbase.dev/billing?checkout=success and https://govbase.dev/billing?checkout=cancel.
Behavior:
- Resolves
tenant_idfrom the API key mapping. - Calls Stripe
/v1/checkout/sessionsin subscription mode with:client_reference_id=tenant_idmetadata[tenant_id]andsubscription_data[metadata][tenant_id]=tenant_idline_items[0][price]=price_id, quantity1
Response: { "ok": true, "tenant_id", "session_id", "checkout_url" }
Requires: GOVAI_STRIPE_SECRET_KEY (sk_test… / sk_live…). If missing or empty → 503 with STRIPE_NOT_CONFIGURED.
Webhook lifecycle#
Endpoint: POST /stripe/webhook (unsigned; uses Stripe-Signature + GOVAI_STRIPE_WEBHOOK_SECRET.)
Persistence: Every verified event is inserted into stripe_webhook_events (stripe_event_id PK). Duplicates return 200 with "duplicate": true once processing completed.
Processing (idempotent side effects):
| Event | Effect |
|---|---|
checkout.session.completed | Upsert tenant_billing_accounts with customer + subscription ids; status incomplete if subscription id present |
customer.subscription.created / updated | Upsert subscription fields, periods, first subscription item id |
customer.subscription.deleted | Set subscription_status to canceled |
invoice.paid / invoice.payment_succeeded | If customer maps to a tenant: set billing_invoice_status=paid |
invoice.payment_failed | If customer maps: billing_invoice_status=failed, subscription_status=past_due |
Events that cannot be mapped (e.g. invoice for an unknown Stripe customer) do not fail the webhook — Stripe still receives 200 after persistence.
Retries: If processing fails, the handler returns 500 and leaves processed_at null so a retry can re-run processing. After success, processed_at is set.
Configure in Stripe Dashboard: point the webhook URL at https://<host>/stripe/webhook and select at least the event types above.
Usage traces and reporting#
- Traces: Each successful
POST /evidenceappendsgovai_billing_usage_trace(ledger_tenant_id,run_id,billing_unit, defaultevidence_event). - Summary:
GET /billing/usage-summary— unchanged; aggregates traces for a time window. - Stripe metered push:
POST /billing/report-usage(Bearer; optional body{ "billing_unit": "evidence_event" }):- Resolves the current billing window: subscription
current_period_*fromtenant_billing_accountswhen present, otherwise UTC month start → now. - Counts traces in that window.
- Inserts
billing_usage_reportswith unique(tenant_id, billing_unit, period_start, period_end)— idempotent (second call returnsidempotent_hit: true). - If
stripe_subscription_item_idis set andGOVAI_STRIPE_SECRET_KEYis configured, posts a usage record (action=set) and storesstripe_usage_record_id. - If no subscription item id, status
recorded_local(quantity stored only). - On Stripe API failure: row
failed, structured 502STRIPE_USAGE_REPORT_FAILED.
- Resolves the current billing window: subscription
Retries and Stripe failures
- Safe against duplicate charging: repeating
POST /billing/report-usagefor the same tenant, unit, and billing window hits the samebilling_usage_reportsrow (idempotency); it does not create a second row or a second metered push for that period by default. - Not automatic recovery: if the first attempt left the row in
failed, a later retry returns the existing row (idempotent_hit: true) without automatically re-calling Stripe until you add an explicit operator or product recovery path (for example fixing config and clearing/advancing state). Idempotency prevents double charge; it does not guarantee automatic completion after an external Stripe outage or misconfiguration.
Billing enforcement#
Variable: GOVAI_BILLING_ENFORCEMENT = off (default) | on (1, true, yes, on).
When on, gated routes reject tenants whose tenant_billing_accounts.subscription_status is not active or trialing with 403 BILLING_INACTIVE.
Never enforced on:
GET /health,GET /ready(core router / unauthenticated audit)POST /stripe/webhookPOST /billing/checkout-sessionGET /billing/status
Billing status and entitlements#
Endpoint: GET /billing/status (Bearer API key).
Returns Stripe ids, subscription_status, commercial_plan (free | pro | enterprise), commercial_plan_display, can_use_hosted_api (true when status is active or trialing), latest_invoice_status, mapped billing_units, and operator flags:
enforcement_enabled— whetherGOVAI_BILLING_ENFORCEMENTis on for this deploymentpro_list_price_monthly— authoritative list price (499 EUR) for UI copystripe_configured/stripe_checkout_configured— whether Checkout can run without passingprice_id
commercial_plan may still show Pro for past_due / unpaid while can_use_hosted_api is false (display vs entitlement).
GET /usage (metering off) resolves the same commercial plan from tenant_billing_accounts for limit fields.
Environment variables#
| Variable | Purpose |
|---|---|
GOVAI_STRIPE_SECRET_KEY | Stripe API secret for Checkout, portal, invoices, usage records |
GOVAI_STRIPE_WEBHOOK_SECRET | Webhook signing secret (whsec_…) |
GOVAI_STRIPE_PRICE_PRO | Default Pro subscription Price for Checkout when price_id omitted |
GOVAI_STRIPE_PRICE_TEAM | Legacy alias for Pro Price |
GOVAI_STRIPE_PRICE_ENTERPRISE | Maps subscription items to enterprise entitlements |
GOVAI_STRIPE_PRICE_* (metered) | Optional unit prices — see stripe_billing.rs |
GOVAI_API_KEYS + GOVAI_API_KEYS_JSON | API keys and ledger tenant ids (required for meaningful multi-tenant billing) |
GOVAI_BILLING_ENFORCEMENT | Optional subscription gate on hosted billable routes (off default) |
NEXT_PUBLIC_GOVAI_API_BASE_URL | Dashboard /billing → Rust API (browser) |
Local testing with Stripe CLI#
- Run Postgres + migrate (
sqlx migrate/GOVAI_AUTO_MIGRATE/ your deploy process). - Export
GOVAI_STRIPE_WEBHOOK_SECRETfromstripe listen --forward-to localhost:8088/stripe/webhook. - Trigger test events:
stripe trigger customer.subscription.updated(extend payload JSON in Dashboard Send test webhook to includemetadata.tenant_idmatching a key inGOVAI_API_KEYS_JSON). - Complete Checkout in test mode and confirm
tenant_billing_accountsupdates.
Known limitations#
- One metered path: usage records use the first subscription item id captured from webhook payloads; complex multi-item subscriptions need operator alignment.
- No hosted billing UI, proration, tax, or dunning automation beyond webhook state updates.
- Team tables from migration
0012(team_billing,team_subscriptions) are not wired into this path; this implementation uses ledgertenant_id(tenant_billing_accounts) only. - Checkout and usage APIs call Stripe over the public network; failures return structured JSON errors.
Related code#
rust/src/stripe_billing.rs— DB + Stripe HTTP helpersrust/src/stripe_webhook.rs— verify + persist + dispatchrust/migrations/0014_tenant_stripe_billing.sql— schema