Restricted Access: This documentation is only accessible to @tenzo.ai email addresses.
Overview
The billing system tracks credit usage across calls and SMS messages. Each organization has a billing model that defines how credits are consumed. Credits are added manually by support and deducted automatically when calls complete or SMS messages are sent/received.
Credits remaining = SUM(billing_credit_additions.credits_added) - SUM(billing_credit_usage.credits_used)
Database Tables
Three tables power the billing system:
| Table | Purpose |
|---|
org_billing_settings | Stores which billing model an org uses (one row per org) |
billing_credit_additions | Records every credit top-up (support adds X credits) |
billing_credit_usage | Records every billable action (call events, SMS events) |
billing_credit_usage Columns
| Column | Type | Description |
|---|
credit_usage_id | UUID PK | Unique row identifier |
org_id | VARCHAR FK | Organization being billed |
usage_type | billing_usage_type_enum | What kind of charge (see Usage Types below) |
usage_key | VARCHAR(255) UNIQUE | Idempotency key — prevents double-charging |
credits_used | Float | Credit amount deducted |
units | Float nullable | Rounded minutes or SMS segments |
call_id_str | VARCHAR nullable | Call ID (string, for backward compat) |
call_id_fk | UUID FK nullable | Call ID (typed FK to calls.call_id) |
sms_message_id | VARCHAR(255) nullable | Twilio message SID for SMS events |
sms_message_length | Integer nullable | Character length of the SMS |
billing_model | VARCHAR | Which model was active when this charge was created |
created_at | DateTime | When the charge was recorded |
Usage Types (BillingUsageType enum)
| Value | Description |
|---|
CALL_FLAT | Single flat charge per call (PER_INTERVIEW, INTERVIEW_LENGTH models) |
CALL_MINUTE | Per-minute charge (PER_CREDIT, LUXUS models) |
CALL_ATTEMPT | Attempt charge — call was dialed (LUXUS only) |
CALL_ANSWERED | Answered bonus — candidate picked up (LUXUS only) |
SMS_SENT | Outbound SMS charge |
SMS_RECEIVED | Inbound SMS charge |
Billing Models
Each organization is assigned one billing model via org_billing_settings. The model determines how credits are calculated for calls and SMS.
PER_INTERVIEW
Flat 1 credit per qualifying interview.
| Condition | Credits |
|---|
question_completion_rate > 0 | 1.0 |
| No questions answered | 0 (no charge) |
No SMS billing.
INTERVIEW_LENGTH
Length-based flat rate per qualifying interview.
| Condition | Credits |
|---|
| Call < 10 minutes, completion rate > 0 | 1.0 |
| Call >= 10 minutes, completion rate > 0 | 2.0 |
| No questions answered | 0 (no charge) |
No SMS billing.
PER_CREDIT
Per-minute call billing + per-segment SMS billing.
Calls:
| Condition | Credits |
|---|
| Any call with duration > 0 | ceil(minutes) |
| Zero duration | 0 (no charge) |
No completion rate gate — voicemails and no-answers with duration still cost credits.
SMS:
| Direction | Credits |
|---|
| Outbound | 0.2 * ceil(chars / 160) |
| Inbound | 0.2 * ceil(chars / 160) |
LUXUS
Multi-event billing with separate charges for attempt, minutes, and answered bonus.
Calls:
| Event | Condition | Credits |
|---|
| Attempt | CallStatus.attempt_completed | 0.3 |
| Minutes | Answered + duration > 0 | 0.5 * ceil(minutes) |
| Answered bonus | Answered | 0.3 |
A single answered call can produce up to 3 billing events. An unanswered call (voicemail, no-answer) produces only the attempt event.
SMS:
| Direction | Credits |
|---|
| Outbound | 0.1 * ceil(chars / 160) |
| Inbound | 0.2 flat (any length) |
PER_PLACEMENT
No automatic billing. Credits are not deducted for calls or SMS.
Idempotency
Every billing event has a usage_key that is unique in the database. The DAO uses ON CONFLICT (usage_key) DO NOTHING when inserting, so retries and concurrent writes cannot double-charge.
Key Patterns
| Pattern | Used by | Example |
|---|
call:{call_id} | PER_INTERVIEW, INTERVIEW_LENGTH | call:abc-123 |
call:{call_id}:minutes:{n} | PER_CREDIT, LUXUS | call:abc-123:minutes:6 |
call:{call_id}:attempt | LUXUS | call:abc-123:attempt |
call:{call_id}:answered | LUXUS | call:abc-123:answered |
sms:out:{twilio_sid} | Outbound SMS | sms:out:SM123abc |
sms:in:{twilio_sid} | Inbound SMS | sms:in:SM456def |
Integration Points
Billing events are generated at three points in the application:
Call Billing
CallPostProcessor._record_billing_credit_usage() (call_post_processor.py:2163) fires after every completed call:
- Loads the call with its campaign relationship to get
org_id
- Fetches
org_billing_settings for the org
- Calls
generate_call_billing_events(call, billing_model) — pure function, no I/O
- Passes the resulting events to
billing_dao.record_billing_events()
- Errors are caught and logged — billing failures never crash the post-processing pipeline
Outbound SMS Billing
send_smses() (texting_helpers.py:417) fires after each Twilio message is sent:
- Fetches billing settings for the campaign’s org
- Calls
generate_sms_billing_event(message_length, "outbound", sent_message.sid, billing_model)
- If the model charges for SMS, records the event via
billing_dao.record_sms_billing_event()
Inbound SMS Billing
failed_call_sms() (text_webhooks.py:660) fires on incoming SMS webhooks:
- Resolves the campaign and org from the most recent call for the sender
- Fetches billing settings for the org
- Calls
generate_sms_billing_event(message_length, "inbound", message_sid, billing_model)
- If the model charges for SMS, records the event via
billing_dao.record_sms_billing_event()
Architecture
The billing system follows the project’s separation of concerns:
Client Billing Visibility
The billing page is gated behind the CLIENT_BILLING_PAGE_VISIBLE feature flag (per-org, default off). When enabled, non-support users see a read-only billing page with credits remaining, usage history, and addition history. Support-only controls (billing model selector, add credits, backfill, placements tab) remain hidden.
| User type | Flag OFF | Flag ON |
|---|
| Support | Full billing + placements | Full billing + placements |
| Client | No billing nav or page | Read-only billing page |
Enabling for an Org
No admin UI — update directly in the org_feature_flags table:
INSERT INTO org_feature_flags (id, org_id, flag_name, enabled)
VALUES (gen_random_uuid(), '<org_id>', 'client_billing_page_visible', true)
ON CONFLICT (org_id, flag_name) DO UPDATE SET enabled = true;
Or via DAO: feature_flags_dao.set_flag(org_id, FeatureFlag.CLIENT_BILLING_PAGE_VISIBLE, True)
Backfill Script
server/scripts/backfill_billing_event_usage.py normalizes existing data for the event model migration. It is idempotent and safe to run multiple times.
Usage:
python -m scripts.backfill_billing_event_usage # Dry run (no changes)
python -m scripts.backfill_billing_event_usage --write-changes # Persist changes
Steps:
- Rename
PER_CREDIT to INTERVIEW_LENGTH in org_billing_settings.billing_model
- Rename
PER_CREDIT to INTERVIEW_LENGTH in billing_credit_usage.billing_model
- Backfill
usage_key from call_id_str where missing (usage_key = 'call:' || call_id_str)
- Normalize NULL
usage_type to CALL_FLAT
- Validation check — reports any rows still missing
usage_key
API Endpoints
Support Endpoints (/support/billing/{org_id}/...)
| Method | Path | Description |
|---|
GET | /billing/{org_id} | Full billing info (model, credits remaining, totals) |
GET | /billing/{org_id}/credits-used | Total credits used |
GET | /billing/{org_id}/credits-added | Credit addition history |
GET | /billing/{org_id}/credits-usage | Credit usage history |
POST | /billing/{org_id}/credits | Add credits to org |
PUT | /billing/{org_id}/model | Update billing model |
POST | /billing/{org_id}/backfill | Backfill credit usage from calls |
GET | /billing/{org_id}/client-visibility | Get client billing visibility flag |
PUT | /billing/{org_id}/client-visibility | Toggle client billing visibility flag |
Client Endpoints (/billing/...)
All client endpoints require CLIENT_BILLING_PAGE_VISIBLE flag to be enabled. Auth context provides org_id.
| Method | Path | Description |
|---|
GET | /billing/info | Billing info for authenticated org |
GET | /billing/credits-added | Credit addition history for authenticated org |
GET | /billing/credits-usage | Credit usage history for authenticated org |
File Index
| File | Purpose |
|---|
server/billing/billing_helpers.py | Pure functions — generate_call_billing_events(), generate_sms_billing_event(), calculate_credits_for_call() |
server/billing/billing_models.py | Pydantic models — BillingEvent, API request/response schemas |
server/billing/billing_api.py | FastAPI endpoints — support + client routes, feature flag gating |
server/dao/billing_dao.py | DAO — record_billing_events(), record_sms_billing_event(), backfill_credit_usage_for_org() |
server/models.py | SQLAlchemy models — OrgBillingSettings, BillingCreditUsage, BillingCreditAddition, BillingUsageType enum |
server/caller/call_post_processor_service/call_post_processor.py | Call billing trigger — _record_billing_credit_usage() |
server/texter/texting_helpers.py | Outbound SMS billing trigger — inside send_smses() |
server/texter/text_webhooks.py | Inbound SMS billing trigger — inside failed_call_sms() |
server/scripts/backfill_billing_event_usage.py | Data migration script for event model rollout |
server/feature_flags/feature_flag_enum.py | CLIENT_BILLING_PAGE_VISIBLE flag definition |
ui/src/pages/billing/BillingPage.tsx | Frontend billing page with feature flag gating |
ui/src/components/side-nav-bar/SideNavBar.tsx | Conditional billing nav item |
server/tests/unit/test_billing.py | Unit tests for billing calculations (48 tests) |
server/tests/unit/test_billing_dao.py | DAO tests for event recording and idempotency |
server/tests/unit/test_billing_wiring.py | Wiring tests for integration trigger points (3 tests) |