Corteksa
GuidesBilling

Overview

Billing — Overview

Complete reference for the billing system: how plans, trials, payments, renewals, add-ons, and account enforcement work — plus the API the frontend integrates against.

This page has two halves:

  • Part A — Concepts (business): the model, lifecycle, and rules.
  • Part B — Integration (developer): endpoints, payloads, and flows.

The payment gateway is Paymob (paymob.com). Multi-currency; the merchant account is EGP-native but charges are made in the plan's currency (USD by default).


Part A — Concepts

1. The model at a glance

A workspace has exactly one subscription. The subscription tracks the billing state; the workspace tracks which plan and period are applied. Plans are the pricing catalog (Basic, Advanced, Enterprise, …); each has a monthly and/or annual price.

2. Subscription statuses

StatusMeaning
trialFree trial, no charge yet. Full plan access.
activePaid and current. Renews automatically each period.
past_dueA charge failed. In a 3-day grace period; workspace is read-only.
canceledUser canceled. Keeps access until current_period_end, then becomes an unpaid account (→ suspended, read-only). No renewal.
suspendedGrace expired without payment. Read-only; on the path to deletion.

3. Plans & tiers

Plans map to a tenant topology:

  • Shared tier (Basic, Advanced, …) — runs on the shared hyper-tenant database.
  • Dedicated tier (Enterprise) — provisioned by the team, not self-serve. Subscribing to Enterprise returns 400 "The Enterprise plan is set up by our team. Please contact support."

Self-serve rule. Any non-Enterprise plan can be selected via /change-plan (during the trial it's a free swap; see §6). Only Enterprise is gated — it's provisioned by the team, not self-serve.

3.1 The plan catalog

Three plans exist (basic, advanced, enterprise). They are seeded — there is no create/update API. Prices are USD, per member (seat), per month; the annual figure is the discounted per-month rate when billed yearly (charged as rate × 12 × seats).

PlanSlugMonthlyAnnual (per month)BillingPopular
Basicbasic$25$19Per member
Advancedadvanced$39$29Per member
EnterpriseenterpriseCustom — contact salesCustomFlat negotiated price

3.2 Plan limits (metered resources)

LimitBasicAdvancedEnterprise
WhatsApp (WAHA) sessions11Custom / unlimited
Workflow executions / month5,00030,000Custom / unlimited
AI credits / month3001,000Custom / unlimited
Storage5 GB25 GBCustom / unlimited

How limits behave:

  • null anywhere in the API means unlimited. Enterprise ships with null limits unless the super-admin sets per-workspace custom limits.
  • Effective limit = plan limit → overridden per-field by super-admin custom_limits (Enterprise) → plus recurring add-on grants (extra WhatsApp sessions). Resolved centrally and cached 5 minutes.
  • One-time packs (workflow executions, AI credits) do not raise the monthly limit — they credit a persistent pack balance drawn down after the monthly allowance is spent, surviving month rollover (see §8).
  • Usage counters are keyed by month, not plan — switching plans never resets consumption, only the ceiling.
  • Realtime: UsageCounterService.recordAiCredits / recordWorkflowExecution emit an in-process usage.recorded event after each increment. A realtime listener pushes usage:updated to the workspace's /data/events room (hyper → workspace:{id}; dedicated → the tenant room), so open clients update usage live without a manual refresh. The push carries { metric, charged, remaining }remaining is computed with the same computeMetricRemaining helper as GET /usage, so the client can render the new balance straight from the event (no refetch required). Best-effort — the balance read, compute, and emit are all guarded, so a realtime hiccup never affects the billed write. See FRONTEND_INTEGRATION.md → "Realtime usage updates".

When a limit is hit, the consuming action fails with HTTP 402 and a machine-readable code — the frontend should catch this globally and prompt an upgrade / add-on purchase:

{
  "code": "PLAN_LIMIT_EXCEEDED",
  "metric": "workflow_executions",
  "limit": 5000,
  "used": 5000,
  "message": "Plan limit exceeded for workflow_executions. Upgrade your plan or purchase an add-on."
}

3.3 Plan feature tiers (permissions)

Each plan also grants a fixed permission set (seeded from pricing-plan.constants.ts). All plans include the shared platform features: messaging templates, document templates, integrations, and all messaging provider sessions (WAHA, Facebook, Instagram, WhatsApp Cloud, TikTok). On top of that:

CapabilityBasicAdvancedEnterprise
CustomersFull CRUDCreate / read / update (no delete)Full CRUD
ProductsCreate / readCreate / read / updateFull CRUD
OrdersRead onlyCreate / read / updateFull CRUD
InvoicesRead onlyCreate / read / updateFull CRUD
Object schema managementCreate / read / updateFull CRUD + activate
Relation managementFull CRUD
User / admin / role managementFull CRUD

The exact permission list per plan is returned by GET /admin/pricing/plans (userPermissions).

4. Trial lifecycle

ConstantValueMeaning
TRIAL_DAYS14Default trial length from signup
TRIAL_EXTENDED_DAYS30Trial length from signup once a card is saved
GRACE_DAYS3Grace window after a failed charge
  • A new workspace starts on a 14-day trial (trial_ends_at = signup + 14d).
  • Adding a card extends the trial to 30 days from signup (not from the card-add date). No money is captured at this point — see §5.
  • At trial end:
    • With a saved card → the first real charge fires (→ active on capture).
    • Without a card → the subscription goes past_due (grace starts, read-only).

5. Payment flow (card save → native subscription)

Recurring billing runs on native Paymob subscriptions, which need two integration IDs: a 3DS integration for the first (customer-present) transaction and a MOTO integration for the recurring auto-deductions.

  • Adding a payment method (POST /checkout) creates the subscription via one hosted 3DS checkout (Unified Checkout) — that single transaction saves the card and connects it to the subscription. Returns a redirect URL; there is no separate save-card step and no is_auth.
    • On a trial → the plan carries the full recurring amount but the first deduction is delayed to trial end via subscription_start_date (native free trial); the setup transaction is a nominal card verification.
    • Lapsed / recovering → the subscription starts immediately (the setup transaction charges the full amount, recovering the account).
  • Recurring (native subscription): once created, Paymob auto-debits the card every cycle via the MOTO integration. Each debit arrives as a charge_captured webhook (correlated by subscription_id) that advances the period — the app schedules no renewals.
  • Seat/plan changes update the subscription plan's amount in place (an unattended PUT, no re-checkout); plan upgrades additionally fire a one-time prorated MIT charge for the current period. Add-on packs are one-time MIT charges. Cancelling calls the gateway's cancel endpoint.

6. Plan changes (upgrade / downgrade)

How a plan change is handled depends on one thing: the subscription's status. The rule of thumb: during the trial a plan is just a preview you can swap for free; once you're paying (active) a plan change moves money and is metered precisely.

Endpoint split. Changing the plan and managing the card are two separate endpoints. POST /change-plan only ever changes the plan and never opens a Paymob page; adding or replacing a card is POST /checkout (§B). So a plan change never asks for card details, and there is no with_payment flag anymore.

6.1 The governing rule — behaviour keys off status

  • trial → plan changes are free, immediate swaps, whether or not a card is on file. Nothing is captured during the trial, and the trial-end charge bills whatever plan is attached at that moment, so a card added just to extend the trial does not lock the plan choice.
  • active → a plan change is a real billing event, charged to the card already on file (§6.3).
  • lapsed / canceled (past_due / suspended / canceled) → /change-plan is rejected with 400 { error_key: "PAYMENT_METHOD_REQUIRED" }. A paid plan is never handed out for free outside the trial — the user must add a payment method / recover via POST /checkout first (which reactivates the subscription), then change plan.

6.2 During the trial — free swaps

  • Pick / preview / switch to any non-Enterprise plan via /change-planapplied immediately, no charge. Full access to the selected plan for the rest of the trial.
  • Adding a card is a separate action (POST /checkout) — that's what stores the card and extends the trial from 14 → 30 days. It does not charge and does not change the plan.
  • No abuse from switching: usage counters are keyed by month, not by plan, so switching plans never resets consumption — it only changes the ceiling. Switching also never extends the trial (only adding a card does, and that's computed from signup and is idempotent).

6.3 While active (paying) — reuse the saved card, no re-entry

A plan change on an active subscription (POST /change-plan) is charged to the card already on file (an MIT charge, like a renewal). No hosted page, no new card, no waiting for the next renewal. The response carries charged: true when an upgrade fired a payment; the frontend then polls /usage.

DirectionChargeWhen the new plan takes effectOn failure
Upgrade (costs more)Saved card charged the prorated difference now: (new_base − old_base) × remaining_fractionOn the charge_captured webhook (kind: plan_change) — lands within seconds, not at next renewalPlan not applied; subscription stays active (non-fatal, no past_due)
Downgrade / no-costNoneImmediately (nothing to confirm)n/a
  • Downgrades never refund the current period; the lower price folds into the next renewal.
  • The charge is auxiliary — it never advances the billing period or changes the subscription status. It only records an invoice line (plan_change_charged / plan_change_failed).

6.4 How proration is calculated (difference-based)

You're charged only for the increase over your current plan, for the time left in the current period — never the full new-plan price:

remaining_fraction = (period_end − now) / (period_end − period_start)
amount             = (new_base_total − old_base_total) × remaining_fraction

Example: on a $20/mo plan, upgrading to a $40/mo plan halfway through the month → charged (40 − 20) × 0.5 = $10 now; the next renewal bills the full $40. (base_total = base price × seats for per-member plans.)

Any plan change also drops the cached effective limits, so the new plan's caps take effect at once instead of after the 5-minute cache TTL.

7. Renewals

  • Renewals are auto-debited by Paymob (the MOTO subscription) — there is no app renewal scheduler. Each debit's charge_captured webhook advances the period; a charge_failed (after Paymob's retrial_days) moves the workspace to past_due.
  • Each attempt stamps last_renewal_attempt_at with a 12-hour cooldown so a webhook in flight isn't double-charged.
  • The charge uses a per-period idempotency key (renew-{subscriptionId}-{periodEnd}), so a retried request returns the original charge instead of charging twice.
  • On charge_captured the period advances; on charge_failed the subscription goes past_due.

8. Add-ons

Two kinds, both require an active subscription with a saved card:

  • Recurring (e.g. extra WhatsApp sessions): no immediate charge — the quantity folds into the next renewal total. Capacity is granted right away. Both price_monthly and price_annual are per-month figures; on an annual plan the add-on bills price_annual × 12 (matching the base plan's full-year charge). E.g. a +1 WhatsApp session (annual) at $14/mo bills $14 × 12 = $168/year.
  • One-time packs (e.g. AI-credit / workflow-execution packs): charges the saved card immediately; the consumable balance is credited only after the charge_captured webhook confirms.

Canceling a recurring add-on stops it at the next renewal — no refund for the current period.

The seeded catalog (returned by GET /user/billing/addons):

Add-onSlugModeGrantsPrice (USD)
+1 WhatsApp Sessionwhatsapp-sessionRecurring+1 session / month$19/mo ($14/mo on annual)
+10,000 Workflow Executionswf-pack-10kOne-time+10,000 pack balance$8
+50,000 Workflow Executionswf-pack-50kOne-time+50,000 pack balance$30
+250 AI Creditsai-credits-250One-time+250 pack balance$6
+1,000 AI Creditsai-credits-1kOne-time+1,000 pack balance$20
+5,000 AI Creditsai-credits-5kOne-time+5,000 pack balance$80

9. Seats & proration (per-member plans)

  • Per-member plans bill base price × seat count; flat plans bill the base once (seatCount = 1).
  • Adding seats mid-cycle charges a prorated amount for the remainder of the period:
    fraction = (period_end − now) / (period_end − period_start)
    amount   = per_seat_price × seats_added × fraction
  • Removing seats never refunds — it just lowers the snapshot, reducing the next renewal.
  • Seat changes are deferred — adding/removing a member is never charged mid-cycle; it only updates the seat snapshot and bills base × seats at the next renewal (no refund on removal). Plan upgrades and one-time packs are auxiliary charges: they do not advance the billing period or change status. A failed auxiliary charge is non-fatal.

10. Enforcement lifecycle (unpaid accounts)

  1. Payment fails → past_due + read-only (immediate, via webhook) The instant Paymob reports charge_failed (a failed renewal or a trial that ended with no card), the subscription flips to past_due, a 3-day grace clock starts (grace_period_ends_at), and the workspace goes read-only right away (read_only_since = now). Read-only means writes are blocked but the app is still viewable — and crucially, billing endpoints stay open so the user can pay to recover.

  2. Grace expires → suspended (after 3 days) If they haven't paid within the 3-day grace, the hourly grace scheduler moves them to suspended. Still read-only, now on the path to deletion. The same scheduler also sweeps canceled subscriptions whose current_period_end has passed → suspended (read_only_since = now), so a canceled account becomes an ordinary unpaid account once its paid period ends and follows steps 3–4 identically.

  3. Unpaid ≥ 90 days → enrolled for deletion After 90 days unpaid, the read-only-delete scheduler stamps deletion_scheduled_at — the workspace is now queued for system deletion (tagged deletion_requested_by_email = 'system@billing' so it's distinguishable from a user deleting their own workspace).

  4. Deletion due → data purged (irreversible) When that scheduled time arrives, the workspace-deletion scheduler permanently purges the workspace data. This is the only irreversible step.

At any point before the purge, a successful payment (charge_captured) rescues the account: it clears read_only_since (lifts read-only) and — per the code I just checked (billing-enforcement.service.ts:53-96) — cancels the pending system deletion (clears deletion_scheduled_at etc.), as long as purging hasn't started and it was a billing-initiated deletion (a user's own intentional delete is never auto-cancelled).

11. Schedulers

JobDefault cronEnv var
Renewal (charge due subscriptions)30 2 * * * (02:30 UTC daily)BILLING_RENEWAL_CRON
Trial expiry (first charge / → past_due)0 * * * * (hourly)BILLING_TRIAL_EXPIRY_CRON
Grace expiry (past_due → suspended)15 * * * * (hourly :15)BILLING_GRACE_EXPIRY_CRON
Read-only delete (enroll ≥90d unpaid)30 * * * * (hourly :30)BILLING_READONLY_DELETE_CRON
Workspace deletion (purge due)0 * * * * (hourly :00)WORKSPACE_DELETION_CRON

Key windows: GRACE_DAYS = 3, RENEWAL_COOLDOWN_HOURS = 12, BILLING_READONLY_DELETE_AFTER_DAYS = 90, BILLING_DELETE_FINAL_GRACE_DAYS = 0.


Part B — Integration (API)

  • Base path: every route is under /api/v1.
  • Auth: user-facing endpoints need the user JWT and an active workspace on the user (else 400 "No workspace selected").
  • Envelope: { "message": string, "data": … }; lists add a top-level pagination.
  • Identifiers: always slugs (plan_slug, addon_slug), never numeric ids. Fields are snake_case.

Two endpoints: plan vs card

  • POST /change-plan — change the plan. Never opens a Paymob page. Trial swaps and lapsed plan-sets apply immediately; an active change is billed to the saved card (charged: true ⇒ poll /usage).
  • POST /checkout — add / replace the card (the only endpoint that opens Paymob). Returns a redirect_url.

Card flow (POST /checkout)

POST /user/billing/checkout  (callback_url)
      └─► returns { redirect_url }  ← Paymob hosted page
browser ─► redirect_url  (user enters card + 3DS)
Paymob ─► redirects browser to your callback_url (?payment_id=…&status=…)

        │  server-to-server (async):
        │  Paymob ─► POST /api/v1/webhooks/paymob ─► saves card / activates (lapsed recovery)

frontend ─► GET /user/billing/payment-status?payment_id=…   (immediate outcome)
frontend ─► GET /user/billing/usage                     (final workspace state, poll)

The redirect back is not proof of success. Confirm the outcome with payment-status?payment_id=, and confirm the final workspace/subscription state by polling /usage (the webhook lands a moment after the redirect).

Endpoints

ActionMethodPath
List plansGET/api/v1/admin/pricing/plans
Change planPOST/api/v1/user/billing/change-plan
Add / replace cardPOST/api/v1/user/billing/checkout
Payment status by payment_idGET/api/v1/user/billing/payment-status?payment_id=…
Usage + subscription stateGET/api/v1/user/billing/usage
Cancel subscriptionPOST/api/v1/user/billing/cancel
Add-on catalogGET/api/v1/user/billing/addons
Purchase add-onPOST/api/v1/user/billing/addons
Cancel recurring add-onDELETE/api/v1/user/billing/addons/{addon_slug}
InvoicesGET/api/v1/user/billing/invoices
Invoice PDFGET/api/v1/user/billing/invoices/{reference}/pdf
Paymob webhook (server only)POST/api/v1/webhooks/paymob

Change plan

POST /user/billing/change-plan → 200. Never returns a redirect — this endpoint only changes the plan; it does not touch the card.

{
  "plan_slug": "advanced", // required
  "period": "monthly" // optional: "monthly" | "annual"
}

Response:

{
  "message": "Plan updated", // "Plan change payment initiated" when charged:true
  "data": {
    "charged": false, // true = an active upgrade fired a charge → poll /usage
    "subscription": {
      "status": "trial",
      "trial_ends_at": "2026-07-31T12:00:00.000Z",
      "current_period_end": null,
      "grace_period_ends_at": null,
      "canceled_at": null,
      "gateway": "tap",
      "has_payment_method": false,
      "seat_count": 1,
      "read_only": false,
      "read_only_since": null,
      "delete_scheduled_at": null
    }
  }
}
  • trial → plan applied immediately (free swap), charged:false.
  • active → billed to the saved card: an upgrade returns charged:true (plan applies on the webhook — poll /usage); a downgrade applies immediately, charged:false.
  • lapsed / canceled (past_due / suspended / canceled) → rejected 400 { error_key: "PAYMENT_METHOD_REQUIRED" }; recover via POST /checkout first, then change plan.
  • Errors: 400 plan not found / Enterprise (contact support) / PAYMENT_METHOD_REQUIRED (subscription not active).

Add / replace card

POST /user/billing/checkout → 200. The only endpoint that opens a Paymob hosted page.

{
  "callback_url": "https://app.example.com/en/settings/billing" // optional
}

Response:

{
  "message": "Checkout session created",
  "data": { "redirect_url": "https://checkout.paymob.com/…" }
}
  • Send the browser to redirect_url; after Paymob returns, poll /usage (and optionally payment-status?payment_id=).
  • Adding a card is allowed on any plan and in any status — nothing gates it.
  • past_due / suspended on a priced plan → charges the full current-plan amount to recover (the charge also saves the card); reactivates on the charge_captured webhook.
  • everyone else, including free/unpriced plans$1 authorize + auto-void: saves or replaces the card, captures no money (a trial is extended to 30 days). Applied on the card_saved webhook.
  • callback_url is where Paymob returns the browser (appends ?payment_id=…&status=…); falls back to the server default when omitted.
  • Errors: only 400 "No workspace selected".

Payment status

GET /user/billing/payment-status?payment_id=chg_xxx → live status from Paymob (works for chg_… and trial auth_…).

{
  "message": "Payment status retrieved",
  "data": {
    "status": "success", // "success" | "failed" | "pending"
    "gateway_status": "CAPTURED", // raw Paymob status
    "message": "Payment completed successfully."
  }
}

pending → not settled; poll again. Cross-workspace payment_id403.

Usage + subscription state

GET /user/billing/usage — the source of truth for the billing screen (no separate "get subscription" endpoint).

{
  "period_key": "2026-07",
  "subscription": {
    "status": "active",
    "plan_slug": "advanced",
    "plan_name": "Advanced",
    "is_per_member": true,
    "period": "monthly",
    "base_price": 39,
    "seat_count": 3,
    "recurring_total": 136,
    "trial_ends_at": null,
    "current_period_start": "2026-07-01T00:00:00.000Z",
    "current_period_end": "2026-08-01T00:00:00.000Z",
    "grace_period_ends_at": null,
    "canceled_at": null,
    "gateway": "tap",
    "has_payment_method": true
  },
  "metrics": {
    "whatsapp_sessions": {
      "limit": 2,
      "used": 1,
      "remaining": 1,
      "unlimited": false
    },
    "workflow_executions": {
      "limit": 30000,
      "used": 8500,
      "remaining": 31500,
      "unlimited": false,
      "pack_balance": 10000
    },
    "ai_credits": {
      "limit": 1000,
      "used": 400,
      "remaining": 850,
      "unlimited": false,
      "pack_balance": 250
    },
    "storage": {
      "limit": 26843545600,
      "used": 1073741824,
      "remaining": 25769803776,
      "unlimited": false
    }
  },
  "active_addons": [
    {
      "addon_slug": "whatsapp-session",
      "name": "+1 WhatsApp Session",
      "type": "whatsapp_session",
      "quantity": 1,
      "status": "active",
      "canceled_at": null
    }
  ],
  "consumables": { "workflow_execution_balance": 10000, "ai_credit_balance": 250 }
}

recurring_total = base_price × seat_count + recurring add-ons (here: 39 × 3 + 19). unlimited: truelimit/remaining null. Storage values are bytes; remaining on packs-backed metrics already includes pack_balance (max(0, limit − used) + pack_balance). whatsapp_sessions.used is the live connected-session count, not a monthly counter.

Cancel

POST /user/billing/cancel{ "message": "Subscription canceled successfully" }. Only active subscriptions. Keeps access until current_period_end, no renewal. Once the period ends the account is treated like any unpaid one — an hourly scheduler flips it to suspended (read-only, on the deletion ladder). To come back, the user pays via POST /checkout (full charge → reactivates), exactly like recovering a past_due/suspended account.

Add-ons

  • GET /user/billing/addons — catalog: addon_slug, name, type (whatsapp_session | workflow_execution_pack | ai_credit_pack), billing_mode (recurring | one_time), prices, grant_*.
  • POST /user/billing/addons{ "addon_slug": "whatsapp-session", "quantity": 2 }. Requires status:"active" + has_payment_method:true, else 400. Recurring → applied now, redirect_url: null; one-time → may return a redirect_url.
  • DELETE /user/billing/addons/{addon_slug} — cancel recurring (next renewal, no refund).

Invoices

  • GET /user/billing/invoices?page=1&limit=50 — paginated (newest first). Items: reference, type (PaymentEventType), status (paid | failed), amount, currency, gateway, occurred_at + top-level pagination.
  • GET /user/billing/invoices/{reference}/pdf — branded PDF.

Webhook (server → server, not called by frontend)

POST /api/v1/webhooks/paymob — HMAC-verified. Drives all state changes: activates subscriptions, applies the paid plan switch, credits one-time packs, and moves failed charges to past_due. This is why the frontend must poll /usage after checkout — the state lands here, not on the redirect.


AI usage cost & pricing (platform admin)

Separate from subscription billing: this is how AI runs are costed, priced, and reported. Everything here is super-admin only (AdminAuthGuard + SuperAdminGuard) — pricing is global platform config, not tenant data.

The ledger — ai_usage_event (main DB). One immutable row per billed AI run: workspace_id, admin_id, channel, provider, model_key, input/output/cached tokens, cost_usd (real dollars), credits, and the pricing row that applied. run_id is unique (idempotent — a retried run never double-bills). Every AI v2 run writes one via MeteringCreditGate → AiRunBilling.billRun.

The price catalog — ai_model_pricing (main DB). Effective-dated USD rates (input / output / cached per-million). A price change is a new row (the old one is closed), so past charges stay reproducible. PricingResolver reads the current rates to cost each run.

Real pricing from OpenRouter. OpenRouterPricingService mirrors OpenRouter's published /api/v1/models rates into the catalog (source='openrouter') — OpenRouter is the price source only, calls still go direct to Gemini/Kimi. Only models in the explicit OPENROUTER_MODEL_MAP sync; it upserts only when a rate changed (no effective-dated churn), and a model OpenRouter doesn't list is skipped, never mis-priced. Every live provider default (gemini/kimi) must be in the map — a guardrail test (openrouter-model-map.guardrail.spec) fails CI if one is missing, so a used model can't ship unpriced.

Accuracy caveat. We call the provider APIs directly, so the true cost is the provider's own rate. OpenRouter's published number is a close reference that can differ (a reasoning/blended tier) — e.g. gemini-2.5-flash reads ~$0.30/$2.50 per 1M on OpenRouter vs Google's ~$0.15/$0.60 base. Enable the sync only for models whose OpenRouter rate matches your direct invoice; keep the rest admin-set. The sync ships off, so hand-set prices stand until AI_PRICING_OPENROUTER_SYNC_ENABLED=true.

  • POST /api/v1/admin/ai-pricing/sync-openrouter — sync now; returns { updated, unchanged, notFound, failed, models[] }.
  • Daily @Cron (OPENROUTER_PRICING_CRON), off unless AI_PRICING_OPENROUTER_SYNC_ENABLED=true.
  • Manual overrides still available: GET/PUT /admin/ai-pricing/models, GET/PUT /admin/ai-pricing/credit-setting, GET /admin/ai-pricing/ledger.

Cost dashboard — admin/ai-analytics (aggregates the ledger). All accept from / to (ISO; default last 30 days) and optional workspaceId (drilldown):

  • GET /summary — total USD, credits, runs, workspace count for the range.
  • GET /by-workspace — cost / credits / runs per workspace, ranked, paginated (with name).
  • GET /by-model · GET /by-channel — spend breakdowns.
  • GET /time-series?granularity=day|month — spend over time.

Reference — enums

SubscriptionStatus: trial · active · past_due · canceled · suspended

PaymentEventType (invoices): trial_started · subscription_activated · subscription_renewed · subscription_canceled · charge_captured · charge_failed · card_saved · seat_proration_charged · addon_one_time_charged · addon_charge_failed · plan_change_charged · plan_change_failedstatus: "failed" for *_failed, paid otherwise.

ChargeKind (internal, on charge metadata): initial · renewal · seat_proration · addon_one_time · plan_change

AddOnBillingMode: recurring · one_time

Normalized webhook event types: charge_captured · charge_failed · card_saved · ignored


Reference — key config

BILLING_RETURN_URL / FRONTEND_BASE_DOMAIN   # default redirect target
APP_BASE_URL                                 # backend base (Paymob webhook + return)
PAYMOB_BASE_URL                              # regional base (accept.paymob.com …)
PAYMOB_SECRET_KEY                            # Intention API + saved-token pay + cancel
PAYMOB_PUBLIC_KEY                            # Unified Checkout URL (client-safe)
PAYMOB_HMAC_SECRET                           # webhook HMAC verification
PAYMOB_API_KEY                               # subscription-plan mgmt + transaction inquiry
PAYMOB_INTEGRATION_ID_3DS                    # first/checkout transaction (customer present)
PAYMOB_INTEGRATION_ID_MOTO                   # recurring auto-deductions + saved-card MIT
BILLING_RENEWAL_CRON=30 2 * * *              # native subs auto-debit; cron only STARTS subscriptions
BILLING_TRIAL_EXPIRY_CRON=0 * * * *
BILLING_GRACE_EXPIRY_CRON=15 * * * *
BILLING_READONLY_DELETE_CRON=30 * * * *
WORKSPACE_DELETION_CRON=0 * * * *
BILLING_READONLY_DELETE_AFTER_DAYS=90
BILLING_DELETE_FINAL_GRACE_DAYS=0

# AI pricing (OpenRouter price sync)
AI_PRICING_OPENROUTER_SYNC_ENABLED=false     # daily catalog sync on/off (manual endpoint always works)
OPENROUTER_PRICING_CRON=15 4 * * *
OPENROUTER_MODELS_URL=https://openrouter.ai/api/v1/models
OPENROUTER_API_KEY                            # optional; the models list is public

On this page