Skip to content

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.

Billing uses two separate axes to answer different questions:

AxisTypeQuestion it answersShape
ReportingOperationType”What is feature X costing us?”Area-shaped — one variant per AI feature
PricingBillingDimension”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.

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 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 AiModelTierBillingDimension. The same workload costs the same credits regardless of which AI feature triggered it.

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?;

Each variant carries the operation’s native measurement. AiText carries both model_tier (pricing) and feature (reporting):

VariantFieldsUnit
VoiceCallduration_secs: i64Seconds → ceil-minutes
VoiceRecordingduration_secs: i64Seconds → ceil-minutes
SmsOutboundsegments: i32Segments
SmsInboundsegments: i32Segments
MmsOutboundcount: i32Messages
AiTexttokens: i64, model_tier: AiModelTier, feature: AiUsageFeatureTokens → ceil-1K
Transcriptionduration_secs: i64Seconds → ceil-minutes
RealtimeOpenaiduration_secs: i64Seconds → ceil-minutes
RealtimeGeminiduration_secs: i64Seconds → ceil-minutes
EmailOutboundcount: i32Emails

OperationInput dispatches to both axes:

  • operation_type() → follows the feature axis via AiUsageFeature::to_op_type()
  • billing_dimension() → follows the tier axis via BillingDimension::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.

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
OverdraftLimitExceeded

The 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.

  • Tier gate: if subscription_tier.*_included is NULL for this billing dimension, the operation is rejected with OperationNotAllowed — regardless of credit balance.
  • Op-type pool: org_budget.*_included stores credits (not native units). Cycle reset refills this from subscription_tier.*_included.
  • Defensive floors: op_pool_remaining and purchased_credits are floored at 0 before arithmetic to prevent negative DB values from inflating downstream deductions.
  • Overdraft: shortfall beyond purchased credits drives included_credits negative. Runaway orgs (no limit) absorb all shortfall; bounded orgs block at -overdraft_limit.
  • Concurrency: SELECT ... FOR UPDATE on org_budget serializes concurrent ops per org.

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.

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.

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.

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.

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.

When you add a new AiUsageFeature variant, update these four sync points:

  1. OperationType — add the Ai* variant with matching as_str() / label() / FromStr
  2. AiUsageFeature::to_op_type() — add the 1:1 mapping
  3. UI grouping in cycle_usage_section_component.rs — the new variant auto-groups under “AI usage”
  4. No pricing changes neededBillingDimension stays unchanged; pricing is model-tier based

Migration m20260430_120000_per_area_op_audit backfilled existing ai_text_* rows to area-shaped variants:

  • Joins operation.reference_idai_usage_log.id to resolve the original AiUsageFeature
  • Sets operation_type = 'ai_' || ai_usage_log.feature
  • Orphan rows (no matching ai_usage_log) → ai_text_legacy
  • Pre/post RAISE NOTICE diagnostics verify per-org credit totals are unchanged
  • down() is best-effort dev rollback only (emits RAISE WARNING)

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.