xml-pipeline/docs/bloxserver-billing.md
dullfig a5c00c1e90 Add BloxServer API scaffold + architecture docs
BloxServer API (FastAPI + SQLAlchemy async):
- Database models: users, flows, triggers, executions, usage tracking
- Clerk JWT auth with dev mode bypass for local testing
- SQLite support for local dev, PostgreSQL for production
- CRUD routes for flows, triggers, executions
- Public webhook endpoint with token auth
- Health/readiness endpoints
- Pydantic schemas with camelCase aliases for frontend
- Docker + docker-compose setup

Architecture documentation:
- Librarian architecture with RLM-powered query engine
- Stripe billing integration (usage-based, trials, webhooks)
- LLM abstraction layer (rate limiting, semantic cache, failover)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 22:04:25 -08:00

668 lines
23 KiB
Markdown

# BloxServer Billing Integration — Stripe
**Status:** Design
**Date:** January 2026
## Overview
BloxServer uses Stripe for subscription management, usage-based billing, and payment processing. This document specifies the integration architecture, webhook handlers, and usage tracking system.
## Pricing Tiers
| Tier | Price | Runs/Month | Features |
|------|-------|------------|----------|
| **Free** | $0 | 1,000 | 1 workflow, built-in tools, community support |
| **Pro** | $29 | 100,000 | Unlimited workflows, marketplace, WASM, project memory, priority support |
| **Enterprise** | Custom | Unlimited | SSO/SAML, SLA, dedicated support, private marketplace |
### Overage Pricing (Pro)
| Metric | Included | Overage Rate |
|--------|----------|--------------|
| Workflow runs | 100K/mo | $0.50 per 1K |
| Storage | 10 GB | $0.10 per GB |
| WASM execution | 1000 CPU-sec | $0.01 per CPU-sec |
## Stripe Product Structure
```
Products:
├── bloxserver_free
│ └── price_free_monthly ($0/month, metered runs)
├── bloxserver_pro
│ ├── price_pro_monthly ($29/month base)
│ ├── price_pro_runs_overage (metered, $0.50/1K)
│ └── price_pro_storage_overage (metered, $0.10/GB)
└── bloxserver_enterprise
└── price_enterprise_custom (quoted per customer)
```
### Stripe Configuration
```python
# One-time setup (or via Stripe Dashboard)
# Free tier product
free_product = stripe.Product.create(
name="BloxServer Free",
description="Build AI agent swarms, visually",
)
free_price = stripe.Price.create(
product=free_product.id,
unit_amount=0,
currency="usd",
recurring={"interval": "month"},
metadata={"tier": "free", "runs_included": "1000"}
)
# Pro tier product
pro_product = stripe.Product.create(
name="BloxServer Pro",
description="Unlimited workflows, marketplace access, custom WASM",
)
pro_base_price = stripe.Price.create(
product=pro_product.id,
unit_amount=2900, # $29.00
currency="usd",
recurring={"interval": "month"},
metadata={"tier": "pro", "runs_included": "100000"}
)
pro_runs_overage = stripe.Price.create(
product=pro_product.id,
currency="usd",
recurring={
"interval": "month",
"usage_type": "metered",
"aggregate_usage": "sum",
},
unit_amount_decimal="0.05", # $0.0005 per run = $0.50 per 1K
metadata={"type": "runs_overage"}
)
```
## Database Schema
```sql
-- Users table (synced from Clerk + Stripe)
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
clerk_id VARCHAR(255) UNIQUE NOT NULL,
email VARCHAR(255) NOT NULL,
name VARCHAR(255),
-- Stripe fields
stripe_customer_id VARCHAR(255) UNIQUE,
stripe_subscription_id VARCHAR(255),
stripe_subscription_item_id VARCHAR(255), -- For usage reporting
-- Billing state (cached from Stripe)
tier VARCHAR(50) DEFAULT 'free', -- free, pro, enterprise
billing_status VARCHAR(50) DEFAULT 'active', -- active, past_due, canceled
trial_ends_at TIMESTAMPTZ,
current_period_start TIMESTAMPTZ,
current_period_end TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Usage tracking (local, for dashboard + Stripe sync)
CREATE TABLE usage_records (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id),
period_start DATE NOT NULL, -- Billing period start
-- Metrics
workflow_runs INT DEFAULT 0,
llm_tokens_in INT DEFAULT 0,
llm_tokens_out INT DEFAULT 0,
wasm_cpu_seconds DECIMAL(10,2) DEFAULT 0,
storage_gb_hours DECIMAL(10,2) DEFAULT 0,
-- Stripe sync state
last_synced_at TIMESTAMPTZ,
last_synced_runs INT DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(user_id, period_start)
);
-- Stripe webhook events (idempotency)
CREATE TABLE stripe_events (
event_id VARCHAR(255) PRIMARY KEY,
event_type VARCHAR(100) NOT NULL,
processed_at TIMESTAMPTZ DEFAULT NOW(),
payload JSONB
);
-- Index for cleanup
CREATE INDEX idx_stripe_events_processed ON stripe_events(processed_at);
```
## Usage Tracking
### Real-Time Counting (Redis)
```python
# On every workflow execution
async def record_workflow_run(user_id: str):
"""Increment run counter in Redis."""
key = f"usage:{user_id}:runs:{get_current_period()}"
await redis.incr(key)
await redis.expire(key, 86400 * 35) # 35 days TTL
# Track users with usage for batch sync
await redis.sadd("users:with_usage", user_id)
async def record_llm_tokens(user_id: str, tokens_in: int, tokens_out: int):
"""Track LLM token usage."""
period = get_current_period()
await redis.incrby(f"usage:{user_id}:tokens_in:{period}", tokens_in)
await redis.incrby(f"usage:{user_id}:tokens_out:{period}", tokens_out)
```
### Periodic Sync to Stripe (Hourly)
```python
async def sync_usage_to_stripe():
"""Hourly job: push usage increments to Stripe."""
user_ids = await redis.smembers("users:with_usage")
for user_id in user_ids:
user = await get_user(user_id)
if not user.stripe_subscription_item_id:
continue # Free tier without Stripe subscription
# Get usage since last sync
period = get_current_period()
runs_key = f"usage:{user_id}:runs:{period}"
current_runs = int(await redis.get(runs_key) or 0)
last_synced = await get_last_synced_runs(user_id, period)
delta = current_runs - last_synced
if delta <= 0:
continue
# Check if over included limit
tier_limit = get_tier_runs_limit(user.tier) # 1000 or 100000
if current_runs <= tier_limit:
# Still within included runs, just track locally
await update_last_synced(user_id, period, current_runs)
continue
# Calculate overage to report
overage_start = max(last_synced, tier_limit)
overage_runs = current_runs - overage_start
if overage_runs > 0:
# Report to Stripe
await stripe.subscription_items.create_usage_record(
user.stripe_subscription_item_id,
quantity=overage_runs,
timestamp=int(time.time()),
action='increment'
)
await update_last_synced(user_id, period, current_runs)
# Clear the tracking set (will rebuild next hour)
await redis.delete("users:with_usage")
```
### Dashboard Query
```python
async def get_usage_dashboard(user_id: str) -> UsageDashboard:
"""Get current usage for user dashboard."""
user = await get_user(user_id)
period = get_current_period()
# Get real-time counts from Redis
runs = int(await redis.get(f"usage:{user_id}:runs:{period}") or 0)
tokens_in = int(await redis.get(f"usage:{user_id}:tokens_in:{period}") or 0)
tokens_out = int(await redis.get(f"usage:{user_id}:tokens_out:{period}") or 0)
tier_limit = get_tier_runs_limit(user.tier)
return UsageDashboard(
period_start=period,
period_end=user.current_period_end,
runs_used=runs,
runs_limit=tier_limit,
runs_percentage=min(100, (runs / tier_limit) * 100),
tokens_used=tokens_in + tokens_out,
estimated_overage=calculate_overage_cost(runs, tier_limit),
days_remaining=(user.current_period_end - datetime.now()).days,
)
```
## Subscription Lifecycle
### Signup Flow
```
User clicks "Start Free Trial"
┌───────────────────────────────────────────────────────────┐
│ 1. Create Stripe Customer │
│ │
│ customer = stripe.Customer.create( │
│ email=user.email, │
│ metadata={"clerk_id": user.clerk_id} │
│ ) │
└───────────────────────────────────────────────────────────┘
┌───────────────────────────────────────────────────────────┐
│ 2. Create Checkout Session (hosted payment page) │
│ │
│ session = stripe.checkout.Session.create( │
│ customer=customer.id, │
│ mode='subscription', │
│ line_items=[{ │
│ 'price': 'price_pro_monthly', │
│ 'quantity': 1 │
│ }, { │
│ 'price': 'price_pro_runs_overage', # metered │
│ }], │
│ subscription_data={ │
│ 'trial_period_days': 14, │
│ }, │
│ success_url='https://app.openblox.ai/welcome', │
│ cancel_url='https://app.openblox.ai/pricing', │
│ ) │
│ │
│ → Redirect user to session.url │
└───────────────────────────────────────────────────────────┘
┌───────────────────────────────────────────────────────────┐
│ 3. User enters payment details on Stripe Checkout │
│ │
│ Card validated but NOT charged (trial) │
└───────────────────────────────────────────────────────────┘
┌───────────────────────────────────────────────────────────┐
│ 4. Webhook: checkout.session.completed │
│ │
│ → Update user with stripe_customer_id │
│ → Update user with stripe_subscription_id │
│ → Set tier = 'pro' │
│ → Set trial_ends_at │
└───────────────────────────────────────────────────────────┘
```
### Trial End
```
Day 11 of 14-day trial
┌───────────────────────────────────────────────────────────┐
│ Scheduled job: Trial ending soon emails │
│ │
│ SELECT * FROM users │
│ WHERE trial_ends_at BETWEEN NOW() AND NOW() + INTERVAL '3d'│
│ AND billing_status = 'trialing' │
│ │
│ → Send "Your trial ends in 3 days" email │
└───────────────────────────────────────────────────────────┘
Day 14: Trial ends
┌───────────────────────────────────────────────────────────┐
│ Stripe automatically: │
│ 1. Charges the card on file │
│ 2. Sends invoice.payment_succeeded webhook │
│ │
│ Our webhook handler: │
│ → Update billing_status = 'active' │
│ → Send "Welcome to Pro!" email │
└───────────────────────────────────────────────────────────┘
```
### Cancellation
```python
# User clicks "Cancel subscription" in Customer Portal
# Stripe sends webhook
@webhook("customer.subscription.updated")
async def handle_subscription_updated(event):
subscription = event.data.object
user = await get_user_by_stripe_subscription(subscription.id)
if subscription.cancel_at_period_end:
# User requested cancellation (takes effect at period end)
await send_email(user, "subscription_canceled", {
"effective_date": subscription.current_period_end
})
await db.execute("""
UPDATE users
SET billing_status = 'canceling',
updated_at = NOW()
WHERE id = $1
""", user.id)
@webhook("customer.subscription.deleted")
async def handle_subscription_deleted(event):
subscription = event.data.object
user = await get_user_by_stripe_subscription(subscription.id)
# Subscription actually ended
await db.execute("""
UPDATE users
SET tier = 'free',
billing_status = 'canceled',
stripe_subscription_id = NULL,
stripe_subscription_item_id = NULL,
updated_at = NOW()
WHERE id = $1
""", user.id)
await send_email(user, "downgraded_to_free")
```
## Webhook Handlers
### Endpoint Setup
```python
from fastapi import FastAPI, Request, HTTPException
import stripe
app = FastAPI()
@app.post("/webhooks/stripe")
async def stripe_webhook(request: Request):
payload = await request.body()
sig_header = request.headers.get("stripe-signature")
try:
event = stripe.Webhook.construct_event(
payload, sig_header, settings.STRIPE_WEBHOOK_SECRET
)
except ValueError:
raise HTTPException(400, "Invalid payload")
except stripe.error.SignatureVerificationError:
raise HTTPException(400, "Invalid signature")
# Idempotency check
if await is_event_processed(event.id):
return {"status": "already_processed"}
# Route to handler
handler = WEBHOOK_HANDLERS.get(event.type)
if handler:
await handler(event)
else:
logger.info(f"Unhandled webhook: {event.type}")
# Mark processed
await mark_event_processed(event)
return {"status": "success"}
```
### Handler Registry
```python
WEBHOOK_HANDLERS = {
# Checkout
"checkout.session.completed": handle_checkout_completed,
# Subscriptions
"customer.subscription.created": handle_subscription_created,
"customer.subscription.updated": handle_subscription_updated,
"customer.subscription.deleted": handle_subscription_deleted,
"customer.subscription.trial_will_end": handle_trial_ending,
# Payments
"invoice.payment_succeeded": handle_payment_succeeded,
"invoice.payment_failed": handle_payment_failed,
"invoice.upcoming": handle_invoice_upcoming,
# Customer
"customer.updated": handle_customer_updated,
}
```
### Key Handlers
```python
@webhook("checkout.session.completed")
async def handle_checkout_completed(event):
"""User completed checkout - provision their account."""
session = event.data.object
# Get or create user
user = await get_user_by_clerk_id(session.client_reference_id)
# Update with Stripe IDs
subscription = await stripe.Subscription.retrieve(session.subscription)
await db.execute("""
UPDATE users SET
stripe_customer_id = $1,
stripe_subscription_id = $2,
stripe_subscription_item_id = $3,
tier = $4,
billing_status = $5,
trial_ends_at = $6,
current_period_start = $7,
current_period_end = $8,
updated_at = NOW()
WHERE id = $9
""",
session.customer,
subscription.id,
subscription['items'].data[0].id, # First item for usage reporting
'pro',
subscription.status, # 'trialing' or 'active'
datetime.fromtimestamp(subscription.trial_end) if subscription.trial_end else None,
datetime.fromtimestamp(subscription.current_period_start),
datetime.fromtimestamp(subscription.current_period_end),
user.id
)
@webhook("invoice.payment_failed")
async def handle_payment_failed(event):
"""Payment failed - notify user, potentially downgrade."""
invoice = event.data.object
user = await get_user_by_stripe_customer(invoice.customer)
attempt_count = invoice.attempt_count
if attempt_count == 1:
# First failure - soft warning
await send_email(user, "payment_failed_soft", {
"amount": invoice.amount_due / 100,
"update_url": await get_customer_portal_url(user)
})
elif attempt_count == 2:
# Second failure - stronger warning
await send_email(user, "payment_failed_warning", {
"amount": invoice.amount_due / 100,
"days_until_downgrade": 3
})
else:
# Final failure - downgrade
await db.execute("""
UPDATE users SET
tier = 'free',
billing_status = 'past_due',
updated_at = NOW()
WHERE id = $1
""", user.id)
await send_email(user, "downgraded_payment_failed")
@webhook("customer.subscription.trial_will_end")
async def handle_trial_ending(event):
"""Trial ending in 3 days - Stripe sends this automatically."""
subscription = event.data.object
user = await get_user_by_stripe_subscription(subscription.id)
await send_email(user, "trial_ending", {
"trial_end_date": datetime.fromtimestamp(subscription.trial_end),
"amount": 29.00, # Pro price
"manage_url": await get_customer_portal_url(user)
})
```
## Customer Portal
Stripe's hosted portal for self-service billing management.
```python
async def get_customer_portal_url(user: User) -> str:
"""Generate a portal session URL for the user."""
session = await stripe.billing_portal.Session.create(
customer=user.stripe_customer_id,
return_url="https://app.openblox.ai/settings/billing"
)
return session.url
```
**Portal capabilities:**
- Update payment method
- View invoices and receipts
- Cancel subscription
- Upgrade/downgrade plan (if configured)
## Email Templates
| Trigger | Template | Content |
|---------|----------|---------|
| Trial started | `trial_started` | Welcome, trial ends on X |
| Trial ending (3 days) | `trial_ending` | Your trial ends soon, card will be charged |
| Trial converted | `trial_converted` | Welcome to Pro! |
| Payment succeeded | `payment_succeeded` | Receipt attached |
| Payment failed (1st) | `payment_failed_soft` | Please update your card |
| Payment failed (2nd) | `payment_failed_warning` | Service will be interrupted |
| Payment failed (final) | `downgraded_payment_failed` | You've been downgraded |
| Subscription canceled | `subscription_canceled` | Access until period end |
| Downgraded | `downgraded_to_free` | You're now on Free |
## Rate Limiting & Abuse Prevention
### Soft Limits (Warning)
```python
async def check_usage_limits(user_id: str) -> UsageLimitResult:
"""Check if user is approaching limits."""
usage = await get_current_usage(user_id)
user = await get_user(user_id)
tier_limit = get_tier_runs_limit(user.tier)
percentage = (usage.runs / tier_limit) * 100
if percentage >= 100:
return UsageLimitResult(
allowed=True, # Still allow, but warn
warning="You've exceeded your included runs. Overage charges apply.",
overage_rate="$0.50 per 1,000 runs"
)
elif percentage >= 80:
return UsageLimitResult(
allowed=True,
warning=f"You've used {percentage:.0f}% of your monthly runs."
)
return UsageLimitResult(allowed=True)
```
### Hard Limits (Free Tier)
```python
async def enforce_free_tier_limits(user_id: str) -> bool:
"""Free tier has hard limits - no overage allowed."""
user = await get_user(user_id)
if user.tier != "free":
return True # Paid tiers have soft limits
usage = await get_current_usage(user_id)
if usage.runs >= 1000:
raise UsageLimitExceeded(
"You've reached the Free tier limit of 1,000 runs/month. "
"Upgrade to Pro for unlimited workflows."
)
return True
```
## Testing
### Test Mode
Stripe provides test mode with test API keys and test card numbers.
```python
# .env
STRIPE_SECRET_KEY=sk_test_... # Test mode
STRIPE_WEBHOOK_SECRET=whsec_...
# Test cards
# 4242424242424242 - Succeeds
# 4000000000000002 - Declined
# 4000002500003155 - Requires 3D Secure
```
### Webhook Testing
```bash
# Use Stripe CLI to forward webhooks locally
stripe listen --forward-to localhost:8000/webhooks/stripe
# Trigger test events
stripe trigger invoice.payment_succeeded
stripe trigger customer.subscription.trial_will_end
```
## Monitoring & Alerts
| Metric | Alert Threshold |
|--------|-----------------|
| Webhook processing time | > 5 seconds |
| Webhook failure rate | > 1% |
| Payment failure rate | > 5% |
| Usage sync lag | > 2 hours |
| Stripe API errors | Any 5xx |
## Security Checklist
- [ ] Webhook signature verification
- [ ] Idempotent event processing
- [ ] API keys in environment variables (never in code)
- [ ] Customer portal for sensitive operations (not custom UI)
- [ ] PCI compliance via Stripe Checkout (no card data touches our servers)
- [ ] Audit log for billing events
---
## References
- [Stripe Billing](https://stripe.com/docs/billing)
- [Stripe Webhooks](https://stripe.com/docs/webhooks)
- [Stripe Checkout](https://stripe.com/docs/payments/checkout)
- [Stripe Customer Portal](https://stripe.com/docs/billing/subscriptions/customer-portal)
- [Metered Billing](https://stripe.com/docs/billing/subscriptions/metered-billing)