> ## Documentation Index
> Fetch the complete documentation index at: https://f4c7a9e2d8b1-docs.tenzo.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Billing System

> Architecture and implementation guide for the credit-based billing system

<Warning>
  **Restricted Access**: This documentation is only accessible to @tenzo.ai email addresses.
</Warning>

## 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:

```mermaid theme={null}
sequenceDiagram
    participant Call as Completed Call
    participant CPP as CallPostProcessor
    participant SMS_Out as send_smses()
    participant SMS_In as failed_call_sms()
    participant Helpers as billing_helpers
    participant DAO as billing_dao
    participant DB as billing_credit_usage

    Call->>CPP: Call completes
    CPP->>DAO: Load org billing settings
    CPP->>Helpers: generate_call_billing_events(call, model)
    Helpers-->>CPP: List[BillingEvent]
    CPP->>DAO: record_billing_events(org_id, events)
    DAO->>DB: INSERT ... ON CONFLICT DO NOTHING

    SMS_Out->>DAO: Load org billing settings
    SMS_Out->>Helpers: generate_sms_billing_event(len, "outbound", sid, model)
    Helpers-->>SMS_Out: BillingEvent | None
    SMS_Out->>DAO: record_sms_billing_event(org_id, event)
    DAO->>DB: INSERT ... ON CONFLICT DO NOTHING

    SMS_In->>DAO: Load org billing settings
    SMS_In->>Helpers: generate_sms_billing_event(len, "inbound", sid, model)
    Helpers-->>SMS_In: BillingEvent | None
    SMS_In->>DAO: record_sms_billing_event(org_id, event)
    DAO->>DB: INSERT ... ON CONFLICT DO NOTHING
```

### Call Billing

`CallPostProcessor._record_billing_credit_usage()` (`call_post_processor.py:2163`) fires after every completed call:

1. Loads the call with its campaign relationship to get `org_id`
2. Fetches `org_billing_settings` for the org
3. Calls `generate_call_billing_events(call, billing_model)` — pure function, no I/O
4. Passes the resulting events to `billing_dao.record_billing_events()`
5. 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:

1. Fetches billing settings for the campaign's org
2. Calls `generate_sms_billing_event(message_length, "outbound", sent_message.sid, billing_model)`
3. 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:

1. Resolves the campaign and org from the most recent call for the sender
2. Fetches billing settings for the org
3. Calls `generate_sms_billing_event(message_length, "inbound", message_sid, billing_model)`
4. 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:

```mermaid theme={null}
flowchart TD
    subgraph PureFunctions["Pure Functions (no I/O)"]
        GCE["generate_call_billing_events()\nCall → List[BillingEvent]"]
        GSE["generate_sms_billing_event()\nSMS params → BillingEvent | None"]
        CCF["calculate_credits_for_call()\nConvenience sum wrapper"]
    end

    subgraph DAO["Data Access Layer"]
        RBE["record_billing_events()\nON CONFLICT DO NOTHING"]
        RSBE["record_sms_billing_event()\nSingle-event wrapper"]
        BF["backfill_credit_usage_for_org()\nBulk generate + insert"]
    end

    subgraph Triggers["Integration Points"]
        CPP["CallPostProcessor\n_record_billing_credit_usage()"]
        SS["send_smses()"]
        FCS["failed_call_sms()"]
    end

    subgraph API["API Layer"]
        SA["Support API\n/support/billing/{org_id}/*"]
        CA["Client API\n/billing/*"]
    end

    CPP --> GCE
    CPP --> RBE
    SS --> GSE
    SS --> RSBE
    FCS --> GSE
    FCS --> RSBE
    SA --> DAO
    CA --> DAO

    style PureFunctions fill:#e8f4f8,stroke:#169fff
    style DAO fill:#f0f8e8,stroke:#5cb85c
    style Triggers fill:#fef8e8,stroke:#f0ad4e
    style API fill:#f8e8f4,stroke:#c85cb8
```

## 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:

```sql theme={null}
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:**

```bash theme={null}
python -m scripts.backfill_billing_event_usage           # Dry run (no changes)
python -m scripts.backfill_billing_event_usage --write-changes  # Persist changes
```

**Steps:**

1. Rename `PER_CREDIT` to `INTERVIEW_LENGTH` in `org_billing_settings.billing_model`
2. Rename `PER_CREDIT` to `INTERVIEW_LENGTH` in `billing_credit_usage.billing_model`
3. Backfill `usage_key` from `call_id_str` where missing (`usage_key = 'call:' || call_id_str`)
4. Normalize NULL `usage_type` to `CALL_FLAT`
5. 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)                                                             |
