Files

21 KiB

operator-billing-views

Purpose

Gives operators read-only visibility into the billing domain — accounts, subscriptions and their details, invoices with line items, payments, and payment methods — each attributed with its Stripe sync status, so billing state can be inspected and reconciled from the operator panel without reaching for the Stripe dashboard. These are views only: no mutation of billing state happens through this surface.

Requirements

Requirement: Operator can view billing accounts with Stripe sync status

The system SHALL provide a read-only view for operators to list all billing accounts with their associated organization, status, and Stripe customer sync status.

Scenario: Billing accounts list shows all accounts

  • WHEN an operator with the operator-member role accesses the Billing > Accounts view
  • THEN the system displays a table of all billing accounts with columns: account name, organization name, status, Stripe sync status, and creation date
  • WHEN a billing account has a stripe.customer_mappings record with sync_status = 'synced'
  • THEN the system displays a link to the Stripe dashboard customer page using the format https://dashboard.stripe.com/{mode}/customers/{stripe_customer_id}

Scenario: Billing account shows pending status

  • WHEN a billing account has a stripe.customer_mappings record with sync_status = 'pending'
  • THEN the system displays a "Pending" badge and no deep link

Requirement: Operator can view subscriptions with Stripe sync status

The system SHALL provide a read-only view for operators to list all subscriptions with their status, billing account, period dates, and Stripe sync status.

Scenario: Subscriptions list shows all subscriptions

  • WHEN an operator accesses the Billing > Subscriptions view
  • THEN the system displays a table of all subscriptions with columns: subscription ID (truncated), billing account, status, current period, cancel at period end flag, and Stripe sync status
  • WHEN a subscription has a stripe.subscription_mappings record with sync_status = 'synced'
  • THEN the system displays a link to the Stripe dashboard subscription page

Scenario: Subscription status is color-coded

  • WHEN a subscription has status active or trialing
  • THEN the system displays a green/success badge
  • WHEN a subscription has status past_due, unpaid, or incomplete
  • THEN the system displays a red/danger badge
  • WHEN a subscription has status canceled or ended
  • THEN the system displays a gray/secondary badge

Requirement: Operator can view subscription details

The system SHALL provide a detailed view of a subscription showing its items, changes history, and associated pool provision.

Scenario: Subscription detail shows items

  • WHEN an operator views a subscription detail
  • THEN the system displays all subscription items with their product name, price amount, currency, and quantity

Scenario: Subscription detail shows change history

  • WHEN an operator views a subscription detail
  • THEN the system displays the subscription change history ordered by change date descending, showing previous status, new status, and Stripe event ID

Requirement: Operator can view invoices with line items

The system SHALL provide a read-only view for operators to list invoices with their status, amounts, and payment state, governed by the operator-list-scale contract (search, status filter including the derived Overdue state, pagination with true total).

Scenario: Invoices list leads with meaning

  • WHEN an operator accesses the Billing > Invoices view
  • THEN the system displays a paged table of invoices whose first column is the invoice number (the platform-assigned per-account reference number) rendered as the row's link to the per-invoice detail (chrome-conventions "Row navigation is the primary column's link"; an unissued invoice links the word "Unnumbered" with the tooltip explaining that a reference number is assigned at issuance), followed by the billing account's organization, status, amount due, amount paid, due date, paid date, and Stripe sync — the amounts carry their ISO 4217 code, so no separate currency column renders, and the always-identical billing account name renders nowhere in the list (maintainer, 2026-08-30)
  • AND the raw invoice ID does not render in the list at all (maintainer decision 2026-08-23: the detail page is the row's identity and carries the ID)

Scenario: Invoice status is color-coded

  • WHEN an invoice has status paid AND amount_due = amount_paid
  • THEN the system displays the badge map's success badge
  • WHEN an invoice has status paid AND amount_paid < amount_due
  • THEN the system displays the warning-tone "Partially paid" badge
  • WHEN an invoice has status open past its due date
  • THEN the system displays the danger-tone Overdue presentation (see "Open invoices past due present as Overdue")
  • WHEN an invoice has status open (not past due) or draft
  • THEN the system displays the warning-tone badge
  • WHEN an invoice has status void or uncollectible
  • THEN the system displays the secondary-tone badge

Scenario: Invoice line items are viewable

  • WHEN an operator opens an invoice's row from the list
  • THEN the per-invoice detail at /operator/billing/invoices/{invoiceID} displays all line items with description, quantity, amount, and period

Scenario: Invoices are found by their number

  • WHEN an operator searches the invoices view for an invoice number, or part of one, in either the platform form or a grandfathered Stripe form
  • THEN the matching invoices render and the placeholder reads "Search by organization or invoice number"

Requirement: Operator can view payments

The system SHALL provide a read-only view for operators to list all payments with their status, amount, and payment method.

Scenario: Payments list shows all payments

  • WHEN an operator accesses the Billing > Payments view
  • THEN the system displays a table of all payments with columns: payment ID (truncated), invoice ID (truncated), billing account, status, amount, currency, and payment method (card brand + last4 if available)
  • WHEN a payment has a stripe.payment_mappings record with sync_status = 'synced'
  • THEN the system displays a link to the Stripe dashboard payment intent page

Scenario: Payment shows card details when available

  • WHEN a payment has an associated payment method with card details
  • THEN the system displays the card brand and last 4 digits (e.g., "Visa •••• 4242")

Requirement: Operator can view payment methods

The system SHALL provide a read-only view of payment methods associated with billing accounts.

Scenario: Payment methods are visible in billing account detail

  • WHEN an operator views a billing account detail
  • THEN the system displays all attached payment methods with type, card details (if applicable), and default status

Scenario: Detached payment methods are indicated

  • WHEN a payment method has detached_at set
  • THEN the system displays a "Detached" badge and shows the detached date

Requirement: Stripe sync vocabulary is defined where it appears

Operator billing views that render a Stripe sync state (such as Not mapped or Synced) SHALL define the vocabulary at the point of use: the "Stripe sync" column header carries a help icon whose tooltip states that Not mapped means the record has no linked Stripe object, that this is expected when Stripe is not configured or a sync has not run, and what an operator should do before assuming breakage (maintainer decision 2026-08-23, revising the always-on wrapper banner to the dense-surface help-icon pattern). The sync states render through the badge map. On the invoice detail, the Stripe provenance renders as a plain fact row — never an alert — and only when the Stripe integration is configured (maintainer, 2026-08-30: provenance is noise on a deployment with no provider); Stripe's own invoice number renders there as the external reference.

Scenario: Not mapped is explained at the column header

  • WHEN an operator views a billing table containing Not mapped badges
  • THEN the "Stripe sync" column header carries a help icon whose tooltip defines the vocabulary
  • AND states what an operator should conclude from (and do about) an unmapped record
  • AND no always-on legend banner renders

Scenario: Detail provenance is a gated fact

  • WHEN an operator views an invoice detail on a deployment with Stripe configured
  • THEN the Stripe sync state renders as a fact row with the badge map's badge
  • WHEN Stripe is not configured
  • THEN no Stripe provenance renders on the detail

Requirement: Billing views disclose projection recency

Because operator billing data is projected from processed webhook events, each operator billing view SHALL display when the most recent webhook event was processed (e.g. "Last updated at …"). When no webhook event has ever been processed, the views SHALL say nothing about the stream (maintainer, 2026-08-30: the zero-events banner conflated the provider stream with the records below it, reading as "no billing exists" above seeded or locally-created rows; the stream's health is the Stripe integration settings surface's concern).

Scenario: Recency stamp renders with data

  • WHEN an operator views a billing table and webhook events have been processed
  • THEN the view shows the most recent processed event time

Scenario: A quiet stream says nothing

  • WHEN an operator views a billing table on a deployment where no webhook event has ever been processed
  • THEN no recency line and no zero-events statement renders

Requirement: Empty billing views distinguish unconfigured Stripe from no data

When the Stripe integration is not configured (its required settings are absent, per the same required-key completeness check the Stripe settings surface performs), an empty operator billing view SHALL render the blocked empty state: it states that billing data is projected from Stripe, which is not configured, and offers the Stripe settings surface as the prerequisite action. When Stripe is configured but the view is empty, the plain empty state renders ("No invoices yet."). The two states SHALL NOT render identical copy: one is blocked on the operator, the other is waiting on the world.

Scenario: Unconfigured Stripe blocks with the reason

  • WHEN an operator views an empty billing view and Stripe is not configured
  • THEN the blocked empty state renders with a "Configure Stripe" action linking the Stripe settings surface

Scenario: Configured but empty is plain absence

  • WHEN an operator views an empty billing view and Stripe is configured
  • THEN the plain empty state renders, with copy distinct from the blocked state

Requirement: Billing views are navigated in-page

The four billing views (accounts, subscriptions, invoices, payments) SHALL be reachable from an in-page pill row rendered by the shared Billing wrapper at their existing URLs — the sidebar holds one flat "Billing" entry with no children (maintainer decision 2026-08-23; operator-panel-navigation: sections navigate themselves). The pill row SHALL mark exactly the current view active, and each billing page SHALL mark the sidebar's Billing entry active. View content and behavior are unchanged; only the navigation mechanism is defined here.

Scenario: Billing sub-views navigate from the wrapper's pill row

  • WHEN an operator views any of the four billing pages
  • THEN a pill row renders Accounts, Subscriptions, Invoices, and Payments at their existing URLs
  • AND exactly the current view's pill is marked active
  • AND the sidebar shows a single Billing entry, marked active, with no children

Each of the four billing views SHALL lead with human-meaningful columns (the linked organization, amounts, status, dates) and SHALL render object IDs de-emphasized — muted, non-leading, never the row's first column; the invoices list goes further and does not render the raw invoice ID at all, leading with the invoice number as the row's link (chrome-conventions "Row navigation is the primary column's link"; no View button renders), and other views' cross-references to an invoice link the invoice's own number (the plain word "Invoice" when unissued), never a raw ID — and each row SHALL link to its linked organization's composite view where one exists (maintainer decision 2026-08-23: subject links plus one invoice detail; no dedicated detail pages for accounts, subscriptions, or payments, whose substance fits the row plus the organization composite). A row whose subject cannot be resolved renders without a dead link.

Scenario: Rows are entered through their subject, not their ID

  • WHEN an operator views any of the four billing views
  • THEN each row's leading column is human-meaningful and its linked organization is a link to /operator/organizations/{orgID}
  • AND object IDs render muted and non-leading

Scenario: Unresolvable subjects degrade honestly

  • WHEN a billing row has no resolvable linked organization
  • THEN the row renders its data without a dead link

Requirement: Operator invoice detail is addressable

The system SHALL provide a per-invoice detail view at /operator/billing/invoices/{invoiceID} headed "Invoice {number}" (the UUID muted beneath; Stripe's number, when the mapping carries one, shown as "Stripe invoice {stripe_invoice_number}", an external reference and never the heading), showing the invoice's line items (description, quantity, amount, period), amounts due and paid with correct minor-unit formatting, status including the derived Overdue presentation, due and paid dates, the linked organization (linked to its composite), and its Stripe sync state with the deep link when synced — reusing the member invoice-detail projection queries rather than a parallel read path. The page SHALL carry the standard back affordance to the invoices view (chrome-conventions), mark the Billing sidebar entry active, and answer an unknown invoice ID with the panel's ordinary 404 handling.

Scenario: Invoice detail renders the full projection

  • WHEN an operator loads /operator/billing/invoices/{invoiceID} for a projected invoice
  • THEN line items, amounts, status (with Overdue derived when applicable), dates, the linked organization, and Stripe sync state render
  • AND a back affordance leads to the invoices view

Scenario: Unknown invoice IDs 404 in the shell

  • WHEN an operator loads the detail route with an ID no invoice has
  • THEN the panel's ordinary 404 handling answers inside the operator shell

Requirement: Open invoices past due present as Overdue

An invoice with stored status open whose due date is in the past SHALL present as Overdue on operator surfaces — a derived, read-time presentation in the style of the grants Live/Superseded derivation, rendered as the badge map's danger tone (maintainer, 2026-08-30: Overdue escalates beyond Open's warning yellow rather than repeating it) — while the stored status is never mutated and an invoice with no due date is never Overdue. The invoices view's status filter SHALL offer Overdue as a value (per operator-list-scale) so "who is behind on payments?" is answered by one filter click.

Scenario: Overdue is derived at read time

  • WHEN an invoice has status open and a due date in the past
  • THEN operator surfaces present it as Overdue with the danger-tone badge
  • AND the stored status remains open

Scenario: Overdue is filterable

  • WHEN an operator filters the invoices view by Overdue
  • THEN exactly the open, past-due invoices render, with the true total

Requirement: Billing views mark Stripe test mode

When the configured Stripe API key is a test key (prefix sk_test_ or rk_test_), every operator billing view (accounts, subscriptions, invoices, payments) and the invoice detail SHALL render one banner under the page header, above the billing pills, reading "Stripe is in test mode. Figures on these pages are test data." with a "Stripe settings" link to /operator/integrations/stripe/settings, so no billing figure can be read as live money by mistake; in live mode no banner renders. The banner derives from the mode the console derives from the key at boot, the same fact behind its dashboard links, never from a setting or a stored flag, and renders through one shared element so the five pages cannot differ.

Scenario: Test mode is visible on every billing page

  • WHEN the Stripe API key is a test key and an operator opens any billing view or an invoice detail
  • THEN the banner renders under the page header, above the billing pills, with the sentence and the settings link

Scenario: Live mode shows nothing

  • WHEN the Stripe API key is a live key
  • THEN no banner renders

Requirement: Billing views show the current environment's rows

Every billing view that reaches a Stripe mapping SHALL show only the rows recorded in the environment the console derives from its API key, so the test-mode banner's sentence (Requirement: Billing views mark Stripe test mode) is true by construction rather than by an operator's care. Each such view (billing accounts through stripe.customer_mappings, subscriptions, invoices, payments, and the member's own invoices) SHALL show a row only when its mapping's livemode equals the key's mode or is NULL. A NULL row is unverified and SHALL show under either key, the same rule every consumer of the stamp follows (stripe-integration-infrastructure, Requirement: Mappings record the environment that made their ids). The operator views page through the core billing queries, so the filter SHALL be applied before paging (the view resolves the excluded ids in the Stripe store and hands the paged query that exclusion, as the invoice-number search hands it matching ids), and the pager's total SHALL count only the rows shown.

The operator views SHALL accept one query parameter, env=all, which shows every row, and the list scaffold SHALL carry it through its URLs beside search, the facet and paging, so a search, a page or a facet click keeps the view in the state the operator put it in. The member's own invoices view SHALL filter without a switch and SHALL accept no such parameter.

The switch rides an absence line rather than a facet pill, because the scaffold carries one facet per list and invoices and subscriptions spend it on status. Above a view holding rows the filter hides, one muted line SHALL name them and carry the switch: 12 invoices from test mode are not shown. Show all, the count being the rows the view would have shown under the same search and facet, so a search that matches none of the hidden rows renders no line. In the all state the line SHALL always render, Showing all environments. Show live only under a live key and Showing all environments. Show test only under a test key, even when nothing is out of mode, so the operator can always return; and every row from the other environment SHALL carry a Test or Live badge rendered through the status-badge part: the live state the map already holds in the success tone, and a test state added beside it in the secondary tone. In the default state, when the filter hides nothing, neither the line nor the switch SHALL render.

Scenario: A billing view shows the key's environment

  • WHEN an operator opens the invoices view under a live key and the projection holds live and test invoices
  • THEN only the invoices whose mapping records live render, and the true total counts those rows

Scenario: An unverified row shows under either key

  • WHEN a projected row's mapping holds livemode NULL
  • THEN the row renders under a live key and under a test key alike

Scenario: The absence line names what is not shown and switches

  • WHEN a billing view under a live key hides twelve invoices recorded in test
  • THEN one muted line above the table reads 12 invoices from test mode are not shown. Show all, and following the switch loads the same view with env=all

Scenario: The all state names itself and badges the other environment

  • WHEN an operator views a billing list with env=all under a live key
  • THEN the line reads Showing all environments. Show live only, and every row recorded in test carries the Test badge from the status-badge part

Scenario: Nothing hidden renders no line

  • WHEN every row a billing view would render is in the key's environment, or unverified
  • THEN neither the absence line nor the switch renders

Scenario: The switch survives search, the facet and paging

  • WHEN an operator in the all state searches, filters by status, or moves to the second page
  • THEN env=all is carried on the scaffold's URLs and the view stays in the all state

Scenario: A member's invoices are filtered without a switch

  • WHEN a member opens their own invoices under a live key
  • THEN only the invoices recorded live or unverified render, and no environment line, switch or badge renders