Raxx · internal docs

internal · gated

Customer Detail View — Design Doc

Issue: #409 (customer detail view — billing console) Epic: #403 (billing console) Refs: #405 (data model), #406 (Stripe service layer), #407 (webhook handler), #408 (customer list) Date: 2026-05-11 UTC Status: Design — ready for feature-developer implementation once #406 + #407 merge


1. Context

Issue #409 is the operator-facing customer detail page at GET /console/customers/<customer_id> in the Console service. It is the primary surface a support operator uses to answer billing questions without touching the Stripe dashboard directly.

The parent design doc (docs/architecture/stripe-customer-billing.md) was re-anchored on 2026-05-11 so that Queue is the authoritative billing store and Console is a pure reader of Queue's HTTP API. This document absorbs all the open decisions surfaced in PR #1604 comments (L1, L416, L424, L426) and the operator's 2026-05-11 review directive.

The scope covers the billing sections only (subscription state, invoices, billing actions, narrative event counts, downgrade banner, DSR sidebar). The non-billing sections already in console/app/templates/customers/detail.html (Identity, Active Sessions, Broker State, Audit Timeline, Support Tickets) are not modified by this design.


2. Invariants

# Invariant
I-1 No stored credentials. Stripe key is fetched from vault at call time; never in DB or source.
I-2 All money amounts stored as INTEGER (cents). Formatted at render time only.
I-3 Audit log row for every state-changing action (cancel, refund, comp, reactivate).
I-4 All billing PII lives in Queue-DB only. Console reads Queue's HTTP API; no Console-DB billing tables.
I-5 Operator sees everything regardless of customer's current plan tier.
I-6 Action buttons are RBAC-gated at the Queue route, not just the Console template.
I-7 No single aggregated "trust score" visible to operator (FCRA/GDPR risk avoidance). Raw event counts only.
I-8 Customer-facing UI hides unavailable features (feedback: feedback_hide_dont_gray_unavailable_features.md). Operator view is always full-fidelity.
I-9 Idempotency token required on all mutating form POSTs to prevent double-action on double-click.
I-10 All timestamps UTC.

3. Data Sources

Console calls Queue's internal API. Queue returns all billing data as JSON. Console templates receive a context dict from the Flask view — no billing schema in Console-DB.

GET /api/internal/billing/customer/<queue_customer_id>
→ {
    customer: { billing_email, billing_name, address_*, default_pm_*, stripe_customer_id },
    subscription: { plan_tier, status, current_period_end, cancel_at_period_end,
                    canceled_at, stripe_price_id, feature_locked_at, prior_tier },
    invoices: [ { stripe_invoice_id, amount_due, amount_paid, status, paid_at,
                  hosted_invoice_url, invoice_pdf_url, stripe_created_at }, ... ],
    event_counts: { late_payment_count, chargeback_count, failed_charge_count },
    recent_events: [ { event_type, occurred_at, status, amount_cents }, ... ]  // last 5
  }

feature_locked_at and prior_tier are two new columns added to billing_subscription (Queue-DB) as part of the downgrade-resistance work (L424 thread, arch-v3 dispatch). These are described in §5.4 below.


4. Page Layout — Billing Sections

The billing sections are appended after the existing Section 4 (Audit Timeline) and before Section 5 (Support Tickets), or gathered in a dedicated "Billing" tab if the operator decides to tab-split the page post-v1. For v1 launch: inline sections, no tab UI.

Section 6: Subscription & Payment
Section 7: Invoice History
Section 8: Billing Actions
Section 9: Narrative Event Counts
Section 10: Data Privacy Actions (DSR sidebar)

A downgrade banner is injected at the top of the page (above Section 1) when subscription.feature_locked_at IS NOT NULL.


5. Section Specifications

5.1 Downgrade Banner (conditional — top of page)

Condition: subscription.feature_locked_at IS NOT NULL

[Banner: amber border, amber-900/20 bg]
Customer downgraded from <prior_tier> on <feature_locked_at UTC>.
Their access is now at the <plan_tier> tier.

Operator context: this is an informational banner only. Operators see all data regardless of customer tier. No action required from this banner.

5.2 Section 6: Subscription and Payment

Displays current subscription state and payment method. Read-only fields rendered as a definition list grid (matches existing Identity section style).

Fields: - Plan tier (badge: free gray / founders amber / pro blue / pro_plus purple) - Status (badge: active green / past_due amber / canceled red / unpaid red / incomplete slate / incomplete_expired slate) - Current period (start → end, UTC) - Cancel at period end (Yes/No) - Canceled at (UTC, if set) - Default payment method: {brand} ending {last4} / exp {month}/{year}

5.3 Section 7: Invoice History

Table of billing_invoice rows, newest first, max 25 for v1.

Columns: - Invoice ID (Stripe in_... — monospace, truncated to 12 chars + ...) - Amount due (formatted: $X.XX) - Amount paid (formatted: $X.XX) - Status (badge) - Date (UTC) - Links: "View invoice ↗" (opens hosted_invoice_url in new tab) | "PDF ↗" (opens invoice_pdf_url in new tab)

Q-INVOICE-LINE-ITEMS resolved: Option 1 (Stripe-hosted invoice URL) for v1. No in-app line-item rendering. hosted_invoice_url and invoice_pdf_url are stored on each invoice row by the webhook handler. Feature-developer links to both. Post-v1 enhancement (Option 2: billing_invoice_line_item table) deferred to after launch — file as a follow-on card once v1 ships.

Empty state: Customer has no invoices yet. Text: "First invoice will appear after first billing cycle."

5.4 Section 8: Billing Actions

Action buttons gated by RBAC. Buttons not visible to users lacking the role (hide, not gray — per invariant I-8).

RBAC matrix:

Action Required role POST route Async?
Issue refund billing-write POST /api/internal/billing/customer/<id>/refund Yes
Issue comp credit billing-write POST /api/internal/billing/customer/<id>/comp Yes
Cancel subscription billing-admin POST /api/internal/billing/customer/<id>/cancel Yes
Reactivate subscription billing-admin POST /api/internal/billing/customer/<id>/reactivate Yes

billing-write is scoped to support leads. billing-admin is scoped to break-glass only for v1 (mirrors queue-billing-write seeding decision in §6 of the main design).

Confirm modal copy (required — hardcoded, not template-interpolated):

Double-click prevention: Every modal POST includes a UUID idempotency token generated client-side at modal-open time. The Queue route rejects duplicate submissions on the same token within a 5-minute window (keyed in Redis or a small DB table).

Async behavior: Queue fires the Stripe API call, returns 202 immediately. Console shows a toast and closes the modal. The billing section refreshes on a WebSocket event from Queue (event type: billing.action.completed) or on a manual "Refresh" button click after 3 seconds. No polling.

Audit log row per action: Queue writes one row to billing_action_log per mutating request:

actor_id   = operator's queue_customer_id (from the service token claims)
action     = 'refund' | 'comp' | 'cancel' | 'reactivate'
entity_type = 'billing_subscription' | 'billing_invoice'
entity_id  = the relevant row UUID
payload    = { amount_cents, idempotency_token, stripe_idempotency_key, stripe_response_status }

5.5 Section 9: Narrative Event Counts

Derived from billing_invoice rows at render time — NOT a stored score column. This is the resolution of the L1 thread discussion on narrative/trust scoring: raw event counts avoid FCRA adverse-action notice risk and GDPR Art. 22 automated-decision scrutiny.

Badges (inline, not a table):

Late payments:   [N]   (0=green, 1-2=yellow, 3+=red)
Chargebacks:     [N]   (0=green, 1+=red)
Failed charges:  [N]   (0=green, 1-2=yellow, 3+=red)

Recent Events strip (last 5 invoice/payment events): Minimal timeline: {occurred_at UTC} {event_type} {status} {amount}

Event types shown: invoice.paid, invoice.payment_failed, invoice.voided, customer.subscription.updated (downgrade/upgrade), charge.dispute.created (chargeback).

Source: the recent_events array returned by Queue's detail endpoint (built from billing_invoice rows + Stripe event log for disputes). Queue computes this server-side; Console renders it.

No single aggregated number. BLR sign-off is pending; until BLR confirms the legal posture, no aggregate score column is added to the schema. This is the provisional architecture pending the BLR memo.

5.6 Section 10: Data Privacy Actions

Sidebar section (right column on wider screens, below billing actions on narrow). Heading: "Data privacy actions"

Two buttons: 1. Export customer dataPOST /api/internal/billing/customer/<id>/dsr/export - Requires billing-admin role - Triggers an async export job; customer receives a download link at their billing email within 24 hours - Confirm modal: "This will prepare a full export of [customer email]'s personal data and send a download link to [billing email]. This action is recorded in the audit log." - Audit log row: action = 'dsr_export_requested'

  1. Erase customer data — opens the DSR erasure confirmation flow (a separate sub-card; not implemented in this card) - Requires billing-admin role - Confirm modal: "This will permanently anonymize [customer email]'s personal data. Invoice rows are retained for 7-year tax compliance but all PII fields are scrubbed. A confirmation will be emailed to [billing email]. This cannot be undone. This action is recorded in the audit log." - The actual erasure job is filed as a separate card (DSR card per L418 thread). This button is a stub that fires POST /api/internal/billing/customer/<id>/dsr/erase with a confirmation token; the stub returns 202 and Queue enqueues the job.

Both routes are audit-logged. Both are hidden from users lacking billing-admin.


6. State Machine — Loading, Empty, Error

stateDiagram-v2
    [*] --> Loading : page render
    Loading --> Loaded : Queue API 200
    Loading --> QueueDown : Queue API 5xx / timeout
    Loaded --> Empty : invoices.length == 0
    Loaded --> Populated : invoices.length > 0
    QueueDown --> Loaded : user clicks Retry

    state Populated {
        [*] --> ActionIdle
        ActionIdle --> ModalOpen : operator clicks action button
        ModalOpen --> ActionIdle : operator cancels
        ModalOpen --> ActionPending : operator confirms
        ActionPending --> ToastShown : Queue returns 202
        ToastShown --> ActionIdle : WebSocket event / manual refresh
    }

Copy: - Loading: Skeleton loaders (matching Confidence Engine aesthetic — no spinners). Use same skeleton pattern as other console surfaces (e.g., replay timeline). - Empty (no invoices): "First invoice will appear after first billing cycle." — shown inside Section 7 only; other billing sections still render. - Queue API down: "Billing data temporarily unavailable. Retry in 30s." with a manual Retry button. The entire billing block (Sections 6-10) is replaced by this error state. Identity, Sessions, Broker State sections still render from their own Queue calls. - Partial data (subscription OK, invoices fail): render subscription section, replace invoice section with degraded state message. Do not fail the entire page.


7. Sequence Diagram

sequenceDiagram
    participant OP as Operator browser
    participant CON as Console Flask view
    participant QA as Queue /api/internal/billing
    participant QDB as Queue-DB

    OP->>CON: GET /console/customers/<id>
    CON->>QA: GET /api/internal/billing/customer/<queue_cid>
    QA->>QDB: SELECT billing_customer + subscription + invoices (last 25) + event counts
    QDB-->>QA: rows
    QA-->>CON: JSON
    CON-->>OP: rendered HTML (skeleton → content)

    OP->>OP: clicks "Cancel subscription"
    OP->>OP: modal opens; idempotency token generated
    OP->>CON: POST /console/customers/<id>/billing/cancel + idempotency_token
    note over CON: RBAC check: billing-admin
    CON->>QA: POST /api/internal/billing/customer/<id>/cancel
    QA->>QDB: write billing_action_log row
    QA->>QA: enqueue Stripe API call (async)
    QA-->>CON: 202 Accepted
    CON-->>OP: 202; toast shown; modal closed
    QA->>QA: Stripe cancel call completes
    QA->>OP: WebSocket event billing.action.completed
    OP->>OP: billing section refreshes

8. API Contracts (new Queue routes)

These routes are added by the feature-developer implementing Queue-side cards (#406, #407 are prerequisites). This doc specifies the contract so Console and Queue implementers work in parallel.

8.1 Read (already in v2 design)

GET /api/internal/billing/customer/<queue_customer_id>
Auth: service-to-service token (Console → Queue)
Response 200: see §3
Response 404: { "error": "customer not found" }
Response 503: { "error": "queue_db_unavailable" }

8.2 Mutating actions (new)

All return 202 Accepted on success. Queue processes async. All require billing-write or billing-admin per RBAC matrix in §5.4. All accept idempotency_token (UUID string) in request body.

POST /api/internal/billing/customer/<id>/refund
Body: { stripe_invoice_id, amount_cents, idempotency_token }

POST /api/internal/billing/customer/<id>/comp
Body: { amount_cents, description, idempotency_token }

POST /api/internal/billing/customer/<id>/cancel
Body: { cancel_at_period_end: true/false, idempotency_token }

POST /api/internal/billing/customer/<id>/reactivate
Body: { idempotency_token }

POST /api/internal/billing/customer/<id>/dsr/export
Body: { idempotency_token }

POST /api/internal/billing/customer/<id>/dsr/erase
Body: { confirmation_token, idempotency_token }

Error responses: 400 (invalid body), 403 (RBAC), 409 (duplicate idempotency token within 5-min window), 422 (business rule violation, e.g. cancel on already- canceled sub), 503 (Stripe unreachable).


9. Schema Additions (Queue-DB)

These columns are added to the existing billing_subscription table as a non-breaking additive migration (queue_0011_subscription_downgrade_state):

ALTER TABLE billing_subscription
  ADD COLUMN feature_locked_at  TIMESTAMPTZ  NULL,
  ADD COLUMN prior_tier         TEXT         NULL
    CHECK (prior_tier IN ('free','founders','pro','pro_plus') OR prior_tier IS NULL);

feature_locked_at is set by the webhook handler when a customer.subscription.updated event carries a tier downgrade (new_plan_tier < old_plan_tier by rank). prior_tier records the outgoing tier for display in the downgrade banner.

The partial unique index for single-active-subscription enforcement (from the L424 thread) is included in the same migration:

CREATE UNIQUE INDEX uq_billing_subscription_active_per_customer
ON billing_subscription (billing_customer_id)
WHERE status IN ('active', 'trialing');

Rollback: downgrade() drops the unique index and removes the two columns. Safe to roll back because both columns are nullable and the unique index only blocks new violations.


10. Security Considerations


11. GDPR / Security Checklist

Question Answer
What PII does this collect? None new. Displays existing Queue-DB PII (billing email, name, address) to authorized operators only.
What is the retention period? Billing PII: 7 years post-customer-deletion per I-5. Invoice rows: permanent (tax floor).
How is it deleted on DSR? DSR erasure button triggers Queue's anonymization job (§5.6).
What is logged for audit? Every mutating action: actor, action, entity, payload, timestamp. Log retention: per Queue audit policy (SOC2 floor = 1 year).
Does any part store a credential in replayable form? No. Stripe key is vault-fetched at call time. Idempotency tokens are single-use.
What happens on breach? Billing tables are in Queue-DB. Queue is in the breach-scope inventory (per main design §7.4). DPA notified within 72 hours per GDPR Art. 33.
Where are secrets? STRIPE_RESTRICTED_KEY, STRIPE_WEBHOOK_SECRET in Infisical /Raxx/Queue/Billing/Stripe/. Rotatable without redeploy.
Is there a kill-switch for live execution paths? FLAG_BILLING_QUEUE_API=false on raxx-queue-prod hides all billing routes immediately. Individual action routes can be gated behind FLAG_BILLING_ACTIONS (new flag, seeded in this card).

12. Phase Split (v1 Launch vs Post-launch)

Phase 1 — v1 Launch (in scope for #409)

Phase 2 — Post-launch


13. Dependencies

Dependency Card Status
Queue billing schema (tables, migrations) #405 Design merged — implementation pending
Stripe service layer (Queue) #406 Unblocked
Webhook handler (Queue) #407 Unblocked
Customer list view (Console) #408 Parallel — no dependency on #409
Queue action routes (refund, comp, cancel, reactivate, DSR) New — see §13 sub-cards To be filed
Raptor Postgres migration #1556 Blocks Raptor mirror only; does not block #409
DSR full erasure flow Separate card (per L418 thread) Post-launch path
BLR memo on narrative score legal posture Pending Does not block display of raw counts

14. Sub-cards (implementation-sized)

# Title Assignee Phase
TBD Queue: billing action routes (refund/comp/cancel/reactivate) feature-developer P1
TBD Queue: DSR export + erase stub routes feature-developer P1
TBD Queue: feature_locked_at + prior_tier migration + webhook handler update feature-developer P1
TBD Queue: FLAG_BILLING_ACTIONS feature flag seed feature-developer P1
TBD Console: billing sections 6–10 template + view + route stubs feature-developer P1
TBD Console: confirm modals + idempotency token client JS feature-developer P1
TBD Console: WebSocket refresh on billing.action.completed feature-developer P2
TBD Queue: billing_invoice_line_item table + line-item rendering feature-developer P2

15. Open Questions

ID Question Blocking?
OQ-1 BLR memo on narrative score legal posture (FCRA/GDPR Art. 22). Does displaying late_payment_count to operators require disclosure to the customer? No — raw counts display can ship; memo affects future aggregate scoring only
OQ-2 DSR full erasure: should the Queue DSR erase job also call Stripe's DELETE /v1/customers/{id} API? Deleting the Stripe customer voids hosted invoice URLs. Blocks DSR erasure full flow; does not block #409 v1 stub
OQ-3 Action button WebSocket: which channel/protocol? Console currently uses SSE for some surfaces. Confirm WebSocket vs SSE for billing action refresh. Blocks P2 WebSocket card only
OQ-4 billing-write role seeding: is it seeded to any group for v1 launch, or stay break-glass? Determines whether any operator can issue refunds pre-launch. Operator decision needed before billing actions go live