A production-grade, horizontally scalable billing service for credit-based usage gating in CIRIS Agent. Designed to replace the Unlimit.com integration with a self-hosted solution featuring PostgreSQL replication, write verification, and zero-dictionary type safety.
Version: 0.1.0
- Horizontal Scalability: Stateless API instances with load balancing
- High Availability: PostgreSQL primary-replica replication
- Data Integrity: Write verification with read-after-write consistency
- Type Safety: Zero dictionary usage - full Pydantic + SQLAlchemy typing
- Idempotency: All mutations support idempotency keys
- Containerization: Complete Docker Compose orchestration
- Automatic Migrations: Database migrations run automatically at startup
┌─────────────┐
│ Nginx │ Load Balancer (port 8080)
└──────┬──────┘
│
├─────────┬─────────┬─────────
│ │ │
┌───▼──┐ ┌──▼───┐ ┌──▼───┐
│ API │ │ API │ │ API │ 3x Stateless Instances
│ #1 │ │ #2 │ │ #3 │
└───┬──┘ └──┬───┘ └──┬───┘
│ │ │
└────────┴─────────┘
│
┌──────▼────────┐
│ PgBouncer │ Connection Pooler
└──────┬────────┘
│
┌────────┴─────────┐
│ │
┌───▼────────┐ ┌────▼────────┐
│ PostgreSQL │───▶│ PostgreSQL │
│ Primary │ │ Replica │
└────────────┘ └─────────────┘
(Writes) (Reads)
- Docker & Docker Compose
- Python 3.11+ (for local development)
git clone <repository-url>
cd CIRISBilling
# Copy environment template
cp .env.example .env
# Edit .env with your passwords
vim .env# Start all services (3 API instances, Postgres, PgBouncer, Nginx)
docker-compose up -d
# View logs
docker-compose logs -fNote: Database migrations run automatically when the application starts. No manual migration step is required.
# Check API health through load balancer
curl http://localhost:8080/health
# Expected response:
# {
# "status": "healthy",
# "database": "connected",
# "timestamp": "2025-01-08T12:00:00Z"
# }POST /v1/billing/credits/check
Verify account has sufficient credits before interaction.
curl -X POST http://localhost:8080/v1/billing/credits/check \
-H "Content-Type: application/json" \
-d '{
"oauth_provider": "oauth:google",
"external_id": "user@example.com",
"wa_id": null,
"tenant_id": null,
"context": {
"agent_id": "datum",
"channel_id": "discord:123",
"request_id": "req-456"
}
}'Response 200 OK:
{
"has_credit": true,
"credits_remaining": 5000,
"plan_name": "pro",
"reason": null
}POST /v1/billing/charges
Deduct credits from account after successful interaction.
curl -X POST http://localhost:8080/v1/billing/charges \
-H "Content-Type: application/json" \
-d '{
"oauth_provider": "oauth:google",
"external_id": "user@example.com",
"amount_minor": 100,
"currency": "USD",
"description": "Agent interaction",
"idempotency_key": "msg-123",
"metadata": {
"message_id": "msg-123",
"agent_id": "datum",
"request_id": "req-456"
}
}'Response 201 Created:
{
"charge_id": "550e8400-e29b-41d4-a716-446655440000",
"account_id": "650e8400-e29b-41d4-a716-446655440000",
"amount_minor": 100,
"currency": "USD",
"balance_after": 4900,
"created_at": "2025-01-08T12:00:00Z",
"description": "Agent interaction",
"metadata": {
"message_id": "msg-123",
"agent_id": "datum",
"channel_id": null,
"request_id": "req-456"
}
}POST /v1/billing/credits
Add credits to account (purchase, grant, refund).
curl -X POST http://localhost:8080/v1/billing/credits \
-H "Content-Type: application/json" \
-d '{
"oauth_provider": "oauth:google",
"external_id": "user@example.com",
"amount_minor": 5000,
"currency": "USD",
"description": "Monthly subscription",
"transaction_type": "purchase",
"external_transaction_id": "stripe_ch_123",
"idempotency_key": "stripe_ch_123"
}'POST /v1/billing/accounts
Create new account or get existing (upsert).
curl -X POST http://localhost:8080/v1/billing/accounts \
-H "Content-Type: application/json" \
-d '{
"oauth_provider": "oauth:google",
"external_id": "user@example.com",
"initial_balance_minor": 1000,
"currency": "USD",
"plan_name": "free"
}'GET /v1/billing/accounts/{oauth_provider}/{external_id}
Retrieve account details.
curl http://localhost:8080/v1/billing/accounts/oauth:google/user@example.com| Code | Meaning | Example |
|---|---|---|
| 200 | OK | Credit check succeeded |
| 201 | Created | Charge/credit created |
| 402 | Payment Required | Insufficient credits |
| 403 | Forbidden | Account suspended |
| 404 | Not Found | Account doesn't exist |
| 409 | Conflict | Idempotency key conflict |
| 422 | Validation Error | Invalid request data |
| 500 | Internal Error | Database integrity error |
| 503 | Service Unavailable | Database down |
# Create virtual environment
python -m venv venv
source venv/bin/activate # or `venv\Scripts\activate` on Windows
# Install dependencies
pip install -r requirements-dev.txt
# Set environment variables
export DATABASE_URL="postgresql+asyncpg://billing_admin:password@localhost:5432/ciris_billing"
# Start development server (migrations run automatically)
python -m app.main# Install test dependencies
pip install -r requirements-dev.txt
# Run tests with coverage
pytest --cov=app --cov-report=html
# View coverage report
open htmlcov/index.html# Format code
black app/ tests/
# Lint code
ruff check app/ tests/
# Type check
mypy app/Scale API instances:
# Scale to 5 instances
docker-compose up -d --scale billing-api=5
# Nginx automatically load balances across all instancesThe system uses PostgreSQL streaming replication:
- Primary: Handles all writes (INSERT, UPDATE, DELETE)
- Replica: Handles reads (SELECT for credit checks, account lookups)
- PgBouncer: Connection pooling (1000 client connections → 25 database connections)
Replication lag is typically <100ms. Monitor with:
-- On replica
SELECT now() - pg_last_xact_replay_timestamp() AS replication_lag;# API health (through load balancer)
curl http://localhost:8080/health
# Individual instance health
curl http://localhost:8000/health # billing-api-1# Check PgBouncer stats
docker-compose exec pgbouncer psql -U billing_admin -p 5432 pgbouncer -c "SHOW STATS;"
# Check active connections
docker-compose exec postgres-primary psql -U billing_admin -d ciris_billing \
-c "SELECT count(*) FROM pg_stat_activity;"# All services
docker-compose logs -f
# Specific service
docker-compose logs -f billing-api-1
docker-compose logs -f postgres-primary
docker-compose logs -f nginx- accounts: User accounts with balance and status
- charges: Immutable ledger of credit deductions
- credits: Immutable ledger of credit additions
- credit_checks: Audit log of all credit check requests
accounts.balance_minor >= 0(non-negative balance)charges.balance_after = balance_before - amount_minor(balance consistency)credits.balance_after = balance_before + amount_minor(balance consistency)- Unique constraint on
(oauth_provider, external_id)- prevents duplicate accounts - Legacy unique constraint on
(oauth_provider, external_id, wa_id, tenant_id)for backwards compatibility
Environment variables:
| Variable | Default | Description |
|---|---|---|
DATABASE_URL |
(required) | Primary database connection URL |
DATABASE_READ_URL |
(optional) | Replica database URL (falls back to primary) |
API_PORT |
8000 |
API server port |
API_HOST |
0.0.0.0 |
API server host |
LOG_LEVEL |
INFO |
Logging level (DEBUG, INFO, WARN, ERROR) |
DB_PASSWORD |
(required) | Database password |
REPLICATION_PASSWORD |
(required) | Replication user password |
All write operations verify data integrity:
# Pattern for all writes
async def create_charge(...):
# 1. Insert
db.add(charge)
await db.flush()
# 2. Verify - read back
verified = await db.get(Charge, charge.id)
if verified is None:
raise WriteVerificationError()
# 3. Validate invariants
if verified.balance_after != expected:
raise DataIntegrityError()
return verifiedThis ensures:
- No silent write failures
- Balance consistency maintained
- Race conditions detected
Zero dictionary usage - all data structures are typed:
# ✅ Correct - Strongly typed
account = AccountIdentity(
oauth_provider="oauth:google",
external_id="user@example.com",
wa_id=None,
tenant_id=None
)
# ❌ Wrong - Never use dicts
account = {
"oauth_provider": "oauth:google",
"external_id": "user@example.com"
}- SQL Injection: Protected by SQLAlchemy ORM parameterization
- Rate Limiting: Nginx rate limiting (100 req/s per IP)
- Connection Pooling: PgBouncer prevents connection exhaustion
- TLS: Enable HTTPS in production (Nginx SSL termination)
# Backup primary database
docker-compose exec postgres-primary pg_dump -U billing_admin ciris_billing \
| gzip > backup_$(date +%Y%m%d_%H%M%S).sql.gz# Restore from backup
gunzip -c backup_20250108_120000.sql.gz | \
docker-compose exec -T postgres-primary psql -U billing_admin ciris_billingCheck database connectivity:
docker-compose logs postgres-primary
docker-compose exec billing-api-1 psql -U billing_admin -h postgres-primary -d ciris_billing -c "SELECT 1;"Check balance consistency:
SELECT
a.id,
a.balance_minor as current_balance,
COALESCE(SUM(cr.amount_minor), 0) - COALESCE(SUM(ch.amount_minor), 0) as calculated_balance
FROM accounts a
LEFT JOIN credits cr ON cr.account_id = a.id
LEFT JOIN charges ch ON ch.account_id = a.id
GROUP BY a.id, a.balance_minor
HAVING a.balance_minor != COALESCE(SUM(cr.amount_minor), 0) - COALESCE(SUM(ch.amount_minor), 0);Check replication status:
-- On primary
SELECT * FROM pg_stat_replication;
-- On replica
SELECT now() - pg_last_xact_replay_timestamp() AS lag;Replace UnlimitCreditProvider in CIRISAgent with:
from ciris_engine.logic.services.infrastructure.resource_monitor import CreditGateProtocol
class CirisBillingProvider(CreditGateProtocol):
def __init__(self, base_url: str = "http://localhost:8080"):
self.base_url = base_url
async def check_credit(self, account, context) -> CreditCheckResult:
async with httpx.AsyncClient() as client:
response = await client.post(
f"{self.base_url}/v1/billing/credits/check",
json={
"oauth_provider": account.provider,
"external_id": account.account_id,
"wa_id": account.authority_id,
"tenant_id": account.tenant_id,
"context": {
"agent_id": context.agent_id,
"channel_id": context.channel_id,
"request_id": context.request_id,
}
}
)
data = response.json()
return CreditCheckResult(
has_credit=data["has_credit"],
credits_remaining=data.get("credits_remaining"),
reason=data.get("reason"),
plan_name=data.get("plan_name"),
)
async def spend_credit(self, account, request, context) -> CreditSpendResult:
# Similar implementation for charges endpoint
passSee LICENSE file.
For issues and questions, see claude.md for detailed design documentation.