Add back-office tenant overview with dashboard, accounts, users, billing events, and Stripe reconciliation#888
Open
Add back-office tenant overview with dashboard, accounts, users, billing events, and Stripe reconciliation#888
Conversation
6ef04b8 to
edc9f2d
Compare
Approve Database Migration
|
54f9b4e to
fc0b457
Compare
…rrent plan empty states
…illing-events endpoint
…illing event log, drift detection, forward MRR, and non-nullable tax breakdown invariant
fc0b457 to
ac23c03
Compare
ac23c03 to
8b2c185
Compare
|
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 branch adds every operator-facing page on top of that shell, the append-only
BillingEventaggregate that captures subscription transitions with deterministic IDs, three data-quality banners that surface when Stripe and the local log 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 gated to admins via the existingBackOfficeIdentityDefaults.AdminPolicyName.Back-office shell additions
BackOfficeSideMenu, plus a Coming soon group for Feature flags, Support, and Wait list.BackOfficeAvatarMenuthat hides the side menu rail and the mobile floating menu trigger so the dashboard fills the screen for unattended displays. Toggle persists per-browser via the same path used by the zoom level.__root.tsxthrough the sharedBannerPortal.BackOfficeBlobProxy) is added so the new accounts list and side pane can render tenant logos without leaking signed URLs.Dashboard
Operator landing page with a period selector (Last 7 / Last 30 / Last 90 days) that drives every card. Each card has skeleton loaders, an empty state, and shares a
DashboardCardShellfor consistent borders, padding, and section headings. KPI tiles useLinkCardso the full tile is a navigation target; non-link tiles useCard./api/back-office/dashboard/kpis): Total accounts (with+N new in last <period> dayssubtitle and delta vs prior period), Blended MRR (forward-MRR sum across all paid subscriptions; clicks through to/billing-events), Users active in period, Active sessions in last 24 hours./api/back-office/dashboard/mrr-trend): area chart with Current period vs Prior period overlay. Each daily point is reconstructed from the BillingEvent log per subscription — the latest event withNewAmountset up to end-of-day. The card header shows the latest blended value./api/back-office/dashboard/plan-distribution): donut chart of paid vs trial vs canceled accounts./api/back-office/dashboard/trends): stacked bar chart of new tenant signups per day for the period, with prior-period comparison./api/back-office/dashboard/trends): area chart of distinct user logins per day with success/failure split./api/back-office/dashboard/recent-signups): table of the most recent 5 paid signups with tenant name, owner email, plan, and time-since./api/back-office/dashboard/recent-stripe-events): table of the most recent 10 BillingEvent rows across all tenants — date, event type, plan transition, MRR impact, MRR after — using the same row template as the standalone Billing events page so behaviour is consistent.Chart.tsx) that defaultaccessibilityLayer={true}so Tab focuses data points instead of the SVG, and apply a global outline fix that suppresses Chrome's blue ring on<svg class="recharts-surface">. Directrechartsimports are now lint-blocked.Accounts list (
/accounts)SortableTableHeadprimitive./back-office/blob), aTenantStatusBadge(combines plan + planned change), and aSubscribed sincevalue.AccountSidePane): tenant logo, plan badge, status, country flag, andAccountSidePaneSections:SidePaneUsersRow) plus atotal · active · inactive · pendingsummary bar.<n> days ago.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):<active> / <total>with activation progress bar and<percent>% activationsubtitle.AmountExcludingTaxoverSucceededpayment transactions, withSince <subscribed date>subtitle.Renews <date>subtitle.Tabs:
AccountUsersTab).AccountBillingHistorySection+AccountPaymentRow) with Date, Plan, Amount excluding VAT, VAT, Total, Status, plus action buttons for Invoice PDF and Credit note PDF when present. One row per Stripe transaction (Stripe is the source of truth — refunds are not split into synthetic rows).AccountBillingEventsSection+AccountBillingEventRow) with date, event type badge, plan transition, MRR impact (signed delta), and MRR after.The detail page is reachable from the accounts list, the side pane, the dashboard's recent signups, the dashboard's recent Stripe events, and a user's tenant memberships.
Users list and user detail (
/users,/users/$userId)TablePaginationcomponent.UsersTable+UsersTableRow).UserActivityTiles): Tenant memberships count, Active sessions, Logins in last 30 days.UserTenantsSection) with one row per tenant — logo, name, plan, role, MRR contribution, scheduled price (when downgrading), renewal date — clickable to the account detail page. Login history table (UserLoginHistorySection) for the last 30 days, every attempt successful or failed, across email and external providers.UserSessionsSection) — device, browser, IP, last seen, with aRevokedbadge for invalidated sessions.Billing events page (
/billing-events)Cross-tenant lifecycle log scoped to the entire platform.
BillingEventsToolbar): search by account name, multi-select Event type filter (covers all 17BillingEventTypevalues), period selector reusing the dashboard's date range.BillingEventsTable): Date, Event (badge), Account, Plan transition, MRR impact (signed delta), MRR after, with deep-link to the source account on click.BillingEvent aggregate and Stripe reconciliation pipeline
Append-only audit log of every subscription, payment, and billing transition.
Core/Features/Subscriptions/Domain/BillingEvent.cs):BillingEventIdis a deterministic SHA-256 hash of(SubscriptionId, EventType, StripeReference, OccurredAt)— webhook redelivery and the historical backfill produce the same ID, so insert is naturally idempotent (PK conflict skipped).ProcessPendingStripeEvents): consumes pendingstripe_eventsrows, advances the local subscription state, and appends BillingEvents in lockstep. Each emission carriesPreviousAmount,NewAmount,AmountDelta,Currency, plus event-type-specific metadata (days on previous plan, days until effective, scheduled-for date, cancellation reason, suspension reason).StripeEventReplayer): backfills BillingEvents from previously stored stripe_events for subscriptions that predate the log. Implemented as a state machine that walks events chronologically and tracks running plan, scheduled plan, cancel-at-period-end flag, and committed MRR — so historical rows reflect the truth at the time they happened.BackfillLegacyBillingEventsAsynconProcessPendingStripeEvents): runs the first time a customer's subscription is synced and has no BillingEvent rows yet. Two-layer idempotency guard: skip-if-any-events at the function level, plus the per-event SHA-256 ID check.SyncTenantWithStripe): admin-gated back-office action that runs the live sync forcefully (even with no pending events), then runs the drift detector. ReturnsBillingEventsAppended,HasDriftDetected,DriftDiscrepancyCount,SyncedAt. Result dialog shows a green check on a clean sync, an amber alert when drift is detected.StripeClient):PaymentTransactionis enriched with the active plan resolved from the Stripe price catalog — for proration upgrade/downgrade invoices, the algorithm picks the line with the largest positive amount so the row reflects the new plan rather than the credited old plan.Drift detection and acknowledgment
BillingDriftDetectorcompares the local subscription state against Stripe history after each sync.DriftDiscrepancyKind(MissingEvent, ExtraEvent, plus type-specific cases) with severity (Warning,Critical).HasDriftDetected+DriftCheckedAt+DriftDiscrepancies(JSONB) so the next sync starts from a known baseline.MissingEventdiscrepancy is raised when a subscription has payment transactions but zero BillingEvents — the back-office Sync action triggers the backfill described above.AcknowledgeBillingDriftcommand lets an admin clear the flag once the drift is reviewed; it does not modify the underlying discrepancy data.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:BillingDriftBanner(/api/back-office/billing-drift/summary): "{N} accounts have billing drift detected." Click-through to/accounts. Fires when any subscription hasHasDriftDetected = true.UnsyncedAccountsBanner(/api/back-office/billing-drift/unsynced-summary): "{N} accounts have not been synced yet — MRR trend is incomplete." Fires when any paid subscription has zerobilling_eventsrows. The dashboard MRR trend reads from the event log, so unsynced subscriptions silently under-count it; the banner makes the gap visible.MrrMismatchBanner(/api/back-office/billing-drift/mrr-consistency-summary): "Dashboard MRR mismatch: KPI shows {X}, trend latest shows {Y}." Click-through to/billing-events. Fires when the KPI tile's forward-MRR sum disagrees with the trend's latest blended value — guards against a regression in either calculation, a missing event emission, or direct DB mutation without an event.All three poll every 60 seconds while the user is signed in and disappear when their condition clears.
MRR trend rewrite, LTV fix, forward-MRR helper
GetDashboardMrrTrendHandler.ComputeDailyMrrgroups events by subscription, sorts byOccurredAt, and for each day picks the latest event withNewAmountset up to end-of-day. Subscriptions backfilled viaBackfillLegacyBillingEventsAsyncare covered the same way. Replaces the prior approach that summedsubscription.CurrentPriceAmountfor every day in the period — that approach changed historical points whenever a subscription was cancelled or reactivated.StripeEventReplayerand the liveProcessPendingStripeEventspaths now emitSubscriptionCancelledwithnewAmount: 0m. Previously the live path defaulted to null, which made the trend's latest-event-with-NewAmount lookup miss the cancellation.GetTenantDetailHandler): filter toStatus == Succeeded, sumAmountExcludingTax. Refunded charges contribute zero (paid + refunded = wash). The previous formula multiplied refunds by-1, which double-counted the refund — under-reported LTV by the refund amount per refunded charge.MrrCalculator.ForwardMrr(new shared helper): per-subscription forward MRR — 0 if cancelling at period end, scheduled price if a downgrade is queued, otherwise the current price. Used byGetDashboardKpisHandler,GetDashboardMrrConsistencySummaryHandler, and the per-accountMrrAmounttile in the front-end. Funnelling all callers through one method keeps the KPI and the consistency check from drifting by formula divergence.Tax breakdown invariant
PaymentTransaction.AmountExcludingTaxandTaxAmountare non-nullabledecimalin the C# domain record. TheTenantPaymentTransactionDTO mirrors the change.subscriptions.payment_transactionsJSONB enforces the invariant at the database level:NOT jsonb_path_exists(payment_transactions, '$[*] ? (!(@.AmountExcludingTax.type() == "number") || !(@.TaxAmount.type() == "number"))').?? Amountfallback in the LTV formula is gone.Admin authorization on Sync with Stripe
/{id}/sync-with-stripechains.RequireAuthorization(BackOfficeIdentityDefaults.AdminPolicyName)on top of the group's regularBackOfficeIdentityDefaults.PolicyName. Authenticated non-admins receive 403; the front-endAccountActionsMenumirrors the gate by hiding the kebab trigger whenme.isAdminis false. Banner endpoints remain on the regular policy so every authenticated back-office user still sees the warnings.Banner portal infrastructure
BannerPortalrenders a<div id="banner-root">fixed at the top of the viewport withz-40and measures its own height into a--banner-offsetCSS variable.BackOfficeBannersportals into that slot viacreatePortal, with auseEffect-based target lookup that defers the DOM read until after the parent's commit so the synchronous mount doesn't race the portal target.AppLayoutandSidebarviewport-height calcs (min-h-dvh/min-h-svh) now subtract--banner-offset, so side-pane footers and other bottom-pinned elements remain visible when a banner is active. The fix benefits the user-facing app's existing banner stack as well.Shared UI primitives
LinkCardwrapsCardwith a TanStack Router<Link>so an entire card is a navigable surface.Cardconsolidated to a single primitive used by both apps.Chartwrapper aroundrechartsdefaultingaccessibilityLayer={true}and re-exportingBar,Line,Area,Pie,Donut,XAxis,YAxis,CartesianGrid,Tooltip,Legend,ResponsiveContainer. Directrechartsimports are lint-blocked. A globaltailwind.cssrule suppresses the unstyleable Chrome focus ring onsvg.recharts-surface.Tablestyling consolidated;TablePaginationacceptstrackingTitlefor telemetry.TenantLogoresolves and proxies tenant blobs throughBackOfficeBlobProxy(host-scoped, admin-policy-protected) so logos load with admin auth instead of leaking signed URLs.MultiSelectgainsclearAllLabeland an "Apply" affordance.Sidebargains the--banner-offset-aware height calc described above.Alertadds aninfovariant.useSmartDateadds locale-awareformatLongDatefor headers and a relative-time formatter ("4 days ago") for side-pane and recent-signups rows.Translations
Comprehensive en-US and da-DK catalogs under
application/account/BackOffice/shared/translations/locale/covering every back-office surface — roughly 350 strings each, including all banner copy, KPI labels, table headers, status badges, dialog text, and tooltips.Backend test coverage
GetTenantsTests,GetTenantDetailTests(incl. refunded LTV),GetTenantPaymentHistoryTests,GetTenantUsersTests,GetTenantUserCountsTests,GetTenantActivityTests.GetBackOfficeUsersTests,GetBackOfficeUserDetailTests.GetDashboardKpisTests(incl. forward-MRR scenarios),GetDashboardMrrTrendTests,GetDashboardPlanDistributionTests,GetDashboardRecentSignupsTests,GetDashboardRecentStripeEventsTests,GetDashboardTrendsTests.GetBackOfficeBillingEventsTests.GetBillingDriftSummaryTests,GetUnsyncedSubscriptionsSummaryTests,GetDashboardMrrConsistencySummaryTests(incl. the divergence path).BillingDriftDetectorTests,BillingEventAppendTests(idempotency),StripeClientTests(proration plan resolution).SyncTenantWithStripeTests(incl. admin-required / non-admin-forbidden / unconfigured-Stripe cases) withTelemetryEventsCollectorSpyassertions on success.E2E coverage
back-office-flows.spec.ts: dashboard, accounts list with filters, account detail tabs, users list and detail, navigation through the side menu.billing-events-flows.spec.ts: filter, search, paginate, click-through to account detail.subscription-flows.spec.tsextended with reconciliation paths.Database migration
A single consolidated migration
AddBackOfficeBillingTracking:subscribed_sinceandscheduled_price_amountcolumns tosubscriptions.has_drift_detected,drift_checked_at,drift_discrepancies(JSONB) columns and a filtered index onhas_drift_detected = true.subscribed_since = created_atfor active paid subscriptions (those with a Stripe subscription id and a non-Basis plan).billing_eventstable — strongly-typed-string PK, the 18 columns described in the BillingEvent aggregate, and indexes onsubscription_id,tenant_id+occurred_at DESC, andoccurred_at DESC.subscriptions.payment_transactionsenforcingAmountExcludingTaxandTaxAmountare present on every JSONB element.Dev tooling
.mcp.jsonadds astripe-developmentserver pointed at the dev sandbox, with the API key sourced fromdotnet user-secretsvia a small wrapper script so no key is committed..claude/skills/db-query/SKILL.mdadds a guided way to runpsqlagainst the local Aspire Postgres for inspection — read-only by default with explicit-permission rules for any write.Checklist