Skip to content

Admin & CS tools

Generated from a canonical source

This page is a read-only projection of docs/handoff-corpus/admin-and-cs-tools.md. Edit the canonical file, then run npm --prefix tools/project-knowledge-derive run derive.

What admin-and-cs-tools is for

The invariant you must not break: no state-changing CS action executes without an operator identity and a typed reason. Mutations are only reachable through /api/v1/admin/cs/* with an admin JWT; a request without a reason gets a 422; the impersonation token is meant to be structurally incapable of mutating. Violating this makes money movements — force-charge and force-refund touch the real charge rail — unattributable, and collapses the compliance answer to "who did this and why" that the audit trail exists to give. (ADR-0050 line 108 + §Consequences; CsActionsPanel.tsx header and reason-gated buttons.)

The capability breaks into twelve reader-facing features, all reached from the merchant admin app:

  • Create a subscription for a customer manually — phone order, migration, or goodwill, without a storefront checkout (US-22.1)
  • See exactly what a subscriber sees, without touching anything — a CS rep opens a read-only, 15-minute impersonation view of the customer's portal to diagnose issues (US-22.3)
  • Create many subscriptions at once from a CSV — bulk import with preview-then-confirm for migrations and B2B onboarding (US-22.4)
  • Charge or refund outside the schedule — force-charge (with optional amount override) or force-refund (full or partial), with a mandatory reason (US-22.5)
  • Pin a merchant note to a subscription — internal context travels with the record (US-22.6)
  • Manage a customer's allotment grants — view and revoke granted allotments from the subscription detail (US-22.7)
  • View and edit subscription custom fields — CS corrects merchant-defined metadata in place (US-22.8)
  • Enroll a B2B account, admin-only — B2B subscriptions are created from admin, never storefront checkout (US-22.9, per ADR-0023)
  • Work the highest-impact problems first — a prioritized exception queue (failed charges, OOS renewals, reconciliation drift) grouped by type, with resolve/escalate/refund actions per row (US-21.3, US-12.1)
  • Full context on one screen — the subscription detail view: state, plan, payment-method health, addresses, exceptions, and CS actions in one place (US-21.6)
  • Complete activity history per subscription — a timeline of every event with who did it (merchant user, subscriber, system, webhook) (US-13.1)
  • Answer "why can't this customer subscribe?" in seconds — a read-only eligibility-audit diagnostic that traces the rule chain with actual input values (US-26.10)

Four decisions carry the most weight:

  • Impersonation is read-only and short-lived; mutation goes through a separate audited namespace. The CS impersonation session (15 min, non-renewable) is designed to never mutate; every state-changing CS action requires the merchant-admin JWT on /api/v1/admin/cs/* plus a reason field (422 without), logged with operator_id (ADR-0050). See the live-state note below — the traced code does not implement the read-only enforcement layer this decision specifies.
  • B2B enrollment is admin-owned, not checkout-owned. B2B subscriptions are created only via the admin enrollment surface (US-22.9); storefront checkout is explicitly not the B2B entry point (ADR-0023).
  • Phase-1 RBAC: all merchant staff can act; accountability is the audit trail, not a permission gate. Any authenticated merchant-staff user can resolve exceptions and run CS actions; actor_user_id is always written (exceptions.resolved_by + typed logEvent); the view-only-vs-resolving role split is deferred to Epic 29 (GH #1331 / synthesis #1333, commit 368139e3; routes/admin/exceptions/resolve.ts header).
  • One event log is the audit substrate. Every action funnels through logEvent() into the events table with actor_kind (merchant_user / subscriber / system / webhook_bc / webhook_processor); the admin Activity timeline (US-13.1) renders that same table — there is no separate audit store (embedded in ADR-0050 §Consequences; no standalone audit-trail ADR exists).

Canonical-framing attestation (operator-ratified 2026-07-02). ADR-0050 is the deciding decision for this domain's override pattern. On the admin app's structure: the v2 left-rail Shell is the sole live routing surface (App.tsx — Hive #1062 hard-launch removed the legacy AppShell; / redirects unconditionally to /v2/home), and the v1 pages/ components are not residue of that migration — they are the actual leaf implementations mounted as route elements inside the v2 Shell. There is no parallel-model trap here analogous to stripe.ts. ADR-0064 (operator auth model) governs a different surface — the cross-tenant operator console (apps/operator-console) — and must not be cited for this merchant-scoped admin app. No deciding ADR exists for the exceptions-queue design or for a standalone audit-trail requirement; that absence is itself a finding.

How it actually works

Routing. The admin SPA's only live route table is App.tsx: / redirects unconditionally to /v2/home, and every CS/exceptions surface mounts inside the Shell at /v2/*. SubscriptionAdminDetail (subscription detail, US-21.6) is at /v2/subscriptions/:id; the three standalone cs-tools pages — impersonate, bulk-create, eligibility-audit — register at /v2/cs-tools/* with no SideNav entry; the route table's own comment says why: "No role-gating infrastructure in SideNav.tsx yet, so routes are registered but no sidenav entry is added. Support staff navigate directly." (App.tsx:128-137).

Force-charge / force-refund. CsActionsPanel.tsx, embedded in the subscription detail page, posts to POST /api/v1/admin/cs/subscriptions/:id/force-charge and .../force-refund, both dispatched from worker.ts:1289-1291. force-charge.ts rejects a missing or blank reason with invalidFields (422) before touching the database, then inserts a pending charge with chain_position='subsequent' and a charge_origin='force_manual' discriminator — the file's own header explains why: the chain_position CHECK constraint only permits 'initial'/'subsequent', and D1 can't alter a CHECK on an FK'd table, so the "this was forced" marker lives in a separate column instead. The charge is scheduled at NOW, so the cron picks it up on the next tick rather than charging synchronously — the UI's "queued" toast and the audit event name (subscription.force_charge_queued) both say this correctly.

Impersonation (US-22.3). POST /api/v1/admin/cs/impersonate (impersonate.ts) verifies the subscription belongs to the calling store, rejects cancelled subscriptions, inserts a row into cs_tool_sessions (id, store_hash, subscription_id, operator_id, expires_at, used_at, ip_addr) with a 15-minute expiry, emits subscription.cs_impersonation_started, and returns { session_id, expires_at, portal_url, read_only: true } where portal_url points at <PORTAL_BASE_URL>/cs-view?session=<uuid>&sub=<id>. This is where the traced mechanism diverges from the decision record — see below.

Exceptions queue (US-21.3, US-12.1). ExceptionQueueList.tsx fetches via GET /api/exceptions on mount and again every POLL_INTERVAL_MS = 30_000 ms (setInterval, ExceptionQueueList.tsx:27-28,276-280) — a failed poll is silent and keeps the last-good list rather than blanking the page. Resolve (resolve.ts) requires a note and writes it to exceptions.resolved_by plus an exception.resolved event; escalate (escalate.ts) takes no note and is a pure status flip — the file's header explains a second D1 CHECK-on-FK'd-table workaround: 'escalated' is not a legal exceptions.status value (migration 0018 locked the CHECK to ('open','resolved')), so escalation sets escalated_at instead and listExceptions() derives the surfaced status='escalated' from escalated_at IS NOT NULL. Both routes carry the same Phase-1 RBAC note as resolve.ts's header: any authenticated merchant-staff user can act; accountability is actor_user_id on the event, not a permission gate.

The chargeback exception type is produced by db.ts (lines ~1918, 2048): a charge's failure_code is classified as 'chargeback' when code.includes('chargeback') is true.

Custom fields (US-22.8). custom-fields.ts loads every field definition in scope for the subscription's plan (active and archived), rejects writes to archived keys with invalidFields, and validates each touched field with validateFieldValue before persisting — a real 422 path exists server-side. CsActionsPanel.tsx renders every active field as a plain <Input> regardless of field_type; an enum field carrying options[] is not rendered as a <Select>, and there is no client-side check before submit — invalid values reach the server and come back as a generic error string parsed from field_errors (CsActionsPanel.tsx:146-169,716-725).

Allotment grants (US-22.7). allotment-grants.ts exposes GET .../allotments/:id (detail + debit history) and POST .../allotments/:id/revoke (sets status='revoked', records revoked_by/revoke_reason, emits allotment_grant.revoked). The file's own BRD-AC comment describes four admin actions — "suspend / revoke / manual-refresh / override-balance" — but only revoke is implemented; there is no suspend, refresh, or balance-override route.

Manual create, B2B enrollment, and the price-lock fix. handleAdminSubscriptionCreate (subscriptions.ts:819) and two B2B enrollment endpoints — b2b-enrollment.ts (POST /api/v1/admin/b2b-enrollment, CS-rep enrollment with metadata.b2b = true) and b2b-po-enrollment.ts (POST /api/v1/admin/b2b-po-enrollment) — all call the shared resolveLockedPrice helper. Per that file's header: before commit 8ccaa0da (closes #1889), the admin manual-create path didn't even select the columns needed to compute locked_price_cents, so an admin-created subscription on a price-locked plan silently floated with later plan edits; commits 77a439b1 (#1890) and 325a0351 (#1891) closed the same defect class at the B2B create sites and made locked_price_cents type-required, so resolveLockedPrice is now the one place this rule is defined across checkout, webhook, admin-create, and both B2B paths.

Eligibility audit (US-26.10). eligibility-audit.ts is GET /api/admin/eligibility/audit, dispatched from worker.ts:1829. It evaluates the same rule chain as checkSubscriptionEligibility (mutex, custom_field, qty_bounds, product_exclusion, category_allowlist, customer_group_scope, geo_scope) but never writes state, and returns a per-rule trace with outcome and reason. The file documents its own BRD drift: the BRD's ui-states describe a POST with a JSON body; the shipped, routed contract is this GET with query params — kept as the working contract per the project's build-rule precedent.

No visual aid transcluded. Input-B records that no arch-derive sequence diagram or focused ERD excerpt covers this domain — the events table schema (audit-trail) has no sign_off field and only a staleness_threshold_days marker, and docs/architecture/sequence-diagrams.md's derives_from list contains no admin/CS-tools/exceptions source file. That gap is stated here rather than papered over with an unrelated diagram.

Where intent and reality diverge

  • Live-state contradiction (not a typed delta — a direct conflict with the decision record). ADR-0050 §2–§3 specifies the impersonation token as a signed JWT carrying role: 'cs_read_only', enforced GET-only by portal route middleware that checks that role. Tracing the actual code found none of that: impersonate.ts mints a bare crypto.randomUUID() stored server-side in cs_tool_sessions (columns id, subscription_id, operator_id, expires_at, used_at, ip_addr — not the customer_id column ADR-0050 §1 specifies either), and returns a portal_url pointing at <base>/cs-view?session=.... A repo-wide search for cs_read_only, cs_session_read_only, and cs-view found zero matches outside impersonate.ts itself and its own test — no portal route consumes that URL, and no role-based GET-only enforcement exists anywhere in the codebase. The one e2e step file that exercises this page says so directly in its own header comment: "The page issues NO GET on load, so the captured mint POST body is the route-wiring proof" — the mint call is stubbed in both CI run modes, and the read-only enforcement this decision's invariant depends on is never exercised, because it doesn't exist to exercise. Filed as a finding, not resolved here per the handoff-corpus contract's report-don't-resolve rule.
  • Verified-but-incomplete — the three cs-tools pages (impersonate, bulk-create, eligibility-audit) are live-routed at /v2/cs-tools/* and G4-verified but have no SideNav entry; App.tsx's own comment says role-gating infrastructure doesn't exist yet, so support staff reach them only by direct URL. Built and reachable, but not discoverable from navigation.
  • Verified-but-incomplete — the AllotmentGrant panel (US-22.7) is revoke-only: issue, suspend, manual-refresh, and override-balance actions named in the BRD AC and the route file's own comment are entirely absent.
  • Verified-but-incomplete — custom-field editing (US-22.8) has no client-side per-field validation, and fields carrying options[] (enum type) render as plain text Inputs instead of selects.
  • Verified-but-incomplete — B2B admin enrollment (US-22.9) passes G4, but cadence, line items, ship-to-locations, and the payer/beneficiary/manager actor assignments required by AC1 are absent.
  • Named-deferred — per-role CS access control (view-only vs resolving staff) is explicitly deferred to Epic 29: BRD §US-1.6 defers the role split, ADR-0050 §Explicitly-out-of-scope defers per-store CS ACLs to Phase-3 RBAC, and resolve.ts's header documents the Phase-1 model verbatim — codified in commit 368139e3 (synthesis #1333).
  • Named-deferred — instant impersonation-session revoke is not implemented (no token blocklist); a minted 15-minute session lives out its window (ADR-0050 §Explicitly-out-of-scope).
  • Named-deferred — customer lookup from a BC App Extension (US-22.2) is not built: coverage-matrix g4_status: null, and its epic-22 ui-states block states "NOT YET BUILT — forward-looking contract."
  • Built-but-untrodden — chargeback rows in the exception queue (US-12.5): the aggregator branch exists and is G4-scenario-passed (code.includes('chargeback') in db.ts), but it fires only when a charge's failure_code contains "chargeback," and no Phase-1 webhook routes real chargebacks into that state — so the branch never renders a row in practice. The dispute workflow (accept/contest, evidence upload, auto-pause) is P2-deferred per Hive #923.

No comp/goodwill-credit action exists in code or BRD for this domain — the nearest primitives are force-charge's amount_cents_override and partial force-refund (CsActionsPanel.tsx:297-324). Traced as an absence, not typed as a delta, because no story or decision establishes the intent.

How to operate & extend

  • The invariant you must not break: no state-changing CS action executes without an operator identity and a typed reason. Mutations only through /api/v1/admin/cs/* with an admin JWT; reason missing → 422. Given the live-state finding above, treat the impersonation session as not yet a hard read-only boundary until the portal-side enforcement is built and traced — don't rely on it as a security control in its current shipped form.
  • Adding a new CS override action: put the route under /api/v1/admin/cs/*, require reason in the body (422 pattern in force-charge.ts), and call logEvent() with actor_user_id and a typed event name — that's the whole audit contract, there's no separate audit table to register with.
  • Adding a new exceptions action: mirror resolve.ts or escalate.ts; if the new status value isn't in the exceptions.status CHECK constraint, don't attempt a schema migration on the FK'd table — follow the escalated_at-as-discriminator pattern instead.
  • Adding a cs-tools page to navigation: the route already exists under /v2/cs-tools/*; the missing piece is a role-gated SideNav entry, which needs the Phase-3 RBAC work (Epic 29) to land first per App.tsx's comment.
  • Closing the impersonation gap: implementing the portal's /cs-view route and the cs_read_only GET-only enforcement ADR-0050 specifies is unstarted work, not a maintenance task — there is no partial implementation to extend.
  • Evidence pointers: _coverage-matrix.json (Epic 22 US-22.1–22.9, Epic 21 US-21.3/21.6, Epic 12 US-12.1/12.5, Epic 13 US-13.1, US-26.10); epic-22-cs-tools.scenario.ts; force-charge.test.ts; admin-sub-create.test.ts; ExceptionQueueList.test.tsx; commits 8ccaa0da / 77a439b1 / 325a0351 (price-lock closure); ADR-0050.

Confidence notes

  • The impersonation live-state contradiction is the load-bearing finding in this page. I traced impersonate.ts, schema.sql's cs_tool_sessions definition, and did a repo-wide grep for cs_read_only, cs_session_read_only, and cs-view across .ts, .tsx, and .svelte files (excluding node_modules), and found matches only in impersonate.ts itself, its test, the e2e step file, two state-derive catalog files, and the OpenAPI spec — none of which implement or exercise portal-side read-only enforcement. I did not find a Hive issue or ADR amendment documenting this as a known/accepted gap, so I'm reporting it as newly surfaced rather than citing it as already tracked — the team lead should confirm whether one exists that I missed.
  • Input-B's canonical-framing attestation states the enforcement mechanism as settled fact ("enforced GET-only at the portal route layer"). Per the contract's report-don't-resolve rule I did not edit or soften that Input-B text where I quoted it directly in Move 1; I added the contradiction as a separate, clearly-marked item in Move 3 and referenced it from the invariant guidance in Move 4 instead of silently reconciling the two.
  • cs_tool_sessions schema column name. ADR-0050 §1 specifies a customer_id column ("the subscriber being impersonated"); the shipped schema.sql table (line 704) has subscription_id, not customer_id. I read this as consistent with the broader live-state finding rather than a separate, unrelated drift.
  • Two B2B enrollment endpoints. I found b2b-enrollment.ts and b2b-po-enrollment.ts both routed and both calling resolveLockedPrice; Input-B's descriptors and typed deltas only name US-22.9 generically. I did not find a decision record distinguishing the two beyond their route names and did not attempt to determine which one is canonical for US-22.9 — noting both rather than guessing.
  • Allotment-grant AC-vs-implementation gap. I read this directly from the route file's own BRD-AC comment block (allotment-grants.ts lines 9–16) rather than solely from Input-B's citation of the epic-22 derived view, so it's independently confirmed at the code layer.
  • I did not independently re-run the coverage-matrix derivation or the epic-22 G4 scenario in this session; I'm relying on Input-B's already-traced g4_status grep results for the pass/null claims, and on my own direct reads for every code-level claim above.