# Hydra Stripe Billing — Implementation Roadmap

**Team:** Tyler (eng) + Tristan (pairing) with Claude Code
**Timeline:** May 5-16, 2026 (~9 working days)
**This document covers:** prerequisites, day-by-day implementation, idempotency requirements, security checklist, test scenarios, pre-live checklist, go-live procedure, incident runbooks, rollback plan

---

## Why this document exists

Payments is not a normal feature. Real money moves. Webhook handlers fire multiple times. Stripe retries failed deliveries for 72 hours. A missed event means a customer has paid but has no access. A double-processed event means a customer gets charged twice. This roadmap treats billing like billing, not like a UI sprint.

---

## Phase 0: Prerequisites (complete before Day 1)

These are not Day 1 tasks. They are blockers. Nothing else can start until every item on this list is done.

### Decisions (Tyler's calls)

| Decision | Default if Tyler doesn't decide | Why it matters |
|---|---|---|
| Seat definition | "Developer with an active connected repo" | Affects how seat_count is validated at checkout |
| Billing cycle anchor | Per-customer (signup date) | Affects when invoices fire |
| Proration behavior in portal | `create_prorations` | Affects what customer owes on mid-cycle seat change |
| `billing_mode` | `classic` (Stripe default) | Different modes change proration behavior |
| Entitlements API | Enable it | Defers feature gating updates to Stripe rather than code deploys |
| Grace period on failed payment | 7 days | How long before access is revoked after invoice failure |

### Stripe account setup

- [ ] Create separate Hydra Stripe account (not Iru's account)
- [ ] Switch to test mode
- [ ] Create Team and Business Products in Dashboard or via CLI
- [ ] Create 4 Price objects: Team monthly ($20), Team annual ($192), Business monthly ($40), Business annual ($384)
- [ ] Configure Customer Portal (seat quantity changes, plan switches, cancellation allowed)
- [ ] Register webhook endpoint: `https://api.hydra.iru.dev/api/webhooks/stripe`
- [ ] Subscribe to all 8 events (see webhook section in spec)
- [ ] Copy webhook signing secret to env vars
- [ ] Copy all Price IDs and Portal Config ID to env vars

### Local tooling

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

# Authenticate
stripe login

# Forward test webhooks locally during development
stripe listen --forward-to localhost:8000/api/webhooks/stripe
```

### Env vars (test mode to start)

```
STRIPE_SECRET_KEY=sk_test_...
STRIPE_PUBLISHABLE_KEY=pk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
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_...
```

None of these values go in code or in git. Ever.

---

## Idempotency Requirements

Stripe retries webhook deliveries up to 5 times over 72 hours if your endpoint returns a non-200. This means every webhook handler **will receive the same event more than once** in production. Every handler must be safe to run multiple times.

The pattern for all handlers:

```python
def handle_invoice_paid(event):
    invoice = event["data"]["object"]
    subscription_id = invoice["subscription"]

    tenant = db.get_tenant_by_subscription(subscription_id)
    if tenant is None:
        log.warning("invoice.paid: no tenant found for sub %s", subscription_id)
        return  # return 200 anyway — Stripe will not retry for unknown subs

    # Idempotency check: if already active, skip
    if tenant.stripe_subscription_status == "active" and tenant.plan_tier != "free":
        log.info("invoice.paid: tenant %s already provisioned, skipping", tenant.id)
        return

    # Apply changes
    db.update_tenant(tenant.id,
        plan_tier=price_id_to_tier(invoice["lines"]["data"][0]["price"]["id"]),
        stripe_subscription_status="active",
        stripe_current_period_end=invoice["period_end"],
    )
```

Apply this pattern to every handler. Check current state before writing. If already in the target state, return early.

**Never return 4xx or 5xx from the webhook handler.** If your DB write fails, log the error and return 200. Stripe retrying is worse than having a missed event you can investigate and replay.

---

## Implementation Plan

### Day 1 — Mon May 5: Foundation

| Card | Title | Estimate |
|---|---|---|
| BILL-01 | Stripe account + Products + Prices + Portal config | 0.5d (ops, not code) |
| BILL-02 | DB migration — billing columns on tenants table | 2h |

**BILL-02 includes:**
- All 11 billing columns (see spec for full SQL)
- 3 indexes: `stripe_customer_id`, `stripe_subscription_id`, `free_tier_reset_at WHERE plan_tier = 'free'`
- Test the migration forward AND backward (rollback must work)

**Claude Code prompt:**
```
Read /Users/tristan.benozer/Desktop/Hydra/hydra-stripe-billing-spec.md, section "Database Schema".
Write the Alembic migration file. Include the rollback (downgrade) function. Add the three indexes.
After writing, tell me the exact command to run the migration and the command to roll it back.
```

**Day 1 done criteria:** Migration runs without error. Rollback runs without error. Stripe account exists in test mode with all Price IDs copied to env.

---

### Day 2 — Tue May 6: Free Tier + Webhook Infrastructure

| Card | Title | Estimate |
|---|---|---|
| BILL-03 | Free tier enforcement middleware | 0.5d |
| BILL-04 | Free tier reset job (rolling 30-day per tenant) | 2h |
| BILL-07 | Webhook endpoint + Stripe signature verification | 2h |

**BILL-03 requirements:**
- `check_free_tier_limit(tenant_id, workflow_type)` raises `FreeTierLimitReached` before execution
- `increment_free_tier_counter(tenant_id, workflow_type)` called only after successful execution, not before
- Paid tenants (`plan_tier != 'free'`) skip the check entirely
- On first workflow run: initialize `free_tier_reset_at = now() + 30 days`

**BILL-04 requirements:**
- Cron must be idempotent: safe to run multiple times per hour
- Uses SQL `free_tier_reset_at + INTERVAL '30 days'` (not `NOW() + 30 days`) to preserve the rolling window per tenant
- Logs tenant count reset per run

**BILL-07 requirements:**
- Signature verification is not optional. Ever. Without it, any caller can forge billing events.
- Return 400 only on invalid signature. Return 200 for all handled events, including errors.
- Log every event with event.id for debugging replay later.

```python
@app.route("/api/webhooks/stripe", methods=["POST"])
def stripe_webhook():
    payload = request.get_data()
    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:
        log.warning("Webhook signature verification failed")
        return jsonify({"error": "invalid signature"}), 400

    log.info("Received Stripe event: %s id=%s", event["type"], event["id"])

    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_entitlement_updated,
    }

    handler = handlers.get(event["type"])
    if handler:
        try:
            handler(event)
        except Exception as e:
            log.error("Webhook handler error: %s event_id=%s", e, event["id"])
            # Return 200 even on handler error — prevents Stripe from retrying a bad event
    else:
        log.debug("Unhandled event type: %s", event["type"])

    return jsonify({"received": True}), 200
```

**Day 2 done criteria:** Free tier blocks at limit. Webhook endpoint receives test events from `stripe trigger invoice.paid` and logs them. Stripe CLI local forwarding works.

---

### Day 3 — Wed May 7: Checkout + Core Provisioning

| Card | Title | Estimate |
|---|---|---|
| BILL-05 | Checkout session endpoint | 0.5d |
| BILL-06 | Post-checkout success page | 2h |
| BILL-08 | `invoice.paid` handler — provision paid access | 2h |

**BILL-08 is the most important handler.** It is the canonical provisioning signal. The Checkout success URL is not. A user closing their browser tab before redirect means the success URL never fires. `invoice.paid` always fires.

**BILL-08 idempotency check:**
- If `plan_tier` is already correct and `stripe_subscription_status == 'active'`, return early without writing
- Log that it was skipped

**Day 3 done criteria (testable with Stripe CLI):**
```bash
# Trigger a test checkout + invoice.paid event
stripe trigger payment_intent.succeeded
stripe trigger invoice.paid

# Verify in DB: tenant.plan_tier should be 'team' or 'business'
# Verify in DB: stripe_subscription_status should be 'active'
```

Full manual test: start as free user, click Upgrade, complete Stripe Checkout with test card `4242 4242 4242 4242`, return to app, verify access is provisioned.

---

### Day 4 — Thu May 8: Full Webhook Coverage

| Card | Title | Estimate |
|---|---|---|
| BILL-09 | `invoice.payment_failed` + grace period | 2h |
| BILL-12 | `checkout.session.completed` + `customer.subscription.created` | 2h |
| BILL-10 | `customer.subscription.updated` — the complex one | 0.5d |

**BILL-09 grace period implementation:**
- Set `stripe_subscription_status = 'past_due'`
- Do NOT immediately revoke access
- Schedule access revocation at `now() + 7 days` (or use `current_period_end` if sooner)
- Send notification email to billing contact

**BILL-10 requires inspecting `data.previous_attributes`:**
```python
def handle_subscription_updated(event):
    sub = event["data"]["object"]
    prev = event["data"].get("previous_attributes", {})

    if "quantity" in prev:
        db.update_tenant_by_subscription(sub["id"], stripe_seat_count=sub["quantity"])

    if "status" in prev:
        if sub["status"] == "active" and prev["status"] == "past_due":
            # Payment recovered — re-provision
            db.update_tenant_by_subscription(sub["id"],
                stripe_subscription_status="active",
                plan_tier=price_id_to_tier(sub["items"]["data"][0]["price"]["id"]),
            )
        elif sub["status"] in ("canceled", "unpaid"):
            # Access revokes at period end, not now
            db.update_tenant_by_subscription(sub["id"],
                stripe_subscription_status=sub["status"],
            )

    if "items" in prev:
        # Price changed (plan upgrade/downgrade)
        new_price_id = sub["items"]["data"][0]["price"]["id"]
        db.update_tenant_by_subscription(sub["id"],
            stripe_price_id=new_price_id,
            plan_tier=price_id_to_tier(new_price_id),
        )
```

**Day 4 done criteria (Stripe CLI test triggers):**
```bash
stripe trigger customer.subscription.updated
stripe trigger invoice.payment_failed

# Verify: past_due status set, access not immediately revoked
# Verify: on subscription.updated with status active from past_due, access restored
```

---

### Day 5 — Fri May 9: Subscription Management Backend

| Card | Title | Estimate |
|---|---|---|
| BILL-11 | `customer.subscription.deleted` handler | 2h |
| BILL-13 | Seat update endpoint — proration preview + apply | 0.5d |
| BILL-14 | Plan upgrade endpoint | 2h |

**BILL-11: never revoke immediately.**
```python
def handle_subscription_deleted(event):
    sub = event["data"]["object"]
    period_end = sub["current_period_end"]

    db.update_tenant_by_subscription(sub["id"],
        stripe_subscription_status="canceled",
        # plan_tier stays as-is until period_end
        # Access gating middleware reads stripe_subscription_status + period_end
    )
    # Schedule a job to flip plan_tier = 'free' at period_end
    schedule_access_revocation(sub["id"], at=period_end)
```

**BILL-13 proration drift prevention (critical):**
```python
# Step 1: Frontend calls with preview=true
proration_date = int(time.time())
preview = create_proration_preview(sub_id, seat_count, proration_date)
return {"amount_due": preview.amount_due, "proration_date": proration_date}

# Step 2: User confirms. Frontend sends back the SAME proration_date.
# Never recalculate proration_date on the apply call.
apply_seat_change(sub_id, seat_count, proration_date=request.proration_date)
```

If `proration_date` is recalculated on apply, the amount changes by a few cents (sometimes dollars on large seat counts). Users will notice.

**End of Week 1 milestone:** Full backend exists. Free tier blocks. Upgrade flow works. All 8 webhook handlers exist and are idempotent.

---

### Day 6 — Mon May 12: Access Gating + Management APIs

| Card | Title | Estimate |
|---|---|---|
| BILL-15 | Customer Portal session endpoint | 1h |
| BILL-16 | Access gating middleware | 0.5d |
| BILL-17 | Billing settings API | 0.5d |

**BILL-16 access gating logic:**
```python
GRACE_PERIOD_DAYS = 7

def require_paid(plan_tier_required):
    def decorator(f):
        @wraps(f)
        def wrapper(tenant_id, *args, **kwargs):
            tenant = db.get_tenant(tenant_id)

            tier_rank = {"free": 0, "team": 1, "business": 2, "enterprise": 3}
            required_rank = tier_rank[plan_tier_required]
            current_rank = tier_rank.get(tenant.plan_tier, 0)

            if current_rank >= required_rank:
                # Check if subscription is in a revoked state
                if tenant.stripe_subscription_status in ("canceled", "unpaid"):
                    # Still within the paid period?
                    if tenant.stripe_current_period_end and \
                       tenant.stripe_current_period_end > now():
                        return f(tenant_id, *args, **kwargs)  # access continues until period end
                    raise SubscriptionRequired(tier=plan_tier_required)

                if tenant.stripe_subscription_status == "past_due":
                    # Within grace period?
                    grace_end = tenant.past_due_since + timedelta(days=GRACE_PERIOD_DAYS)
                    if now() < grace_end:
                        return f(tenant_id, *args, **kwargs)  # grace period
                    raise SubscriptionRequired(tier=plan_tier_required, reason="payment_failed")

                return f(tenant_id, *args, **kwargs)  # active

            raise SubscriptionRequired(tier=plan_tier_required)
        return wrapper
    return decorator
```

**Day 6 done criteria:** A free user hitting a paid endpoint gets a structured `SubscriptionRequired` error. A past_due user within grace period gets through. A canceled user after period end is blocked.

---

### Day 7 — Tue May 13: Frontend — Prompts + Free Tier Tests

| Card | Title | Estimate |
|---|---|---|
| BILL-22 | Free tier enforcement unit tests | 2h |
| BILL-18 | Upgrade prompt components (frontend) | 0.5d |

**BILL-22 test scenarios:**

```python
# 1. Counter below limit — allow
# 2. Counter at limit — block, raise FreeTierLimitReached
# 3. Counter at limit, tenant is paid — allow (skip check)
# 4. Counter at limit, reset job runs — counter zeroed, request allowed
# 5. Reset job runs twice — counter not double-zeroed
# 6. free_tier_reset_at is NULL on first run — initializes to now() + 30 days
# 7. Reset job advances reset_at by 30 days from previous reset_at, not from now()
```

**Day 7 done criteria:** All 7 unit test scenarios pass. Upgrade prompt component renders at each limit trigger with correct copy from pricing doc.

---

### Day 8 — Wed May 14: Frontend — Billing Pages

| Card | Title | Estimate |
|---|---|---|
| BILL-19 | Billing/upgrade page (tier + seat count + Checkout redirect) | 0.75d |
| BILL-20 | Billing settings page | 0.5d |

**BILL-19 required behavior:**
- Seat count changes update total price in real time before the user clicks Upgrade
- Monthly/annual toggle updates the price_id sent to the checkout endpoint
- Checkout URL is fetched from the backend — never constructed on the frontend
- On error from checkout endpoint, show specific error, not a generic failure

**BILL-20 required behavior:**
- Shows current plan, seat count, next renewal date
- "Manage subscription" calls the portal endpoint, redirects to portal URL
- Portal URL must not be cached — generate a new session on each click
- For free users: shows current usage vs limits with a progress indicator

---

### Day 9 — Thu May 15: Post-Checkout UI + Integration Tests

| Card | Title | Estimate |
|---|---|---|
| BILL-21 | Post-checkout success page | 2h |
| BILL-23 | Stripe test mode integration tests | 1d |

**BILL-21 provisioning poll:**
The success page cannot show "You're on Team" until `invoice.paid` has fired and DB is updated. Two options:
- Poll `GET /api/billing/status` every 2 seconds for up to 30 seconds
- WebSocket/SSE push when webhook fires (better, more work)

Minimum viable: poll with a 10-second timeout, show "Provisioning... this usually takes a few seconds."

**BILL-23 test scenarios to run against Stripe test mode:**

| Scenario | Test card | Expected result |
|---|---|---|
| Successful upgrade | 4242 4242 4242 4242 | `plan_tier=team`, `stripe_subscription_status=active` |
| Payment failure at checkout | 4000 0000 0000 0002 | Checkout declines, user stays free |
| Post-upgrade payment failure | 4000 0000 0000 0341 | `status=past_due`, access within grace period |
| Grace period expiry | manual | Access revoked, plan_tier=free |
| Successful seat increase | n/a | `stripe_seat_count` updated, proration created |
| Team → Business upgrade | n/a | `plan_tier=business`, quantity preserved |
| Cancellation | n/a | Access until period_end, then free |
| Subscription deleted | n/a | Access until period_end, then plan_tier=free |

---

### Day 10 — Fri May 16: Webhook Tests + Pre-Live Audit

| Card | Title | Estimate |
|---|---|---|
| BILL-24 | Webhook handler tests via Stripe CLI | 1d |

**BILL-24 — test each event with `stripe trigger`:**

```bash
stripe trigger checkout.session.completed
stripe trigger customer.subscription.created
stripe trigger customer.subscription.updated
stripe trigger customer.subscription.deleted
stripe trigger invoice.paid
stripe trigger invoice.payment_failed
stripe trigger invoice.payment_action_required

# Verify idempotency: fire each event twice, check DB is not corrupted
stripe trigger invoice.paid && stripe trigger invoice.paid  # second should be a no-op
```

**Day 10 done criteria:** All 8 event types trigger without errors. Firing the same event twice does not corrupt tenant state. All test scenarios from BILL-23 pass.

---

## Pre-Live Security Checklist

Do not switch to live Stripe keys until every item is checked.

**Secrets**
- [ ] No Stripe keys appear anywhere in the codebase (`grep -r "sk_test_" .` returns nothing)
- [ ] No Stripe keys in git history (`git log -p | grep sk_test` returns nothing)
- [ ] Webhook secret is an env var, not a hardcoded string
- [ ] All 9 billing env vars are set in production config

**Webhook handler**
- [ ] Signature verification runs on every request — not just some
- [ ] Endpoint returns 200 for all handled event types
- [ ] Endpoint returns 200 even when a handler throws an exception (after logging)
- [ ] Endpoint returns 400 only on signature verification failure
- [ ] Each handler is idempotent — verified by firing same event twice in tests
- [ ] Webhook endpoint URL in Stripe Dashboard matches the production URL exactly

**Access gating**
- [ ] No paid feature can be accessed by a free user — tested manually
- [ ] A `past_due` tenant within grace period can access paid features
- [ ] A `past_due` tenant past grace period cannot access paid features
- [ ] A `canceled` tenant within `current_period_end` can access paid features
- [ ] A `canceled` tenant past `current_period_end` cannot access paid features

**Data integrity**
- [ ] No tenant can have `plan_tier != 'free'` with a NULL `stripe_subscription_id`
- [ ] No tenant can have `stripe_subscription_status = 'active'` with `plan_tier = 'free'`
- [ ] DB indexes exist on `stripe_customer_id`, `stripe_subscription_id`
- [ ] Migration rollback was tested

**Stripe setup**
- [ ] All 4 Price objects have correct amounts (verify in Dashboard: $20, $192, $40, $384)
- [ ] Webhook endpoint is registered in the LIVE account (not just test mode)
- [ ] Live webhook secret is different from test webhook secret — update env var

---

## Go-Live Procedure

Run this sequence exactly. Do not skip steps.

```
1. Final test run in test mode — all BILL-23 + BILL-24 scenarios pass
2. In Stripe Dashboard: switch to Live mode
3. Create Products + Prices in live mode (same amounts as test)
4. Register webhook endpoint in live mode → copy new webhook signing secret
5. Update all STRIPE_* env vars to live keys
6. Deploy
7. Smoke test: attempt a real upgrade with a real card (can refund immediately)
8. Monitor webhook delivery in Stripe Dashboard for 30 minutes
9. Monitor application logs for any webhook errors
10. If anything fails: execute rollback (see below)
```

Do not go live on a Friday. Go live Monday or Tuesday morning when Tyler is available to monitor.

---

## Incident Runbooks

### Customer paid but has no access

**Symptoms:** Customer contacts support saying they completed checkout but features are locked.

**Diagnosis:**
```bash
# Check Stripe Dashboard: did invoice.paid fire?
# Check application logs for webhook receipt of that invoice
# Check DB: what is the tenant's stripe_subscription_status?
```

**Resolution:**
1. If `invoice.paid` was received but DB was not updated: replay the event from Stripe Dashboard
2. If `invoice.paid` was never delivered: check webhook endpoint health, manually trigger from Dashboard
3. Emergency: manually set `plan_tier` and `stripe_subscription_status` in DB while fixing root cause

---

### Webhook endpoint returning 5xx

**Symptoms:** Stripe Dashboard shows failed webhook deliveries.

**Note:** Stripe retries up to 5 times over 72 hours. You have time to fix before events are permanently lost.

**Resolution:**
1. Check application logs for the exception
2. Fix the bug, deploy
3. Stripe will retry automatically
4. If the retry window has passed: replay events manually from Stripe Dashboard (each event has a "Resend" button)

---

### Double billing (customer charged twice)

**Symptoms:** Customer sees two charges on their card.

**Diagnosis:**
```bash
# Check Stripe Dashboard for duplicate subscriptions on the customer
# Check DB for duplicate stripe_subscription_id entries for the tenant
```

**Resolution:**
1. Cancel the duplicate subscription in Stripe Dashboard immediately
2. Issue a full refund for the duplicate charge via Dashboard
3. Ensure DB only has one `stripe_subscription_id` for the tenant
4. Root cause: usually a double-submit on the checkout endpoint — add frontend disable-on-click

---

### Proration amount mismatch (customer dispute)

**Symptoms:** Customer was charged a different amount than the preview showed.

**Diagnosis:**
- Check logs for the `proration_date` used in preview vs apply
- If different: this is the drift bug

**Resolution:**
1. If difference is cents: expected, normal. Explain to customer.
2. If difference is significant: the apply call recalculated `proration_date`. Issue partial refund. Fix the code to pass `proration_date` from the preview response.

---

### Customer can't cancel (portal is broken)

**Symptoms:** Customer Portal redirect fails or portal doesn't load.

**Resolution:**
1. Tyler can cancel directly from Stripe Dashboard on behalf of the customer
2. Fix the portal session endpoint
3. Portal session URLs expire — never cache them

---

## Rollback Plan

If billing breaks in production:

**Option A: Feature flag (preferred)**
- Before go-live, add a `BILLING_ENABLED` env var (default `false` in dev)
- If billing is broken: set `BILLING_ENABLED=false` in production
- Effect: all tenants treated as free tier; no access checks on paid features
- This is a safe failure mode for PLG — users would rather have free access than an error

**Option B: DB rollback**
- If the DB migration caused the problem: run `alembic downgrade -1`
- This removes the billing columns
- Verify downgrade was tested before going live

**Option C: Stripe-side**
- If Stripe configuration is wrong (wrong prices, wrong portal config): fix in Stripe Dashboard without a deploy
- Webhook endpoint can be disabled from Dashboard to stop processing events while debugging

---

## Post-Ship: Hydra Dog-Fooding

After billing merges and is stable for one week:

```
1. hydra discover  — index the billing module
2. hydra audit     — run the full audit gauntlet on billing code
                     (are we properly validating webhook signatures?
                      are we logging secrets accidentally?)
3. hydra fix       — merge whatever audit surfaces
4. hydra improve   — optimize webhook handlers, DB query patterns
```

Billing is the first internal Hydra target. It has real production risk, bounded scope, and clear correctness criteria. If Hydra can audit and improve its own billing code, that is a meaningful dog-fooding milestone.

---

## Reference

| File | Purpose |
|---|---|
| `hydra-stripe-billing-research.md` | Multi-source Stripe API research, primary citations, 24 Kanban cards |
| `hydra-stripe-billing-spec.md` | Full implementation spec — DB schema, Python pseudocode for every endpoint and handler |
| `hydra-stripe-billing-roadmap.md` | This file |
| `hydra-stripe-billing-linear.csv` | Linear-importable CSV (24 tickets) |
