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.

The OperationType enum defines all billable event categories:

pub enum OperationType {
VoiceCall, // Billed in ceil-minutes
VoiceRecording, // Billed in ceil-minutes
SmsOutbound, // Billed per segment
SmsInbound, // Billed per segment
MmsOutbound, // Billed per message
AiTextBudget, // Billed per 1K tokens (DeepSeek, Gemini Flash Lite)
AiTextMid, // Billed per 1K tokens (Gemini Pro, GPT-4.1)
AiTextPremium, // Billed per 1K tokens (Claude Sonnet)
AiTextUltra, // Billed per 1K tokens (Claude Opus)
Transcription, // Billed in ceil-minutes
RealtimeOpenai, // Billed in ceil-minutes
RealtimeGemini, // Billed in ceil-minutes
EmailOutbound, // Billed per email
}

AI text operations map from AiModelTier (Budget, Mid, Premium, Ultra) via OperationType::from_ai_model_tier().

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:

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

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
  • Tier gate: if subscription_tier.*_included is NULL for this operation type, 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 operation type (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:

-- Example: voice_call_included was 600 minutes, rate is 15 credits/min
-- After migration: voice_call_included = 9000 credits
UPDATE subscription_tier SET
voice_call_included = voice_call_included * (SELECT voice_call_credits FROM billing_config LIMIT 1),
-- ... 12 more columns

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

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.