Operation Metering
Every billable event in Loquent — a voice call, an SMS, an AI generation — flows through record_operation(). This service converts raw measurements into billed units, translates those units into credits via billing_config rates, and runs a credits-only waterfall against org_budget.
Two-Axis Architecture
Section titled “Two-Axis Architecture”Billing uses two separate axes to answer different questions:
| Axis | Type | Question it answers | Shape |
|---|---|---|---|
| Reporting | OperationType | ”What is feature X costing us?” | Area-shaped — one variant per AI feature |
| Pricing | BillingDimension | ”How many credits does this cost?” | Tier-shaped — one variant per model tier |
OperationType is stored in operation.operation_type and drives the admin UI. BillingDimension is crate-private (pub(crate)) and drives the billing_config.*_credits rate lookups, subscription_tier.*_included allowances, and org_budget.*_included pool balances. The pricing schema is unchanged — no new columns needed when you add an AI feature.
OperationType (Reporting Axis)
Section titled “OperationType (Reporting Axis)”The OperationType enum defines all billable event categories. AI variants mirror AiUsageFeature 1:1:
pub enum OperationType { // Telephony / messaging VoiceCall, VoiceRecording, SmsOutbound, SmsInbound, MmsOutbound, Transcription, RealtimeOpenai, RealtimeGemini, EmailOutbound,
// AI text features — one variant per AiUsageFeature (1:1) AiAssistant, AiAssistantTitle, AiEnrichContact, AiEnrichContactFromMessages, AiSummarizeCall, AiUpdateContactMemory, AiUpdateContactMemoryFromMessages, AiAnalyzeCall, AiIdentifySpeakers, AiAutoTagContact, AiQueryKnowledge, AiGenerateInstructions, AiEditInstructions, AiCustomEditInstructions, AiTextAgentSuggestions, AiTextAgentSingleReply, AiAssessPlanTemplate, AiInstantiatePlan, AiExecuteAutonomousCampaign, AiExtractTasks, AiExtractTasksFromMessages, AiGenerateReport, AiDashboardBriefing, AiDashboardDetailedBriefing,
// Sentinel for pre-refactor rows that couldn't be backfilled AiTextLegacy,}Naming invariant: every Ai* variant serializes as "ai_" + AiUsageFeature::as_str() (e.g., AiSummarizeCall → "ai_summarize_call"). The is_ai_text() method relies on this prefix. Don’t break it.
Backwards compatibility: FromStr still accepts the old tier strings (ai_text_budget, ai_text_mid, ai_text_premium, ai_text_ultra) and maps them to AiTextLegacy.
BillingDimension (Pricing Axis)
Section titled “BillingDimension (Pricing Axis)”BillingDimension is crate-private and drives all pricing math. It retains the old 13-variant shape:
pub(crate) enum BillingDimension { VoiceCall, VoiceRecording, SmsOutbound, SmsInbound, MmsOutbound, AiTextBudget, // DeepSeek, Gemini Flash Lite AiTextMid, // Gemini Pro, GPT-4.1 AiTextPremium, // Claude Sonnet AiTextUltra, // Claude Opus Transcription, RealtimeOpenai, RealtimeGemini, EmailOutbound,}BillingDimension::from_ai_model_tier() maps AiModelTier → BillingDimension. The same workload costs the same credits regardless of which AI feature triggered it.
Recording an Operation
Section titled “Recording an Operation”Callers construct an OperationInput and pass it to record_operation():
use crate::mods::billing::{OperationInput, OperationReference, record_operation};
let receipt = record_operation( &db, organization_id, OperationInput::VoiceCall { duration_secs: 187 }, Some(OperationReference::Call(call_id)),).await?;OperationInput Variants
Section titled “OperationInput Variants”Each variant carries the operation’s native measurement. AiText carries both model_tier (pricing) and feature (reporting):
| Variant | Fields | Unit |
|---|---|---|
VoiceCall | duration_secs: i64 | Seconds → ceil-minutes |
VoiceRecording | duration_secs: i64 | Seconds → ceil-minutes |
SmsOutbound | segments: i32 | Segments |
SmsInbound | segments: i32 | Segments |
MmsOutbound | count: i32 | Messages |
AiText | tokens: i64, model_tier: AiModelTier, feature: AiUsageFeature | Tokens → ceil-1K |
Transcription | duration_secs: i64 | Seconds → ceil-minutes |
RealtimeOpenai | duration_secs: i64 | Seconds → ceil-minutes |
RealtimeGemini | duration_secs: i64 | Seconds → ceil-minutes |
EmailOutbound | count: i32 | Emails |
Dual dispatch
Section titled “Dual dispatch”OperationInput dispatches to both axes:
operation_type()→ follows the feature axis viaAiUsageFeature::to_op_type()billing_dimension()→ follows the tier axis viaBillingDimension::from_ai_model_tier()
Two calls with the same model_tier but different feature values produce different OperationType values but the same BillingDimension — same cost, different reporting buckets.
Credits-Only Waterfall
Section titled “Credits-Only Waterfall”record_operation() converts native units to credits once before the waterfall starts, then deducts in a single unit (credits) across four pools:
native units → credits_required = ceil(units × billing_config.*_credits rate) │ ▼ ┌─────────────────────┐ │ 1. Op-type pool │ org_budget.*_included (per-operation credits) └────────┬────────────┘ │ remainder ▼ ┌─────────────────────┐ │ 2. Included credits │ org_budget.included_credits (general pool) └────────┬────────────┘ │ remainder ▼ ┌─────────────────────┐ │ 3. Purchased credits│ org_budget.purchased_credits (top-ups) └────────┬────────────┘ │ shortfall ▼ ┌─────────────────────┐ │ 4. Overdraft │ Drives included_credits negative └────────┬────────────┘ │ remaining shortfall ▼ OverdraftLimitExceededThe waterfall uses BillingDimension to look up rates and allowances — not OperationType. This means adding a new AI feature variant requires no schema changes to billing_config or subscription_tier.
Key details
Section titled “Key details”- Tier gate: if
subscription_tier.*_includedisNULLfor this billing dimension, the operation is rejected withOperationNotAllowed— regardless of credit balance. - Op-type pool:
org_budget.*_includedstores credits (not native units). Cycle reset refills this fromsubscription_tier.*_included. - Defensive floors:
op_pool_remainingandpurchased_creditsare floored at 0 before arithmetic to prevent negative DB values from inflating downstream deductions. - Overdraft: shortfall beyond purchased credits drives
included_creditsnegative. Runaway orgs (no limit) absorb all shortfall; bounded orgs block at-overdraft_limit. - Concurrency:
SELECT ... FOR UPDATEonorg_budgetserializes concurrent ops per org.
Credit conversion
Section titled “Credit conversion”The billing_config table holds one rate per billing dimension (e.g. voice_call_credits = 15). Conversion happens in credits_for():
fn credits_for(units: Decimal, rate: i32) -> Result<i32, AppError> { let credits = units * Decimal::from(rate); credits.ceil().to_i32() .ok_or_else(|| AppError::Internal("credit calculation overflow".into()))}A 4-minute voice call at rate 15 costs 4 × 15 = 60 credits.
Tier Allowances (Credits)
Section titled “Tier Allowances (Credits)”Both subscription_tier.*_included and org_budget.*_included store credits, not native units. The migration m20260422_120000 multiplied each existing native-unit value by its billing_config.*_credits rate.
NULL (operation not available on tier) and 0 are preserved.
OperationReceipt
Section titled “OperationReceipt”record_operation() returns a receipt showing the credit breakdown:
pub struct OperationReceipt { pub operation_id: Uuid, pub operation_type: OperationType, pub units: Decimal, // Native units (for audit) pub free_units_used: Decimal, // Credits from op-type pool (name kept for schema stability) pub included_credits: i32, // Credits from included pool pub purchased_credits: i32, // Credits from purchased pool pub overdraft_credits: i32, // Portion that drove included_credits negative}free_units_used stores credits (not native units) despite its column name — the DB schema retains the original name for stability.
Pre-flight Check
Section titled “Pre-flight Check”Use check_operation_allowed() for a lightweight check before starting expensive operations. It takes &OperationInput and resolves the per-tier allowance from the billing dimension, verifying subscription status and tier gates without locking org_budget.
Operation References
Section titled “Operation References”Each operation links back to its source entity:
pub enum OperationReference { Call(Uuid), Message(Uuid), AiUsageLog(Uuid),}Stored as (reference_type, reference_id) on the operation table.
Adding a New AI Feature
Section titled “Adding a New AI Feature”When you add a new AiUsageFeature variant, update these four sync points:
OperationType— add theAi*variant with matchingas_str()/label()/FromStrAiUsageFeature::to_op_type()— add the 1:1 mapping- UI grouping in
cycle_usage_section_component.rs— the new variant auto-groups under “AI usage” - No pricing changes needed —
BillingDimensionstays unchanged; pricing is model-tier based
Migration: Per-Area Backfill
Section titled “Migration: Per-Area Backfill”Migration m20260430_120000_per_area_op_audit backfilled existing ai_text_* rows to area-shaped variants:
- Joins
operation.reference_id→ai_usage_log.idto resolve the originalAiUsageFeature - Sets
operation_type = 'ai_' || ai_usage_log.feature - Orphan rows (no matching
ai_usage_log) →ai_text_legacy - Pre/post
RAISE NOTICEdiagnostics verify per-org credit totals are unchanged down()is best-effort dev rollback only (emitsRAISE WARNING)
Pre-Launch Escape Hatch
Section titled “Pre-Launch Escape Hatch”Orgs without an org_budget row (not yet onboarded) get a zero-cost receipt and a warn! log. This escape is removed once all orgs are backfilled with subscriptions.