Billing Domain Implementation¶
Version: 1.0.0 Last Updated: 2026-02-11 Status: Stable
Overview¶
The Billing domain manages team balance accounting, transaction history, auto-refill configuration, and invoice generation. It maintains the source-of-truth for team spending and provides audit trails for all financial operations.
Key Responsibilities: - Track team balance across payment methods - Record all financial transactions (deposits, charges, refunds) - Manage auto-refill thresholds and triggers - Generate and serve invoices as PDF receipts - Support multiple payment gateways (Stripe, PayPal)
Architecture¶
graph TB
BA["BillingAccount<br/>(Team Balance)<br/>- balance<br/>- auto_refill_config"]
BT["BillingTransaction<br/>(Audit Trail)<br/>- type<br/>- amount<br/>- gateway<br/>- status"]
BPM["BillingPaymentMethod<br/>(Saved Cards)<br/>- external_id<br/>- card_last_four<br/>- is_default"]
INV["BillingInvoice<br/>(Receipts)<br/>- transaction_id<br/>- pdf_url<br/>- issued_at"]
BA -->|has many| BT
BA -->|has many| BPM
BT -->|has one| INV
BA -->|linked to| TEAM["Team"]
BT -->|linked to| USER["User<br/>created_by"]
BPM -->|linked to| USER
style BA fill:#3b82f6,color:#fff
style BT fill:#10b981,color:#fff
style BPM fill:#f59e0b,color:#fff
style INV fill:#8b5cf6,color:#fff
Data Model¶
Entities¶
BillingAccount¶
Team-scoped billing account holding balance and payment setup.
class BillingAccount(Base, TimestampMixin):
id: int # Primary key
team_id: int # FK → Team (unique)
balance: Decimal # Current team balance (always >= 0)
currency: str = "USD" # ISO 4217 code
stripe_customer_id: str | None # Stripe customer ID
auto_refill_config: dict | None # Config below
created_at: datetime
updated_at: datetime
auto_refill_config JSON Structure:
{
"enabled": bool,
"threshold": 50.0, // Refill when balance <= $50
"amount": 100.0, // Add $100 on trigger
"payment_method_id": "pm_1234abcd", // Stripe saved card
"last_triggered_at": "2026-01-15T10:30:00Z"
}
BillingTransaction¶
Immutable audit record of every financial change.
class BillingTransaction(Base, TimestampMixin):
id: int # Primary key
billing_account_id: int # FK → BillingAccount
created_by_user_id: int | None # User who initiated (nullable for system)
# Transaction details
transaction_type: str # "deposit" | "charge" | "refund"
amount: Decimal # Always > 0 (direction from type)
currency: str = "USD" # ISO 4217 code
status: str # "pending" | "completed" | "failed" | "refunded"
# Payment gateway metadata
payment_gateway: str # "stripe" | "paypal"
stripe_payment_intent_id: str | None
# Balance snapshot
balance_before: Decimal | None
balance_after: Decimal | None
# Details
description: str | None # Human-readable (e.g., "Stripe one-time payment, card ****1234")
metadata: dict | None # Gateway-specific data
created_at: datetime
updated_at: datetime
BillingPaymentMethod¶
Tokenized payment method (never stores raw card data).
class BillingPaymentMethod(Base, TimestampMixin):
id: int # Primary key
billing_account_id: int # FK → BillingAccount
user_id: int | None # FK → User (who added it)
# Stripe metadata
provider: str = "stripe" # Future: "paypal"
external_method_id: str # e.g., "pm_1234abcd"
# Safe display info
card_brand: str | None # "visa" | "mastercard" | "amex"
card_last_four: str | None # Last 4 digits (e.g., "4242")
card_exp_month: int | None
card_exp_year: int | None
# Settings
is_default: bool = False
payment_metadata: dict | None # Additional Stripe data
created_at: datetime
updated_at: datetime
BillingInvoice (NEW)¶
Generated PDF receipt for transactions.
class BillingInvoice(Base, TimestampMixin):
id: int # Primary key
billing_transaction_id: int # FK → BillingTransaction (unique)
# Invoice details
invoice_number: str # e.g., "INV-2026-00001"
issued_at: datetime # Issue timestamp
# Storage
pdf_url: str # S3/CDN URL to PDF
pdf_storage_path: str # Storage bucket path
# Metadata for display
amount: Decimal # Amount on invoice
currency: str = "USD"
payment_method_description: str # e.g., "Stripe ••••1234"
created_at: datetime
updated_at: datetime
Business Logic¶
Core Operations¶
1. Create or Get Billing Account¶
Operation: get_or_create_account(team_id: int)
Precondition: Team exists
Postcondition: BillingAccount with balance >= 0 exists for team
Side Effects: May insert new account with zero balance
Idempotent: Yes
Auto-creates account on first payment with zero balance.
2. Record Transaction¶
Operation: record_transaction(
team_id, transaction_type, amount, gateway,
payment_intent_id, description, metadata
)
Precondition: BillingAccount exists, amount > 0
Postcondition: BillingTransaction inserted with snapshot
Side Effects: Does NOT update balance immediately (async job)
Idempotent: Yes (via stripe_payment_intent_id uniqueness)
Returns: BillingTransaction object
Transaction type determines direction: - deposit: balance += amount - charge: balance -= amount (fails if insufficient) - refund: balance += amount
3. Update Balance¶
Operation: update_balance(
team_id, new_balance, reason
)
Precondition: BillingAccount exists
Postcondition: Balance updated, audit entry created
Side Effects: Invalidates Redis cache
Invariant: balance >= 0 (constraint enforced)
Called after payment confirmation or usage charges. Triggers auto-refill check if enabled.
4. Configure Auto-Refill¶
Operation: set_auto_refill(
team_id, enabled, threshold, amount,
payment_method_id
)
Precondition: User has "billing.edit" permission
Postcondition: auto_refill_config updated
Side Effects: Cache invalidation
Idempotent: Yes
Validates: - threshold > 0 - amount > 0 - payment_method_id exists for team
5. Trigger Auto-Refill¶
Operation: trigger_auto_refill(team_id)
Precondition: Auto-refill enabled, balance < threshold, no recent trigger
Postcondition: Stripe charge attempted, transaction recorded
Side Effects: May create BillingTransaction
Returns: Boolean (success)
Error Handling: Logs failure, sends owner email notification
Called asynchronously after balance updates. Debounced to prevent rapid retries.
6. Generate Invoice¶
Operation: generate_invoice(transaction_id)
Precondition: BillingTransaction exists, status = COMPLETED
Postcondition: BillingInvoice created, PDF generated, stored
Side Effects: Uploads PDF to S3, sends email to team owner
Returns: BillingInvoice with pdf_url
Generates PDF receipt from: - Transaction amount, date, method - Team name, company info - Invoice number (sequential) - Issuer business details
7. Download Invoice¶
Operation: download_invoice(invoice_id)
Precondition: Invoice exists, user owns team
Postcondition: PDF downloaded or redirected to S3 URL
Returns: Binary PDF or 302 redirect
Validates ownership, logs download for audit trail.
State Machine¶
Transaction Lifecycle:
stateDiagram-v2
[*] --> PENDING: Record transaction
PENDING --> COMPLETED: Payment confirmed
PENDING --> FAILED: Payment failed
FAILED --> [*]
COMPLETED --> REFUNDED: Refund issued
REFUNDED --> [*]
COMPLETED --> [*]: Expired (>90 days)
Auto-Refill Trigger Flow:
stateDiagram-v2
[*] --> CHECK: Balance updated
CHECK --> ELIGIBLE: balance < threshold && enabled
ELIGIBLE --> CHARGING: Acquire lock
CHARGING --> SUCCESS: Charge succeeded
SUCCESS --> UPDATE: Update balance & log
UPDATE --> [*]
CHARGING --> RETRY: Charge failed
RETRY --> BACKOFF: Wait (exponential)
BACKOFF --> CHARGING: Retry
CHARGING --> FINAL_FAIL: Max retries exhausted
FINAL_FAIL --> NOTIFY: Send owner email
NOTIFY --> [*]
CHECK --> NOT_ELIGIBLE: Condition not met
NOT_ELIGIBLE --> [*]
Invariants¶
- Balance Non-Negative:
balance >= 0(enforced by constraint) - Transaction Immutability: Transactions never updated after creation (only status changes allowed)
- Audit Trail: Every balance change has corresponding transaction(s)
- Auto-Refill Debounce: Max one trigger per hour per team
- Invoice Uniqueness: One invoice per completed deposit transaction
- Currency Consistency: All amounts in transaction match billing account currency
API Endpoints¶
Account Management¶
| Endpoint | Method | Permission | Purpose |
|---|---|---|---|
/api/v1/billing/account |
GET | billing.view |
Get current balance & config |
/api/v1/billing/account/auto-refill |
PUT | billing.edit |
Update auto-refill config |
Transaction History¶
| Endpoint | Method | Permission | Purpose |
|---|---|---|---|
/api/v1/billing/transactions |
GET | billing.view |
List transactions with filters |
/api/v1/billing/transactions/{id} |
GET | billing.view |
Get single transaction |
Invoices (NEW)¶
| Endpoint | Method | Permission | Purpose |
|---|---|---|---|
/api/v1/billing/invoices |
GET | billing.view |
List invoices |
/api/v1/billing/invoices/{id}/download |
GET | billing.view |
Download receipt PDF |
/api/v1/billing/invoices/{id} |
GET | billing.view |
Get invoice metadata |
External Integrations¶
Stripe¶
Used For: - One-time payments (Payment Intents) - Auto-refill charges (Setup Intents + off-session charges) - Saving payment methods - Webhooks for payment confirmation
Key Methods:
stripe.PaymentIntent.create(
amount=amount_cents,
currency="usd",
customer=stripe_customer_id,
metadata={"team_id": team_id}
)
stripe.PaymentIntent.confirm(
intent_id,
payment_method=payment_method_id,
off_session=True
)
Virtuozzo (Cost Deductions)¶
Integration Point: After resource usage, deduct from balance
# Pseudocode
usage_cost = calculate_usage_cost(resource)
new_balance = current_balance - usage_cost
await update_balance(team_id, new_balance, reason=f"Charge for {resource}")
Email Service (Postmark)¶
Triggers: - Auto-refill success: "Auto-refill completed, new balance $X" - Auto-refill failure: "Auto-refill failed, action required" - Invoice issued: "Your receipt #INV-2026-00001"
PDF Generation (ReportLab)¶
Invoice PDF Includes: - Invoice header with number and date - Team and billing info - Transaction details (date, method, amount) - QR code (optional) linking to online receipt - Footer with company/business details
Caching Strategy¶
Redis Keys¶
# Account balance (60 seconds)
f"billing:{team_id}:balance" → Decimal
# Auto-refill config (5 minutes)
f"billing:{team_id}:auto_refill" → JSON
# Last trigger timestamp (24 hours)
f"billing:{team_id}:last_refill_trigger" → ISO datetime
# Rate limiting (per minute)
f"billing:{team_id}:transactions:limit" → int (count)
Invalidation Triggers¶
- On balance update
- On auto-refill config change
- On transaction creation (for transaction list caching)
Error Handling¶
| Error | Cause | Response | Recovery |
|---|---|---|---|
| Insufficient Balance | Charge > balance | 400 Bad Request | Suggest payment/top-up |
| Stripe Connection Error | Network/API down | 503 Service Unavailable | Retry with exponential backoff |
| Payment Intent Not Found | Corrupted state | 404 Not Found | Admin manual investigation |
| Webhook Signature Invalid | Man-in-middle/replay | 401 Unauthorized | Discard, log, alert security |
| Invoice PDF Generation Failed | ReportLab error | 500 Internal Error | Retry job, notify admin |
Performance Considerations¶
- Transaction Listing: Index on
(billing_account_id, created_at)for efficient pagination - Balance Lookups: Redis cache with 60-second TTL
- Auto-Refill Check: Per-team distributed lock to prevent race conditions
- Invoice Generation: Async job (RabbitMQ) to avoid blocking API response
- PDF Storage: S3 with CloudFront CDN for fast downloads
Testing¶
Unit Tests¶
- Balance update invariants
- Transaction type correctness
- Auto-refill trigger logic
- Invoice number sequence
Integration Tests¶
- Stripe payment flow (with test API keys)
- Auto-refill with insufficient balance
- Concurrent balance updates (lock behavior)
- Invoice generation and retrieval
E2E Tests¶
- Full payment flow: intent → confirmation → invoice download
- Auto-refill with real Stripe test card
Related Documentation¶
- Payments Domain - Payment method management
- Payment Processing - Stripe/PayPal integration details
- RBAC - Permission model (
billing.view,billing.edit)