# Hydra Billing: Implementation Spec
**For:** Tyler
**Date:** 2026-05-01
**Status:** Ready to build — pending seat definition decision
**Source:** hydra-stripe-billing-research.md (Stripe docs verified)

---

## Overview

Hydra's billing is a freemium model. No Stripe objects exist for free users. At upgrade, one Stripe Customer and one Subscription are created. Billing is per seat, monthly or annual.

Free tier limits (enforced entirely in Hydra's DB — no Stripe involvement):
- 5 fixes / 30 rolling days
- 1 doc run / 30 rolling days
- 5 Linear cycles / 30 rolling days
- Overage: hard stop, return error, trigger upgrade prompt

Paid tiers (Stripe):
- Team: $20/seat/month or $192/seat/year
- Business: $40/seat/month or $384/seat/year
- Enterprise: custom, outside Stripe

---

## Environment Variables Required

```
STRIPE_SECRET_KEY=sk_live_...
STRIPE_PUBLISHABLE_KEY=pk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...        # from Stripe Dashboard after endpoint registration
STRIPE_TEAM_MONTHLY_PRICE_ID=price_...
STRIPE_TEAM_ANNUAL_PRICE_ID=price_...
STRIPE_BUSINESS_MONTHLY_PRICE_ID=price_...
STRIPE_BUSINESS_ANNUAL_PRICE_ID=price_...
STRIPE_PORTAL_CONFIG_ID=bpc_...
```

Use test keys (`sk_test_...`) in dev and staging. Production Stripe account is separate from Iru's.

---

## Database Schema

Add to tenants table (single migration):

```sql
ALTER TABLE tenants ADD COLUMN IF NOT EXISTS
  -- Free tier counters (no Stripe)
  free_tier_fixes_used        INTEGER NOT NULL DEFAULT 0,
  free_tier_doc_runs_used     INTEGER NOT NULL DEFAULT 0,
  free_tier_cycles_used       INTEGER NOT NULL DEFAULT 0,
  free_tier_reset_at          TIMESTAMPTZ,            -- NULL until first workflow run

  -- Stripe state (NULL until upgrade)
  stripe_customer_id          TEXT,
  stripe_subscription_id      TEXT,
  stripe_subscription_status  TEXT,                   -- active | past_due | canceled | unpaid | incomplete
  stripe_price_id             TEXT,                   -- current active price ID
  stripe_current_period_end   TIMESTAMPTZ,
  stripe_seat_count           INTEGER,

  -- Derived tier (source of truth for feature gating)
  plan_tier                   TEXT NOT NULL DEFAULT 'free';  -- free | team | business | enterprise

CREATE INDEX IF NOT EXISTS idx_tenants_stripe_customer ON tenants(stripe_customer_id);
CREATE INDEX IF NOT EXISTS idx_tenants_stripe_sub ON tenants(stripe_subscription_id);
CREATE INDEX IF NOT EXISTS idx_tenants_free_reset ON tenants(free_tier_reset_at) WHERE plan_tier = 'free';
```

`plan_tier` is the gating field. It is set by webhook handlers, not by the checkout flow.

---

## Stripe Setup (one-time, before coding)

Run these via API or Stripe Dashboard. Store the resulting IDs as env vars.

```bash
# 1. Create Products
stripe products create --name="Hydra Team"
stripe products create --name="Hydra Business"

# 2. Create Prices
stripe prices create \
  --product=prod_TEAM_ID \
  --unit-amount=2000 \
  --currency=usd \
  --recurring[interval]=month \
  --recurring[usage_type]=licensed

stripe prices create \
  --product=prod_TEAM_ID \
  --unit-amount=19200 \
  --currency=usd \
  --recurring[interval]=year \
  --recurring[usage_type]=licensed

stripe prices create \
  --product=prod_BUSINESS_ID \
  --unit-amount=4000 \
  --currency=usd \
  --recurring[interval]=month \
  --recurring[usage_type]=licensed

stripe prices create \
  --product=prod_BUSINESS_ID \
  --unit-amount=38400 \
  --currency=usd \
  --recurring[interval]=year \
  --recurring[usage_type]=licensed

# 3. Register webhook endpoint in Stripe Dashboard
# URL: https://api.hydra.iru.dev/api/webhooks/stripe
# Events to subscribe: see webhook section below

# 4. Configure Customer Portal
stripe billing_portal configurations create \
  --features[subscription_update][enabled]=true \
  --features[subscription_update][default_allowed_updates][]=quantity \
  --features[subscription_update][default_allowed_updates][]=price \
  --features[subscription_update][proration_behavior]=create_prorations \
  --features[subscription_cancel][enabled]=true
```

---

## Free Tier Enforcement

### Middleware

Call `checkFreeTierLimit(tenantId, workflowType)` before executing any billable workflow.

```python
LIMITS = {
    'fix':          5,
    'doc_run':      1,
    'linear_cycle': 5,
}

COUNTER_COLUMNS = {
    'fix':          'free_tier_fixes_used',
    'doc_run':      'free_tier_doc_runs_used',
    'linear_cycle': 'free_tier_cycles_used',
}

def check_free_tier_limit(tenant_id: str, workflow_type: str) -> None:
    tenant = db.get_tenant(tenant_id)

    if tenant.plan_tier != 'free':
        return  # paid tier, no limits

    # Initialize reset timestamp on first run
    if tenant.free_tier_reset_at is None:
        db.set_free_tier_reset_at(tenant_id, now() + timedelta(days=30))
        tenant = db.get_tenant(tenant_id)  # re-fetch

    column = COUNTER_COLUMNS[workflow_type]
    current_usage = getattr(tenant, column)
    limit = LIMITS[workflow_type]

    if current_usage >= limit:
        raise FreeTierLimitReached(
            workflow_type=workflow_type,
            used=current_usage,
            limit=limit,
            reset_at=tenant.free_tier_reset_at,
        )

def increment_free_tier_counter(tenant_id: str, workflow_type: str) -> None:
    column = COUNTER_COLUMNS[workflow_type]
    db.execute(f"UPDATE tenants SET {column} = {column} + 1 WHERE id = %s", tenant_id)
```

`FreeTierLimitReached` propagates to the API layer and returns a structured error the frontend uses to trigger the upgrade prompt.

### Reset Job

Cron: every hour (or daily at 3am UTC).

```python
def reset_free_tier_counters():
    # Reset any free tenant whose 30-day window has elapsed
    db.execute("""
        UPDATE tenants
        SET
            free_tier_fixes_used    = 0,
            free_tier_doc_runs_used = 0,
            free_tier_cycles_used   = 0,
            free_tier_reset_at      = free_tier_reset_at + INTERVAL '30 days'
        WHERE
            plan_tier = 'free'
            AND free_tier_reset_at IS NOT NULL
            AND free_tier_reset_at < NOW()
    """)
```

---

## API Endpoints

### POST /api/billing/checkout

Creates a Stripe Checkout Session for a free user upgrading to paid. Returns the Checkout URL.

**Request:**
```json
{
  "price_id": "price_team_monthly | price_team_annual | price_business_monthly | price_business_annual",
  "seat_count": 5
}
```

**Implementation:**
```python
def create_checkout_session(tenant_id, price_id, seat_count):
    tenant = db.get_tenant(tenant_id)

    # Create Stripe Customer if none exists
    if not tenant.stripe_customer_id:
        customer = stripe.Customer.create(
            email=tenant.billing_email,
            name=tenant.name,
            metadata={"hydra_tenant_id": tenant_id},
        )
        db.update_tenant(tenant_id, stripe_customer_id=customer.id)
    else:
        customer_id = tenant.stripe_customer_id

    session = stripe.checkout.Session.create(
        customer=customer_id,
        mode="subscription",
        line_items=[{
            "price": price_id,
            "quantity": seat_count,
            "adjustable_quantity": {
                "enabled": True,
                "minimum": 1,
                "maximum": 500,
            },
        }],
        success_url="https://app.hydra.iru.dev/billing/success?session_id={CHECKOUT_SESSION_ID}",
        cancel_url="https://app.hydra.iru.dev/billing/upgrade",
    )

    return {"checkout_url": session.url}
```

**Response:**
```json
{"checkout_url": "https://checkout.stripe.com/pay/cs_..."}
```

Frontend redirects to this URL. Do not redirect server-side.

---

### GET /api/billing/success

Called when the user returns from Stripe Checkout. Confirms payment and stores subscription ID. Does NOT provision access — that happens via `invoice.paid` webhook.

**Query params:** `session_id=cs_...`

```python
def billing_success(session_id):
    session = stripe.checkout.Session.retrieve(
        session_id,
        expand=["line_items"],
    )

    if session.payment_status != "paid":
        return {"status": "pending"}  # provisioning not yet complete

    tenant = db.get_tenant_by_stripe_customer(session.customer)
    db.update_tenant(tenant.id,
        stripe_subscription_id=session.subscription,
    )

    return {"status": "provisioning"}  # tell frontend to poll or wait for push
```

---

### POST /api/billing/seats

Updates seat count on an active subscription. Previews proration before applying.

**Request (preview):**
```json
{"seat_count": 10, "preview": true}
```

**Request (apply):**
```json
{"seat_count": 10, "preview": false, "proration_date": 1746057600}
```

```python
def update_seats(tenant_id, seat_count, preview=True, proration_date=None):
    tenant = db.get_tenant(tenant_id)
    subscription = stripe.Subscription.retrieve(tenant.stripe_subscription_id)
    sub_item_id = subscription["items"]["data"][0]["id"]

    proration_date = proration_date or int(time.time())

    if preview:
        invoice = stripe.Invoice.create_preview(
            subscription=tenant.stripe_subscription_id,
            subscription_details={
                "items": [{"id": sub_item_id, "quantity": seat_count}],
                "proration_behavior": "create_prorations",
                "proration_date": proration_date,
            },
        )
        return {
            "amount_due": invoice.amount_due,
            "proration_date": proration_date,
            "lines": [
                {"description": l.description, "amount": l.amount}
                for l in invoice.lines.data
            ],
        }
    else:
        stripe.Subscription.modify(
            tenant.stripe_subscription_id,
            items=[{"id": sub_item_id, "quantity": seat_count}],
            proration_behavior="create_prorations",
            proration_date=proration_date,  # same timestamp as preview
        )
        return {"status": "updated"}
```

**Important:** Always pass the same `proration_date` in preview and apply. Proration is calculated to the second — a delay between preview and apply changes the amount.

---

### POST /api/billing/upgrade

Upgrades from Team to Business (or any tier change). Swaps the price, preserves seat count.

```python
def upgrade_plan(tenant_id, new_price_id):
    tenant = db.get_tenant(tenant_id)
    subscription = stripe.Subscription.retrieve(tenant.stripe_subscription_id)
    sub_item = subscription["items"]["data"][0]

    proration_date = int(time.time())

    # Preview first
    preview = stripe.Invoice.create_preview(
        subscription=tenant.stripe_subscription_id,
        subscription_details={
            "items": [{"id": sub_item.id, "price": new_price_id, "quantity": sub_item.quantity}],
            "proration_behavior": "create_prorations",
            "proration_date": proration_date,
        },
    )

    # Apply (called after user confirms on frontend)
    stripe.Subscription.modify(
        tenant.stripe_subscription_id,
        items=[{
            "id": sub_item.id,
            "price": new_price_id,
            "quantity": sub_item.quantity,  # MUST be explicit — Stripe resets to 1 otherwise
        }],
        proration_behavior="create_prorations",
        proration_date=proration_date,
    )
```

---

### POST /api/billing/portal

Generates a Customer Portal session URL. User clicks "Manage subscription" → redirect.

```python
def create_portal_session(tenant_id):
    tenant = db.get_tenant(tenant_id)

    session = stripe.billing_portal.Session.create(
        customer=tenant.stripe_customer_id,
        configuration=os.environ["STRIPE_PORTAL_CONFIG_ID"],
        return_url="https://app.hydra.iru.dev/settings/billing",
    )

    return {"portal_url": session.url}
```

Portal URL expires when the session ends. Do not cache.

---

### GET /api/billing/status

Returns current billing state for the billing settings page.

```python
def billing_status(tenant_id):
    tenant = db.get_tenant(tenant_id)

    if tenant.plan_tier == 'free':
        return {
            "tier": "free",
            "limits": {
                "fixes": {"used": tenant.free_tier_fixes_used, "limit": 5},
                "doc_runs": {"used": tenant.free_tier_doc_runs_used, "limit": 1},
                "linear_cycles": {"used": tenant.free_tier_cycles_used, "limit": 5},
            },
            "reset_at": tenant.free_tier_reset_at,
        }

    return {
        "tier": tenant.plan_tier,
        "seats": tenant.stripe_seat_count,
        "status": tenant.stripe_subscription_status,
        "period_end": tenant.stripe_current_period_end,
        "price_id": tenant.stripe_price_id,
    }
```

---

## Webhook Handler

### Endpoint

`POST /api/webhooks/stripe`

Must return HTTP 200 within 30 seconds. Stripe retries on failure with exponential backoff for up to 3 days.

**Events to subscribe in Stripe Dashboard:**
- `checkout.session.completed`
- `customer.subscription.created`
- `customer.subscription.updated`
- `customer.subscription.deleted`
- `invoice.paid`
- `invoice.payment_failed`
- `invoice.payment_action_required`
- `entitlements.active_entitlement_summary.updated` (if using Entitlements)

```python
@app.route("/api/webhooks/stripe", methods=["POST"])
def stripe_webhook():
    payload = request.get_data(as_text=True)
    sig_header = request.headers.get("Stripe-Signature")

    try:
        event = stripe.Webhook.construct_event(
            payload, sig_header, os.environ["STRIPE_WEBHOOK_SECRET"]
        )
    except stripe.error.SignatureVerificationError:
        return Response(status=400)

    handlers = {
        "checkout.session.completed":              handle_checkout_completed,
        "customer.subscription.created":           handle_subscription_created,
        "customer.subscription.updated":           handle_subscription_updated,
        "customer.subscription.deleted":           handle_subscription_deleted,
        "invoice.paid":                            handle_invoice_paid,
        "invoice.payment_failed":                  handle_payment_failed,
        "invoice.payment_action_required":         handle_payment_action_required,
        "entitlements.active_entitlement_summary.updated": handle_entitlements_updated,
    }

    handler = handlers.get(event["type"])
    if handler:
        handler(event["data"]["object"])

    return Response(status=200)  # always 200 — Stripe retries on anything else
```

### Event Handlers

```python
def handle_checkout_completed(session):
    # Store subscription ID. Wait for invoice.paid to provision.
    tenant = db.get_tenant_by_stripe_customer(session["customer"])
    if tenant:
        db.update_tenant(tenant.id,
            stripe_subscription_id=session["subscription"],
        )

def handle_subscription_created(subscription):
    tenant = db.get_tenant_by_stripe_customer(subscription["customer"])
    if not tenant:
        return
    db.update_tenant(tenant.id,
        stripe_subscription_id=subscription["id"],
        stripe_subscription_status=subscription["status"],
        stripe_price_id=subscription["items"]["data"][0]["price"]["id"],
        stripe_seat_count=subscription["items"]["data"][0]["quantity"],
    )
    # Do not provision yet if status=incomplete. Wait for invoice.paid.

def handle_invoice_paid(invoice):
    if not invoice.get("subscription"):
        return  # one-off invoice, not a subscription renewal
    tenant = db.get_tenant_by_stripe_subscription(invoice["subscription"])
    if not tenant:
        return

    # Determine tier from price ID
    price_id = invoice["lines"]["data"][0]["price"]["id"]
    tier = price_id_to_tier(price_id)  # "team" or "business"

    db.update_tenant(tenant.id,
        plan_tier=tier,
        stripe_subscription_status="active",
        stripe_price_id=price_id,
        stripe_current_period_end=datetime.fromtimestamp(invoice["lines"]["data"][0]["period"]["end"]),
        stripe_seat_count=invoice["lines"]["data"][0]["quantity"],
    )
    # Provision: grant feature access for this tier

def handle_payment_failed(invoice):
    if not invoice.get("subscription"):
        return
    tenant = db.get_tenant_by_stripe_subscription(invoice["subscription"])
    if not tenant:
        return
    db.update_tenant(tenant.id, stripe_subscription_status="past_due")
    # Send payment failure email
    # Grace period: 7 days. Schedule access revocation job.
    schedule_access_revocation(tenant.id, delay_days=7)

def handle_payment_action_required(invoice):
    tenant = db.get_tenant_by_stripe_subscription(invoice["subscription"])
    if not tenant:
        return
    # Notify customer to complete 3DS authentication
    send_action_required_email(tenant)

def handle_subscription_updated(subscription):
    tenant = db.get_tenant_by_stripe_subscription(subscription["id"])
    if not tenant:
        return

    prev = subscription.get("previous_attributes", {})
    new_status = subscription["status"]
    new_quantity = subscription["items"]["data"][0]["quantity"]
    new_price_id = subscription["items"]["data"][0]["price"]["id"]

    updates = {
        "stripe_subscription_status": new_status,
        "stripe_seat_count": new_quantity,
        "stripe_price_id": new_price_id,
        "plan_tier": price_id_to_tier(new_price_id),
    }

    if new_status == "active" and prev.get("status") == "past_due":
        cancel_access_revocation(tenant.id)  # payment recovered

    if new_status == "canceled":
        # Access continues until current_period_end, not immediately
        schedule_tier_revert(tenant.id, at=subscription["current_period_end"])

    db.update_tenant(tenant.id, **updates)

def handle_subscription_deleted(subscription):
    tenant = db.get_tenant_by_stripe_subscription(subscription["id"])
    if not tenant:
        return
    # Access continues until period end. Revert to free at that timestamp.
    schedule_tier_revert(tenant.id, at=subscription["current_period_end"])

def price_id_to_tier(price_id: str) -> str:
    team_prices = {
        os.environ["STRIPE_TEAM_MONTHLY_PRICE_ID"],
        os.environ["STRIPE_TEAM_ANNUAL_PRICE_ID"],
    }
    business_prices = {
        os.environ["STRIPE_BUSINESS_MONTHLY_PRICE_ID"],
        os.environ["STRIPE_BUSINESS_ANNUAL_PRICE_ID"],
    }
    if price_id in team_prices:
        return "team"
    if price_id in business_prices:
        return "business"
    return "free"
```

---

## Access Gating Middleware

Call at the start of any request that touches a paid feature.

```python
PAID_FEATURES = {
    "custom_agent_rules":   ["team", "business", "enterprise"],
    "unlimited_repos":      ["business", "enterprise"],
    "jira_integration":     ["business", "enterprise"],
    "audit_logs":           ["business", "enterprise"],
    "usage_reporting":      ["business", "enterprise"],
    "priority_fix_queue":   ["business", "enterprise"],
}

def require_feature(feature_name: str):
    def decorator(fn):
        def wrapper(tenant_id, *args, **kwargs):
            tenant = db.get_tenant(tenant_id)
            allowed_tiers = PAID_FEATURES.get(feature_name, [])

            if tenant.plan_tier not in allowed_tiers:
                raise FeatureNotAvailable(feature=feature_name, current_tier=tenant.plan_tier)

            if tenant.stripe_subscription_status in ("past_due",):
                # Check if within grace period (7 days)
                if is_grace_period_expired(tenant):
                    raise SubscriptionPastDue()

            if tenant.stripe_subscription_status in ("unpaid", "canceled"):
                raise SubscriptionInactive()

            return fn(tenant_id, *args, **kwargs)
        return wrapper
    return decorator
```

Usage:
```python
@require_feature("jira_integration")
def create_jira_issue(tenant_id, issue_data):
    ...
```

---

## Testing

### Stripe CLI (local webhook testing)

```bash
# Install Stripe CLI
brew install stripe/stripe-cli/stripe

# Login
stripe login

# Forward webhooks to local dev server
stripe listen --forward-to localhost:8000/api/webhooks/stripe

# In another terminal — trigger specific events for testing
stripe trigger invoice.paid
stripe trigger invoice.payment_failed
stripe trigger customer.subscription.updated
stripe trigger customer.subscription.deleted
stripe trigger checkout.session.completed
```

The CLI prints the webhook secret for local use — set as `STRIPE_WEBHOOK_SECRET` in dev.

### Test Cards (Stripe test mode)

| Card | Result |
|---|---|
| 4242 4242 4242 4242 | Payment succeeds |
| 4000 0000 0000 9995 | Payment fails — insufficient funds |
| 4000 0025 0000 3155 | Requires 3DS authentication |
| 4000 0000 0000 0341 | Attaches but fails on charge |

Use any future expiry date and any 3-digit CVC.

### Key test scenarios

1. **Free user → Team upgrade → invoice.paid → paid access granted**
2. **Seat count increase → proration preview → apply → correct invoice amount**
3. **Payment failure → grace period active → payment retry succeeds → access restored**
4. **Cancellation → access until period end → period end reached → reverts to free**
5. **Team → Business upgrade → tier switch → Business features available**
6. **Free tier limit hit → hard stop → upgrade prompt → upgrade flow → limits lifted**
7. **Rolling 30-day reset → counters zeroed → can run workflows again**

---

## Open Decisions (resolve before coding these parts)

| Decision | Affects |
|---|---|
| Seat definition | What `stripe_seat_count` represents; how in-product seat count UI shows; whether Hydra enforces seat count on user provisioning |
| Grace period (7 days recommended) | `schedule_access_revocation()` delay in `handle_payment_failed` |
| Entitlements API | Whether to add `entitlements.active_entitlement_summary.updated` handler |
| Billing cycle anchor | Whether to pass `billing_cycle_anchor_config[day_of_month]` on subscription creation |
