Add back-office tenant overview with dashboard, accounts, users, billing events, and Stripe reconciliation#888
Merged
Merged
Conversation
6ef04b8 to
edc9f2d
Compare
54f9b4e to
fc0b457
Compare
ac23c03 to
8b2c185
Compare
b942167 to
48cc6cb
Compare
48cc6cb to
92caade
Compare
92caade to
b95f7b5
Compare
b95f7b5 to
b11d490
Compare
b11d490 to
a317253
Compare
…Event self-corrects
cda652c to
de545cf
Compare
|
Approve Database Migration
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.



Summary & Motivation
Builds the cross-tenant back-office surface that platform operators use to monitor revenue, search and inspect customer accounts, audit subscription lifecycle activity, and reconcile local state with Stripe. The base back-office shell and Easy Auth wiring landed earlier in #876; this change adds every operator-facing page on top of that shell, an append-only
BillingEventledger that captures every recognized Stripe event 1:1, a multi-source reconciliation architecture with disaster recovery, three data-quality banners that surface when Stripe and the local archive disagree, and a refactor of the dashboard MRR computation to read history from the event log rather than projecting today's snapshot backward. Sync with Stripe is admin-gated. Every back-office billing surface is gated behind thePUBLIC_SUBSCRIPTION_ENABLEDruntime flag so deployments without Stripe collapse to non-billing tiles.Back-office shell
Side menu entries for Accounts, Users, Billing events, and Invoices, plus a Coming soon group for Feature flags, Support, and Wait list. A Kiosk mode toggle in
BackOfficeAvatarMenuhides the side menu rail and the mobile floating menu trigger so the dashboard fills the screen for unattended displays. The data-quality banner stack is wired into__root.tsxthrough the sharedBannerPortal. An authenticated tenant-logo blob proxy (BackOfficeBlobProxy) lets the accounts list and side pane render tenant logos without leaking signed URLs.Dashboard
Operator landing page with a period selector (Last 7 / 30 / 90 days) that drives every card. Cards share
DashboardCardShell; KPI tiles useLinkCardso the full tile is a navigation target./api/back-office/dashboard/kpis): Total accounts (with+N new in last <period> daysand delta vs prior period), Blended MRR (forward-MRR sum; delta computed from forward-MRR, not signup count), Total revenue (all-time net ex-VAT, clicks to/invoices), Users active in period, Active sessions last 24 hours./api/back-office/dashboard/mrr-trend): Current vs Prior period overlay. Each daily point readsCommittedMrron the latest BillingEvent per subscription up to end-of-day./api/back-office/dashboard/revenue-trend): cumulative ex-VAT area chart with the same period selector and prior-period overlay. Each successful charge addsAmountExcludingTaxto its payment-day bucket; reversals subtract on the reversal day so the line dips when money is returned. Net contribution of any reversed transaction is zero, so the end-of-window cumulative matches the Total Revenue tile.Chart.tsxwrappers that defaultaccessibilityLayer={true}and ship Stripe-style tooltips (per-point date + signed delta vs prior period; green / red coloring consistent across charts). Directrechartsimports are lint-blocked.Accounts list (
/accounts)Toolbar with search-by-name, multi-select Plan and Status filters, and a Clear filters button. Filter state is persisted in the URL;
unsyncedanddriftDetectedparams (set by the banners andIssueFilterBadges) are preserved across paging, sorting, and other filter changes. Sortable columns (Name, Plan, MRR, Renewal date, Status) via a sharedSortableTableHeadprimitive — MRR shows strike-through when a downgrade is scheduled or the subscription is canceling at period end.Side pane preview (
AccountSidePane, 200 ms debounced): tenant logo, plan badge, status, country flag, andAccountSidePaneSections— Plan & revenue (Renewal date · MRR with forward-MRR strike-through, Subscribed since · Lifetime value, Last invoice date · Last invoice amount), Owners, Users preview (top three + total · active · inactive · pending summary), Created with relative date, and a footer action to open the full detail page.Account detail (
/accounts/$tenantId)Header with tenant logo, name, plan and status badges, country flag, Created date, and an
AccountActionsMenukebab (admin-only) with the Sync with Stripe action. Three KPI tiles below the header (AccountHealthTiles): Users (<active> / <total>with activation progress bar), Lifetime value (sum ofAmountExcludingTaxover un-reversedSucceededtransactions; subtitle uses the subscription start date, not the tenant created date), MRR (forward-MRR with strike-through when downgrading or canceling).Tabs: Overview (Owners, Current plan card, Invoices preview, Billing events preview), Users, Invoices, Billing events.
The Current plan card uses a two-column layout: price + status badges across the top, Subscribed since · Renewal date row, Billing address on the left, Payment method + VAT number on the right.
CardBrandLogorenders the network logo next to the masked card number. The Expires label only appears when the subscription is actually canceling at period end; otherwise it'sRenewal date. Terminated subscriptions showExpiredwith the last active date.Cancelledplan transitions on the per-account billing history row render ascancelled-plan → Basisso the column never shows two identical plans.Users list and user detail (
/users,/users/$userId)List toolbar with debounced search by email / first name / last name / account name. Detail header with avatar, full name, email, last-seen relative date. KPI tiles: Tenant memberships count, Active sessions, Logins in last 30 days. Tabs: Overview (Tenant memberships table linking to the account detail page + login history table for the last 30 days), Logins (full history with method, outcome, IP, when, device —
parseUserAgentcorrectly disambiguates Chromium-based Opera from Chrome and Android from desktop Linux), Sessions (active sessions with aRevokedbadge).userDisplay.tshandles firstName-only, lastName-only, both, or neither symmetrically.Billing events page (
/billing-events)Cross-tenant lifecycle log scoped to the entire platform. Toolbar with search by account name, period selector, and toggle pill filters: All / MRR impact / Subscription state / Other. Pills are independent lenses, not a partition — an event can match more than one bucket (an Upgrade is both an MRR change and a state change).
Table columns: Date, Event (badge), Account, Plan transition, MRR impact (signed delta), MRR after. Frontend Zod schema and backend FluentValidation cap the event type filter at 20 to match the enum size.
Invoices page (
/invoices)Cross-tenant revenue audit surface — every invoice, refund, and credit note across all accounts. Toggle-pill filters: All / Invoices / Refunds and credit notes. Search by account name (debounced) with empty and loading states.
Credit notes and refunds render as their own rows. A single Stripe transaction with a credit note becomes two rows — the original Invoice row (paid status, original date) and a separate Credit note row dated
CreditNotedAt ?? RefundedAt ?? Date. Stripe refunds without a credit note render as their own Refund row. Each row carries the correct status, dates, and React keys so virtualization is stable. The same dual-row projection drives the per-tenant Invoices tab on the account detail page.BillingEvent aggregate and multi-source reconciliation
Append-only audit log of every subscription, payment, and billing transition. Strict 1:1 invariant: every recognized Stripe event for a subscription produces exactly one
billing_eventsrow, enforced by a unique index onstripe_event_id— webhook redelivery and reconciliation re-pulls collide on insert and are silently skipped. Each row carries a denormalizedCommittedMrr(state-after MRR) andAmountDeltaso paginated reads and the MRR trend don't walk history.NoOp(hidden from the timeline) andUnclassified(payload combining multiple changes we can't decompose; flips the drift flag).stripe_eventsarchive. Stripe's events.list API only retains events for 30 days (https://docs.stripe.com/api/events), so the durable archive is the canonical source for replay.StripeEventReplayeris the only writer tobilling_events; it walksstripe_eventschronologically, dispatches each row through anIStripeEventPayloadResolverchosen by the event'sapi_version(a future Stripe API version is a new resolver, not a rewrite), and is seeded from the live Subscription aggregate so cancel-of-scheduled-downgrade emits the correctSubscriptionDowngradeCancelledevent. Hard rule: rows inbilling_eventsandstripe_eventsare never deleted, never updated after insert.StripeEventimmutability: rows are immutable afterMarkProcessed(now, tenantId, stripeSubscriptionId). The publicSetTenantId/SetStripeSubscriptionIdsetters are removed so the type system enforces "Processed = work done; never mutate again."stripe_events.payload. Payload reads are deferred to the background replayer; payload-derived columns (e.g.api_version) are nullable until backfilled.ReconcileEventLogFromEventsListAsync): runs on every webhook and every admin Sync. Diffs events.list against the local archive and inserts missing events asStripeEvent.CreateRecovered(...)withrecovery_source = "events_list"andrecovered_at = now— the forensic marker that a webhook delivery was missed.CheckResourceCoverageAsyncaudits Stripe resources against the event log; gaps surface asMissingHistoricalEvent(auto-recoverable inside the 30-day window) orMissingHistoricalEventUnrecoverable(older, must be investigated).StripeEventPayloadHasher): every webhook payload is SHA-256 hashed. Same event id with different payload preserves the existing row and emits aStripeEventPayloadDivergencediscrepancy +StripeEventPayloadMismatchtelemetry event.StripeClient):PaymentTransactionis enriched with the active plan from the Stripe price catalog — for proration invoices the algorithm picks the line with the largest positive amount so the row reflects the new plan rather than the credited old plan.Reconcile, drift detection, and disaster recovery
The Sync action on the account detail page is a two-step flow that distinguishes routine drift from data-loss situations.
SyncTenantWithStripe, admin-gated): runs the live sync forcefully (even with no pending events), pulls events.list, appends any missingstripe_events, runs the replayer to repopulatebilling_events, then runs the drift detector. Result dialog shows appended count plus any discrepancies. Clean syncs land on a green check.MissingHistoricalEventUnrecoverablediscrepancies (gaps older than Stripe's 30-day events.list window). Walks the admin through assigning a manual cutoff so the replayer can resume — archive events Stripe already returned are excluded from the cutoff so a Reconcile-then-disaster-recovery sequence doesn't re-process events twice.BillingDriftDetectorcompares local subscription state against a Stripe-derived snapshot. Discrepancies are categorized byDriftDiscrepancyKind(MissingEvent,MissingHistoricalEvent,MissingHistoricalEventUnrecoverable,UnclassifiedStripeEvent,UnsupportedStripeApiVersion,StripeEventPayloadDivergence, plusSubscriptionStateMismatchandExtraEventreserved for future growth) with severity (Warning,Critical). Result is stored on the subscription asHasDriftDetected+DriftCheckedAt+DriftDiscrepancies(JSONB).AcknowledgeBillingDrift(admin-gated) clears the flag without modifying discrepancy data. The staleness loop re-queries the billing-events count fresh before drift detection, skips every sibling row of the seed event so same-secondsubscription_cyclebursts don't false-positive, and skips boundary rows so a clean Reconcile clears theMissingEventflag. Cancelled accounts with payment transactions but no matching BillingEvent surface as Missing-event drift.BillingDriftWorker(Workers/BillingDriftWorker.cs): background worker that runs the drift detector across every active subscription on a schedule. Parallelized iterations cut the detect pass from tens of minutes to a few minutes. Gated on Stripe being configured — whenSTRIPE_SECRET_KEYis unset, the worker logs once and skips the pass. Subscription reads in the detect path use no row lock so the webhook hot path stays unblocked.Data-quality banners
Three global banners portal into a fixed-top slot above the sidebar via the shared
BannerPortal, all using the warning palette and the same row template. All poll every 60 seconds while the user is signed in and disappear when their condition clears.BillingDriftBanner(/api/back-office/billing-drift/summary): "{N} accounts have billing drift detected." Click-through to/accounts?driftDetected=true.UnsyncedAccountsBanner(/api/back-office/billing-drift/unsynced-summary): "{N} accounts have not been synced yet — MRR trend is incomplete." Click-through to/accounts?unsynced=true.MrrMismatchBanner(/api/back-office/billing-drift/mrr-consistency-summary): "Dashboard MRR mismatch: KPI shows {X}, trend latest shows {Y}." Click-through to/billing-events.Revenue accounting and forward MRR
Status == Succeeded && CreditNoteUrl is null && RefundedAt is null. A credit note voids revenue regardless of whether money was refunded — the customer can still use the balance to pay future invoices, which then count fresh.PaymentTransactiongainsCreditNotedAt,RefundedAt,InvoiceTotal, andAmountFromCreditso the Revenue chart dips on the correct day and the invoice projection renders credit-balance payments distinct from new revenue. Reversal-day fallback:CreditNotedAt ?? RefundedAt ?? Date.CommittedMrrfrom the BillingEvent log — denormalized so it doesn't walk transition history per query.MrrCalculator.ForwardMrr(shared helper): per-subscription forward MRR — 0 if cancelling at period end, scheduled price if a downgrade is queued, otherwise the current price. Used by every MRR caller so KPI and consistency check can't drift by formula divergence.Tax breakdown and currency invariants
PaymentTransaction.AmountExcludingTaxandTaxAmountare non-nullabledecimalin the C# domain record; theTenantPaymentTransactionDTO mirrors the change. The previous?? Amountfallback in the LTV formula is gone.MockStripeClientand the seven affected test files no longer hardcode DKK or the 25% Danish VAT rate — values come from configuration so tests run with any platform currency, andBillingEventlost a hardcoded DKK guard.ApiAggregationService(gateway) aggregates the main API JSON contract and excludes back-office endpoints from the public OpenAPI bundle so they don't leak into customer-facing API docs.Admin authorization, soft-delete, cluster wiring
/{id}/sync-with-stripeand/{id}/drift/acknowledgechain.RequireAuthorization(BackOfficeIdentityDefaults.AdminPolicyName)on top of the group's regular policy. Non-admins receive 403;AccountActionsMenuhides the kebab trigger whenme.isAdminis false. Banner endpoints stay on the regular policy so every authenticated user sees the warnings.GetAllUnfilteredAsyncetc.) skip the tenant filter but re-apply the soft-delete filter, so soft-deleted tenants are excluded from dashboard counts, KPIs, and listings. Subscription and billing-event history is preserved across soft-delete so all-time figures stay stable.cloud-infrastructure/cluster/main-cluster.bicep:accountEnvironmentVariablesis a single merged set used by the account API, workers, and back-office container, so Stripe config,PUBLIC_SUBSCRIPTION_ENABLED, andPUBLIC_GOOGLE_OAUTH_ENABLEDpropagate consistently.PUBLIC_SUBSCRIPTION_ENABLED=falsehides every back-office billing surface.Banner portal, shared UI primitives, translations
BannerPortalrenders a fixed-top<div id="banner-root">(z-40) and measures its height into a--banner-offsetCSS variable.BackOfficeBannersportals into that slot viacreatePortal.AppLayoutandSidebarviewport-height calcs subtract--banner-offsetso bottom-pinned elements stay visible when a banner is active. Benefits the user-facing app's existing banner stack as well.New
LinkCardwrapsCardwith a TanStack Router<Link>.ChartwrapsrechartswithaccessibilityLayer={true}defaulted.TablePaginationacceptstrackingTitle.TenantLogoproxies throughBackOfficeBlobProxy.MultiSelectgainsclearAllLabeland an "Apply" affordance.Alertadds aninfovariant.useSmartDateadds locale-awareformatLongDateand a relative-time formatter.Comprehensive en-US and da-DK catalogs under
application/account/BackOffice/shared/translations/locale/covering every back-office surface — roughly 350 strings each.Test coverage
Backend (
application/account/Tests/): tenant and user back-office queries; every dashboard handler includingGetDashboardRevenueTrendTests(credit-note date fallback, refunded-day dip, net-revenue cumulative);BillingDriftDetectorTests,BillingDriftWorkerTests,BillingEventAppendTests(same-pass replay regression),StripeClientTests(proration plan resolution),MrrCalculatorTests,ApiAggregationServiceTests,StripeEventReplayerTests,EventLogReconciliationTests,StripeEventPayloadDivergenceTests,SyncTenantWithStripeTests(admin / non-admin / unconfigured-Stripe withTelemetryEventsCollectorSpyassertions), plus subscription domain tests for soft-delete preservation and replayer seed-from-live-aggregate.E2E:
back-office-flows.spec.ts(dashboard, accounts, account detail tabs, users, side-menu navigation),billing-events-flows.spec.ts(filter, search, paginate, click-through),subscription-flows.spec.tsextended with reconciliation paths.Database migration
20260509180000_AddBillingEventsAndDriftDetection: adds drift-tracking columns andsubscribed_since/scheduled_price_amounttosubscriptions; createsbilling_events(17 columns, indexes onstripe_event_idunique,tenant_id+occurred_at DESC,occurred_at DESC,subscription_id); addsapi_version,recovered_at,recovery_source,payload_hash,stripe_created_attostripe_eventswith a filtered index onrecovered_at IS NOT NULL. CHECK constraints enforce numeric non-negativeAmountExcludingTax/TaxAmount/InvoiceTotal/AmountFromCrediton every JSONB payment transaction, and^[A-Z]{3}$currency format onbilling_events.currency,subscriptions.current_price_currency, andpayment_transactions[*].Currency.Dev tooling
.mcp.jsonadds Stripe MCP servers for development, staging, and production, each with arestricted-keyguard so a leaked key can't be used outside its environment. API keys sourced fromdotnet user-secretsvia wrapper scripts so no key is committed..claude/skills/db-query/SKILL.mdadds a guided way to runpsqlagainst the local Aspire Postgres for inspection, with an explicit destructive-operations rule: anyDROP,TRUNCATE,DELETE, orALTERrequires extreme care and a clarifying question if the request is even slightly ambiguous — assume the most conservative interpretation.Checklist