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):
- Refund: "This will issue a refund of $X.XX to the customer's card ending in XXXX. The refund typically appears in 5-10 business days. This action is permanent and is recorded in the audit log."
- Comp: "This will issue a $X.XX credit to this customer's account. The credit applies to the next invoice. This action is recorded in the audit log."
- Cancel: "This will cancel the customer's subscription. Their access continues until [current_period_end UTC]. After that date their plan reverts to free. This action is recorded in the audit log."
- Reactivate: "This will reactivate the customer's subscription. They will be billed at the start of the next period. This action is recorded in the audit log."
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 data — POST /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'
- Erase customer data — opens the DSR erasure confirmation flow (a separate
sub-card; not implemented in this card)
- Requires
billing-adminrole - 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 firesPOST /api/internal/billing/customer/<id>/dsr/erasewith 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
- No PII in URL path. Customer detail is accessed by Queue-internal UUID, not by email address. The UUID is non-guessable.
- Confirm modal copy is hardcoded. Dollar amounts and dates are interpolated from Queue's response — they are HTML-escaped by the template engine. No raw Stripe response text is rendered directly.
- No 16-digit sequence in rendered HTML. Existing AC from #409 retained.
Payment method display is
last4+brandonly. - RBAC double-gate. Console template hides buttons for missing roles (client side). Queue routes enforce RBAC server-side. Both must pass independently.
- Idempotency tokens. Client generates UUID at modal-open. Queue stores
(idempotency_token, action, customer_id)with a 5-minute TTL. Duplicate submission returns 409, not 500. This prevents the double-click double-charge pattern. - Async action security. Queue enqueues Stripe calls with the operator's
identity in the payload. The audit log row is written BEFORE the Stripe call,
not after (write-ahead audit). If Stripe call fails, the audit log row has
payload.stripe_response_status = 'failed'. - DSR erasure confirmation token. The erase button requires a separate confirmation token (a short-lived signed value issued by Queue when the modal opens) to prevent CSRF on the most destructive action on the page.
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)
- Downgrade banner
- Section 6: Subscription and Payment (read-only)
- Section 7: Invoice History with Stripe-hosted invoice URL + PDF links
- Section 8: Billing Actions (refund / comp / cancel / reactivate) — stubs that return 501 until Queue action routes land in #406
- Section 9: Narrative Event Counts (late payment, chargeback, failed charge badges
- recent events strip)
- Section 10: Data Privacy Actions (export + erase stub)
- Skeleton loaders, error state, empty state (all three)
- RBAC hide-not-gray for all action buttons
- Idempotency token plumbing
Phase 2 — Post-launch
- Inline line-item rendering (
billing_invoice_line_itemtable — Option 2) - WebSocket live refresh on
billing.action.completedevent (v1 uses manual refresh button after 3-second delay) - Pagination beyond 25 invoices
- Comp credit form with freeform description field (v1 shows amount only)
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 |