Billing System Documentation #
This document describes the billing and subscription management system in BonsAI, which integrates Stripe for payment processing and Clerk for free trial management.
Architecture Overview #
The billing system consists of several components working together:
- Stripe Integration: Handles payment processing, subscription management, and billing events
- Clerk Integration: Manages organizations and user authentication, also free trial limitations
- Billing API: Provides endpoints for retrieving billing information
- Cache Layer: Redis-based caching for performance optimization
- Webhook Handlers: Process real-time updates from Stripe and Clerk
Billing Flow Diagram #
graph TB
subgraph "User Actions"
A[User Creates Organization]
B[User Manages Subscription]
end
subgraph "Clerk"
C[Organization Created Event]
D[Organization Metadata]
end
subgraph "BonsAPI"
E[Clerk Webhook Handler]
F[Stripe Webhook Handler]
G[Billing Service]
H[Organization Repository]
I[Billing API Endpoints]
end
subgraph "Stripe"
J[Customer Created]
K[Subscription Events]
L[Customer Portal]
end
subgraph "Cache Layer"
M[Redis Cache]
end
subgraph "Frontend"
N[Billing Page]
O[Billing Alerts]
end
A --> C
C --> E
E --> J
E --> D
J --> K
K --> F
F --> M
B --> L
I --> G
G --> H
G --> M
G --> J
N --> I
O --> I
M --> I
style A fill:#e1f5fe
style B fill:#e1f5fe
style N fill:#e1f5fe
style O fill:#e1f5fe
style M fill:#fff3e0
style J fill:#e8f5e9
style K fill:#e8f5e9
style L fill:#e8f5e9
Core Components #
1. Stripe Client (libs/rust/bonsai-integration/src/billing/stripe/)
#
The Stripe client provides methods for:
- Creating customers
- Managing subscriptions
- Handling webhooks
- Retrieving billing information
2. Billing Service (apps/bonsapi/src/service/billing/)
#
The billing service layer handles:
- Reading billing information
- Managing trial periods
- Checking subscription status
- Calculating usage limits
3. Organization Management #
Organizations are the billable entities in BonsAI:
- Each organization has a
stripe_customer_idfield - Organizations are created via Clerk webhooks
- Stripe customers are automatically created for new organizations
Coupons #
Stripe coupon ids are random, so a coupon is identified by its metadata.category (welcome, referrer) plus percent_off.
PricingService.ListCoupons(bonsapi-internal gRPC, cluster-only): every coupon with itscategory(OTHERwhen unrecognised), filterable bycategoryandonly_valid. Uncached, sotimes_redeemedis live. Open to every internal caller.- MCP
list_coupons(tofu-external-mcp): served to the sales agent only, for the webapp onboarding chat. Returns uncategorised coupons only, so welcome and referrer coupons never reach sales. PricingReadService::find_coupon(category, percent): bonsapi’s own lookup, from a 5-minute cache. Returns the oldest redeemable match, or logsdiscount_coupon_missing.percentis a whole number.
Onboarding offer #
The Welcome Discount offer, its window experiment (ONBOARDING_OFFER_WINDOWS in Doppler), decline and the logs are documented under Onboarding Discounts.
Checkout. POST /internal/api/v1/billings/checkout (cluster-only) is what the webapp’s checkout route calls for every checkout, the member offer and the billing page alike: it refuses a price the organization is not offered (400) or one listed in another currency than the page showed (409), and returns the catalog, the session currency and, while onboarding, the Welcome coupon. The request names the surface that listed the price: onboarding sells the self-serve catalog to an organization that is still onboarding, billing (also the default) never sells a price that is not is_in_app, onboarding or not.
Onboarding checkout. POST /api/v1/onboarding/checkout (tofu-web, behind ONBOARDING_V2_CHECKOUT) creates the Checkout Session in bonsapi instead of the webapp’s route, with the same price guard and coupon choice. It is documented under Onboarding Discounts.
Listing. GET /api/v1/billings/plans lists what the organization may buy by the same rules (sales’ billing_plans overrides included).
Database Schema #
Organization Table Extension #
ALTER TABLE organization
ADD COLUMN stripe_customer_id VARCHAR(255);
CREATE INDEX organization_stripe_customer_id_idx
ON organization(stripe_customer_id);
Webhook Handling #
Stripe Webhooks #
Endpoint: POST /webhook/stripe
Handled Events:
customer.subscription.created- Handles cache invalidation and immediate chargingcustomer.subscription.deleted- Invalidates billing cachecustomer.subscription.resumed- Invalidates billing cachecustomer.subscription.updated- Invalidates billing cachepayment_intent.payment_failed- Invalidates billing cache, notifies the organization’s billing managers, and starts the payment-failure grace periodinvoice.paid/invoice.payment_succeeded- Invalidates billing cache and cancels any pending payment-failure notification
Security:
- Webhook signatures are verified using
STRIPE_WEBHOOK_SIGNING_SECRET - Uses
stripe::Webhook::construct_event()for cryptographic verification - Falls back to manual parsing for API version mismatches (with signature verification)
- Rejects any unsigned or tampered requests
Processing Flow:
- Verify webhook signature
- Extract customer ID from subscription event
- Look up organization by
stripe_customer_id - Invalidate billing cache for the organization
Payment Failure Notifications #
A failed collection attempt is notified in two stages, so an organization that resells tofu to its own clients is not exposed to them by a billing problem it has not had a chance to fix (ENG-7801).
- Immediately -
payment_intent.payment_failedenqueues aPurchaseFailednotification addressed toBillingManagers, and writes abilling_failure_notificationrow recordingnotify_members_after = now + grace period. - After the grace period -
bonsai-housekeeping(every 10 minutes) picks up rows whosenotify_members_afterhas passed and whosemembers_notified_atis still null, and enqueues aPurchaseFailednotification addressed toRemainingMembers: every organization member who is not a billing manager. Soft-deleted organizations are skipped.
A payment settled inside the window deletes the row, so the second stage never fires. A
repeat failure - Stripe emits payment_intent.payment_failed on every dunning retry - does
not move notify_members_after, otherwise the escalation would be postponed for as
long as Stripe kept retrying.
Configuration: BILLING_FAILURE_GRACE_PERIOD_IN_MILLISECS on bonsapi. Unset defaults
to 5 days (DEFAULT_BILLING_FAILURE_GRACE_PERIOD_IN_MILLISECS).
Recipient scoping: “billing managers” are the org roles that can actually settle a
payment - org:system_owner, org:system_admin and org:billing (“Can manage billing”).
org:contributor and org:collaborator cannot, and are notified only by stage 2.
Organization roles live only in Clerk, so bonsai-notification needs CLERK_SECRET_KEY to
resolve them. Without it no role resolves and the notification fails closed - nothing is
sent, rather than falling back to every member.
Recipient privacy: every templated email is sent as a Mailgun batch
(recipient-variables), so each recipient receives their own message and no recipient can
see any other recipient’s address. See basic_form_fields in
libs/rust/bonsai-email/src/mailgun/mod.rs.
Clerk Webhooks #
Endpoint: POST /webhook/clerk
Handled Events:
organization.created: Creates Stripe customer and sets trial metadataorganization.updated: Upserts Stripe customer and invalidates cache
Trial System #
New organizations receive a 3-day free trial with:
- Trial Duration: 3 days from organization creation
- Trial Limits:
- Max entities: 10
- Max invoices per month: 300
Trial metadata is stored in Clerk organization metadata:
{
"trial_expiry_date": "2024-01-15",
"trial_max_entities": 2,
"trial_max_invoices_per_month": 100
}
Failed reads. Dispatch reserves a read’s usage when it queues the job. The worker reports a failed read by sending the extraction back to PENDING with an EXTRACTION error (PATCH /internal/api/v1/extractions/{id}/status or POST /internal/api/v1/extractions/batch/status/pending). The first such report gives back what dispatch reserved, by the same rule (a bank statement its primary pages, anything else one, nothing for a re-read of something the AI already read), so a reported failure never uses up the allowance. Concurrent reports of the same failure refund it once. This applies to every organization, paid plans included.
Two cases get no refund. A read that times out is failed by the housekeeping sweep straight in the database (mark_stale_extractions_as_failed, mark_stale_awaiting_extractions_as_failed), so a later report finds it failed already; it stops counting when the usage cache is next rebuilt from the database, which counts only reads that produced data (the cache key lives an hour for monthly limits, six hours for annual ones). A read last touched before the current period began was reserved against the previous period, so the new one is left alone.
Immediate Charging for Mid-Month Subscriptions #
When a subscription is created after the 1st of the month, BonsAI handles immediate charging to ensure customers pay for the current month’s usage:
How It Works #
- Billing Cycle Anchor: All subscriptions are anchored to the 1st of each month
- Immediate Charge: When subscribing after the 1st, an immediate invoice is created for the current month
- Metadata Requirements: Subscriptions must include:
charge_current_month: "true"- Enables immediate chargingprice_id: "price_xxx"- The price to charge for the current month
Processing Flow #
graph LR
A[Subscription Created] --> B{Is it the 1st?}
B -->|Yes| C[No Immediate Charge]
B -->|No| D{charge_current_month = true?}
D -->|No| E[No Immediate Charge]
D -->|Yes| F[Create Immediate Invoice]
F --> G[Charge Customer]
G --> H[Regular Billing Continues on 1st]
Implementation Details #
- Handled in
customer.subscription.createdwebhook - Creates a one-time invoice for the current month
- Charges immediately using the default payment method
- Regular subscription billing continues on the 1st of each month
Error Handling #
- If immediate charge fails, the invoice remains open for collection
- Customer receives standard Stripe payment retry emails
- Subscription remains active during retry period
Caching Strategy #
The billing system uses Redis caching to minimize API calls to Stripe:
Cache Keys #
- Pattern:
billing:plan:{organization_id} - TTL: 5 minutes
- Structure:
{
"subscription_status": "active",
"current_plan": "starter",
"billing_cycle_end": "2024-02-01T00:00:00Z",
"max_invoices_per_month": 100,
"max_entities": 10
}
Cache Invalidation #
Cache is invalidated when:
- Stripe subscription events are received
- Organization is updated via Clerk
- After 5 minutes
Frontend Integration #
The webapp displays billing information and alerts:
Billing Alert Component #
Shows warnings when:
- Free trial is expiring (< 3 days)
- Subscription is inactive
- Usage limits are approached
Billing Page #
Located at /settings/organization/billing:
- Shows current plan details
- Provides link to Stripe customer portal
- Displays usage statistics
- Shows trial information
State Management #
Billing state is managed via:
useBillinghook for fetching billing datauseOrganizationhook for organization details- Automatic refetch on focus and authentication changes
Environment Variables #
Required environment variables:
# Stripe Configuration
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SIGNING_SECRET=whsec_...
# Clerk Configuration
CLERK_SECRET_KEY=sk_test_...
CLERK_WEBHOOK_SIGNING_SECRET=whsec_...
# API Configuration
BONSAPI_EXTERNAL_HOST=https://api.bonsai.com
Error Handling #
Common Error Scenarios #
-
No Stripe Customer: Organization exists but no Stripe customer
- Automatically creates customer on first billing request
- Logs warning for monitoring
-
Cache Miss: Billing information not in cache
- Falls back to Stripe API
- Repopulates cache with fresh data
-
Webhook Failures: Failed to process webhook
- Logs error with details
- Returns 200 OK to prevent retries (for non-critical events)
- Critical events may return error for retry
Monitoring and Debugging #
Key Log Points #
- Webhook receipt and processing
- Cache hits/misses
- Stripe API calls
- Customer creation events
- Subscription changes
Metrics to Monitor #
- Webhook processing time
- Cache hit rate
- Stripe API latency
- Failed webhook attempts
- Trial conversion rate
Development and Testing #
Local Development #
- Spin up ngrok
- Set up Stripe test account
- Configure webhook endpoint using ngrok’s host
- Use update BONSAPI_EXTERNAL_HOST and STRIPE_WEBHOOK_SIGNING_SECRET in /.env
Security Considerations #
- Webhook Verification: All webhooks are cryptographically verified
- Authorization: Billing endpoints require valid JWT tokens
- Rate Limiting: Consider implementing rate limits for billing endpoints
- Audit Logging: All billing changes are logged for compliance
- PCI Compliance: No payment information is stored locally
Future Enhancements #
- Usage Tracking: Real-time usage monitoring
- Billing Alerts: Email notifications for billing events
- Multiple Plans: Support for different subscription tiers
- Metered Billing: Usage-based pricing options
- Invoice Management: Direct invoice access and management