Payment / Billing Logic and Usage Cost Guardrails (Translated from Chinese)
Based on the latest code and docs as of 2026-06-24 (cross-referenced against
api/src/billing/,api/src/conversations/, and the ops frontend). English authoritative version: billing-and-cost-guardrails.md (if the two diverge, defer to the English version + code). Terminology follows the repositoryGLOSSARY.md: Specialist = AI persona, Expert = HP human reviewer, AM = Account Manager, client = org.
TL;DR
- Billing = a fixed monthly fee per "assigned Specialist instance", entirely usage-independent.
- Only two things ever hard-block activity: per-org message rate limiting (anti-abuse, unrelated to cost) and suspension for non-payment (dunning). Neither is tied to cost.
- Optional human Expert correction/follow-up work is currently neither metered, priced, nor capped. LLM token cost is now visible to ops but not enforced. There is no per-client cost cap, and no gross-margin guardrail.
1. Billing Model (how money is collected)
Billing unit: one fixed monthly fee per assigned Specialist instance (per org_specialist_assignment / OSA).
- The rate for each (Org × Specialist) assignment lives in
org_specialist_assignment.monthly_rate_config.base_rate, corresponding to one Lago subscription.- Provider = Lago (self-hosted); Stripe is only the downstream payment processor (PSP), not the billing ledger.
- The subscription is created when the AM assigns a Specialist (
SyncSpecialistSubscriptionCommand).
- One invoice per org, itemized by Specialist line; prorated daily; mid-cycle rate changes go through a Lago upgrade/downgrade and are written to
org_specialist_assignment_rate_history. - Reuse amplifies revenue rather than diluting it: the same catalog prototype assigned to N clients = N independent operating instances = N monthly fees (each with its own OSA + subscription).
- Not billed: LLM token cost, message volume, conversation count, Expert hours. The monthly fee is usage-independent — no tier, no included quota, no overage. The old Stripe tiered-plan model has been removed.
2. Payment & Access Gate (dunning)
- Payment methods: card or invoice; the
billingorg role can update the billing email (@OrgRoles("admin","owner","billing")). - The only hard access gate = non-payment: payment failure → grace period (default N days) →
BillingAccessGateService.runGate()sets the org status tosuspended. This is a suspension based on payment status, not usage or cost. - Members with
owner/admin/billingroles are notified on payment failure (#1475).
3. Cost Visibility (observe only, not enforced)
SuperAdmin-only panel that only displays cost/workload, imposes no limits:
| Content | Location | Source |
|---|---|---|
| Per-client LLM cost (total + by Specialist) | /ops/billing?org=<id> | LlmCostRollupService aggregates the unified llm_runs ledger by osa_id (#3194 / PR #3195; the read source switched from messages.cost_cents in #4799) |
| Platform LLM cost (trend, top clients, by Specialist, by purpose) | /ops/billing (SuperAdmin section) | PlatformCostStatsService reads llm_runs (#4797) + nightly Langfuse↔DB reconciliation badge (#4798) |
| Per-Specialist Expert workload (items handled + handle time) | /ops/billing?org=<id> | resolved queue entries → conversation → OSA (#3196 / PR #3200) |
| Expert leverage ratio (how many clients / Specialist instances one expert covers) | /ops/analytics | expert_access (ADR-007) (#3197 / PR #3199, merged) |
LLM cost lands per call in llm_runs (micro-USD precision; the legacy messages.cost_cents/token_input/token_output/model_name columns continue to be written for history only and are no longer read — #4799) and is aggregated by OSA, but is not billed — it is used for audit/analysis only.
4. Usage Cost Guardrail Rules (key)
Note: "Hard-blocked" = whether the behavior is actually stopped; "Observable" = whether ops can see the underlying signal (even if nothing enforces it). The gap between "cannot be enforced" and "cannot even be seen" is exactly where pricing decisions matter most.
| Guardrail | Rule | Hard-blocked | Observable | Dimension |
|---|---|---|---|---|
| Message rate limiting | Per-org org_message_quota, default 1000 messages/hour → HTTP 429 (conversations.service.ts, migration 1714000000007-OrgQuota.ts) | ✅ Yes | ✅ Yes — /ops/billing?org=<id> Org Usage shows hourly/daily usage | Throughput (anti-abuse, not cost) |
| Suspension for non-payment | org → suspended after the payment-failure grace period | ✅ Yes | ✅ Yes — /ops/billing shows payment-failure/dunning status (#1433/#1551) | Payment status (not cost) |
| Agent delivery | Successful Hermes replies deliver directly; legacy confidence/review thresholds are inert | ❌ No content gate | ✅ Yes — turn latency, model/tool usage, and delivery outcome are observable | Delivery |
| Per-client LLM cost cap | None | ❌ | ✅ Yes — per-client/per-Specialist LLM cost is on /ops/billing?org=<id> (#3195) | Cost (visible but uncapped) |
| Optional Expert follow-up cost | None (after-delivery observation/correction is not metered, priced, or capped) | ❌ | ⚠️ Partial — workload (item count + handle time) is visible (#3200); dollarized hour cost is not visible (#3196) | Cost (volume only visible) |
| Usage-based / overage billing | None | ❌ | ✅ Yes — message usage is visible (rate-limit panel), but never billed | Billing |
| Gross-margin guardrail / alert | None enforced (SA-B5 only partially visible) | ❌ | ❌ No — cost is visible, but gross margin (cost vs. revenue) is not computed anywhere | Gross margin |
5. Why It Matters (risk & follow-up)
Because pricing is a fixed Specialist monthly fee + unlimited usage, a heavy client's model/tool usage and optional Expert follow-up can rise while the monthly fee stays fixed. Ops needs a gross-margin signal; delivery must not be blocked as a cost-control mechanism. LLM cost and Expert workload are visible (§3), but:
- Expert hours are still not dollarized (no active review-duration tracking, no per-Expert cost rate) — see #3196.
- There is no per-client cost cap, no overage billing, no gross-margin alert. SA-B5 (per-client gross margin) is only partially complete; SA-E4 (leverage ratio) has shipped.
To safely commit "fixed monthly fee + unlimited usage" to a contract, the patches needed are: a per-client cost/usage soft cap + alert, an Expert-hour cost model, and a gross-margin dashboard (the remainder of SA-B5).
6. Cost Statistics (planned)
Three-tier (platform / client(org) / Specialist) LLM cost statistics have been scoped in docs/specs/llm-cost-statistics.zh.md (approved 2026-07-23). Its four standing decisions are recorded here as design-of-record:
- Langfuse = exploration + cross-validation analysis; the in-house DB = billing-grade numbers (the
api/src/llm/implementation and this spec are authoritative for retention, availability, PII, and data provenance). - Specialist becomes a first-class Langfuse dimension via value-tagging (
specialist:<id>,source:agent|api);user_idremainsorg_id,session_idremains the conversation UUID. - Ledger unification — already switched over (#4799, 2026-07-23): Hermes chat-turn cost is dual-written into
llm_runs(purpose='chat-hermes', computed server-side as micro-USD from tokens × price table); all cost reads (rollups + daily budget gates) now come fromllm_runs;messages.cost_centscontinues to be written only for history. The two-week dual-track observation period was waived by the product owner — the old ledger was never formally used or accepted. A 90-day backfill scriptapi/scripts/backfill-llm-runs-from-messages.ts(run once per environment). - Nightly Langfuse↔DB reconciliation, drift alert (>5% or >$1/day), alert-only, no auto-correction.
6.1 Runbook: Querying LLM Cost via Langfuse (Phase 0 — #4790)
Before the in-product ops view ships (Phase 2, #4797), ops uses self-hosted Langfuse (https://langfuse.hptestingsite.com, project humanwork-dev) to answer cost questions. The following queries have all been verified against Langfuse v3.174.1 (2026-07-23). Auth: HTTP Basic, using the project's pk-lf-…:sk-lf-… key pair (in the Railway api service env, LANGFUSE_PUBLIC_KEY/LANGFUSE_SECRET_KEY); a browser User-Agent is required (the Cloudflare front for that domain returns 403, error 1010, for bot UAs).
"How much did the platform spend today?" — the daily metrics endpoint (also broken down by model):
GET /api/public/metrics/daily?limit=30
"Which orgs spent the most this week (top-N)?" — the Metrics API, grouped by user (= org id). Note: the Metrics API is GET + a URL-encoded query JSON parameter (POST returns 405):
GET /api/public/metrics?query={"view":"traces","metrics":[{"measure":"totalCost","aggregation":"sum"}],"dimensions":[{"field":"userId"}],"fromTimestamp":"<ISO>","toTimestamp":"<ISO>"}
"How much did a given conversation cost?" — the same query with the dimension swapped to "dimensions":[{"field":"sessionId"}] (session = the platform conversation UUID), or via the UI at Tracing → Sessions.
"How much did a given Specialist cost?" — filter (not group) by trace metadata:
"filters":[{"column":"metadata","operator":"=","key":"specialist_id","value":"<specialist uuid>","type":"stringObject"}]
Grouping across specialists in a single query is not possible before #4792 (see the spec's spike conclusion); you can loop the filter over known specialist ids, or wait for value-tagging to ship.
UI dashboard (optional, roughly 10 minutes of manual setup — Langfuse 3.174.1 has no public dashboards API, so it can only be built by hand once): in the project, Dashboards → New dashboard → add three widgets: (1) platform daily cost — view Traces, metric Total cost (sum), no breakdown, day granularity, line chart; (2) cost by org — the same metric, breakdown dimension User, horizontal bar chart, top 10; (3) daily trace count — metric Count, day granularity. Drilling into a session needs no widget — use Tracing → Sessions sorted by Total cost.
Caveats (read before citing any numbers)
- Before #4793 ships, Langfuse's per-org numbers undercount: API-side
LlmServicecalls (summaries, topics, classification, embeddings, transcripts) have nouser_id, so they're invisible in any user/org grouping — only Hermes chat turns are attributed. TheuserId: nullrows in group-by-user results are exactly this unattributed spend. LlmService.stream()is neither metered nor reported (spec OD-2).- Expert consultation turns have no cost in the DB ledger (
cost_usd = Noneon the agent side); but their Langfuse trace carries an inferred generation cost — until Phase 2 unification, this is another small source of Langfuse↔DB drift. - These are analytics numbers, not billing numbers (decision D1): billing-grade queries should use the DB ledger as the source of truth.
7. Source Map
| Concern | File |
|---|---|
| Pricing / subscription sync | api/src/billing/application/commands/sync-specialist-subscription.command.ts, .../assignment-billing-input.resolver.ts, infrastructure/providers/lago.provider.ts |
| Invoice line items / proration | api/src/billing/application/line-item-calculator.service.ts |
| Access gate (dunning) | api/src/billing/access-gate.service.ts |
| Payment-failure notification | api/src/billing/infrastructure/billing-payment-failed.notifier.ts |
| LLM cost rollup | api/src/billing/llm-cost-rollup.service.ts, .../queries/org-llm-cost.query.ts |
| Expert workload | api/src/billing/application/queries/org-expert-workload.query.ts |
| Expert leverage ratio | api/src/expert-access/expert-leverage.query.ts |
| Message rate limiting | api/src/conversations/conversations.service.ts, api/migrations/1714000000007-OrgQuota.ts |
Related
- User stories: SA-B5 (per-client cost / gross margin), SA-E4 (Expert leverage ratio) —
docs/user-stories.md - Status:
docs/implementation-status.md - Issues/PRs: #3194/#3195 (LLM cost), #3196/#3200 (Expert workload), #3197/#3199 (leverage ratio, merged)