Add back-office tenant overview with dashboard, accounts, users, billing events, and Stripe reconciliation#888
Open
tjementum wants to merge 115 commits into
Open
Add back-office tenant overview with dashboard, accounts, users, billing events, and Stripe reconciliation#888tjementum wants to merge 115 commits into
tjementum wants to merge 115 commits into
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
…tenant and remove legacy backfill bandaids
…n-nullable to fail fast on missing fields
…g stale subscriptions on startup
… and reconcile alive
…kedAt does not mask outages
…eplay with user confirmation
…lete and align dashboard semantics
…ock webhook hot path
…iftWorker unit tests
|
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 branch adds every operator-facing page on top of that shell, a multi-source reconciliation architecture for the append-only
BillingEventledger that captures every recognized Stripe event 1:1, 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 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, with the percentage delta computed from forward-MRR — not signup count; 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 reads theCommittedMrrdenormalized column on the latest BillingEvent per subscription 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)unsyncedanddriftDetected(set by the data-quality banners andIssueFilterBadges) are preserved across paging, sorting, searches, and other filter changes.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) — name, email, role, last sign-in, status. The Role multi-select filter is passed to the backend so paging is correct under filters.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.parseUserAgentcorrectly disambiguates Chromium-based Opera from Chrome and Android from desktop Linux by ordering the more-specific patterns first.UserSessionsSection) — device, browser, IP, last seen, with aRevokedbadge for invalidated sessions.userDisplay.ts(getUserDisplayName/getUserInitials) handles firstName-only, lastName-only, both, or neither symmetrically.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 20BillingEventTypevalues), period selector reusing the dashboard's date range. Frontend Zod schema and backend FluentValidation both cap the filter at 20 to match the enum size.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 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; every row maps back to exactly one Stripe event id.Core/Features/Subscriptions/Domain/BillingEvent.cs):BillingEventIdis aStronglyTypedUlid(bilevt_…). Idempotency is enforced by the 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 have to walk history.NoOp(recognized event that doesn't move state we care about — hidden from the timeline) andUnclassified(event whose payload combines multiple changes we can't decompose into one transition; flips the drift flag for admin review).stripe_eventsarchive. Stripe's events.list API only retains events for 30 days (https://docs.stripe.com/api/events), so the durable archive of every webhook payload we have ever received is the canonical source for replay. The replayer is the only writer tobilling_events; the liveProcessPendingStripeEventsdoes not write BillingEvents directly anymore.StripeEventReplayer): walksstripe_eventschronologically, dispatches each row through anIStripeEventPayloadResolverchosen by the event'sapi_versionso a future Stripe API version change is a new resolver implementation rather than a code rewrite. Tracks running plan, scheduled plan, cancel-at-period-end flag, and committed MRR. Hard-rule: rows inbilling_eventsandstripe_eventsare never deleted, never updated after insert — schema changes useALTER TABLE ADD/DROP COLUMNonly.StripeEventimmutability: rows are immutable afterMarkProcessed(now, tenantId, stripeSubscriptionId)— the backfill is folded into the same call, and the publicSetTenantId/SetStripeSubscriptionIdsetters are removed so the type system enforces "Processed = work done; never mutate again."ProcessPendingStripeEvents.ReconcileEventLogFromEventsListAsync): runs every webhook and every admin Sync. Pulls events.list (30-day window) for the customer, diffs against the local archive, and inserts any missing event asStripeEvent.CreateRecovered(...)withrecovery_source = "events_list"andrecovered_at = now. AStripeEventrow's non-nullrecovered_atis the forensic marker that a webhook delivery was missed.CheckResourceCoverageAsync): per sync, audits Stripe resources against the event log — subscriptioncreateddate should have acustomer.subscription.createdevent, every succeeded payment should have aninvoice.payment_succeeded, every refund acharge.refunded, schedule changes asubscription_schedule.updated, payment methods apayment_method.attached. Each gap surfaces as aMissingHistoricalEventdiscrepancy (within the 30-day window — auto-recoverable on next sync) orMissingHistoricalEventUnrecoverable(older than the window — bug, must be investigated).AcknowledgeStripeWebhook+StripeEventPayloadHasher): every incoming webhook payload is hashed with SHA-256. If the same Stripe event id arrives twice with different payloads, the existingstripe_eventsrow is preserved unchanged and aStripeEventPayloadDivergencedrift discrepancy +StripeEventPayloadMismatchtelemetry event are emitted for admin investigation.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 local subscription state against a Stripe-derived snapshot after each sync (the snapshot is built from the just-fetched Stripe state, not from the subscription itself).DriftDiscrepancyKindwith severity (Warning,Critical):MissingEvent— a payment transaction exists with no matching BillingEventMissingHistoricalEvent— gap detected within Stripe's 30-day reconciliation window (auto-recoverable on next sync)MissingHistoricalEventUnrecoverable— gap older than 30 days (cannot be recovered from Stripe; bug to investigate)UnclassifiedStripeEvent— Stripe sent a payload combining multiple changes the writer can't decomposeUnsupportedStripeApiVersion— Stripe sent an event in anapi_versionwe don't have a resolver for; add oneStripeEventPayloadDivergence— same event id, different payload — bug or upstream tamperingSubscriptionStateMismatchandExtraEventreserved for future detector growthHasDriftDetected+DriftCheckedAt+DriftDiscrepancies(JSONB) so the next sync starts from a known baseline.AcknowledgeBillingDriftcommand — gated onBackOfficeIdentityDefaults.AdminPolicyName— 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?driftDetected=true. 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." Click-through to/accounts?unsynced=true. Fires when any paid subscription has zerobilling_eventsrows.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.All three poll every 60 seconds while the user is signed in and disappear when their condition clears.
MRR trend, LTV fix, forward-MRR helper
CommittedMrrfrom the BillingEvent log:GetDashboardMrrTrendHandlergroups events by subscription, sorts byOccurredAt, and for each day picks thecommitted_mrrvalue of the latest event up to end-of-day. The denormalized column means the trend doesn't have to walk transition history per query.GetTenantDetailHandler): filter toStatus == Succeeded, sumAmountExcludingTax. Refunded charges contribute zero (paid + refunded = wash). The previous formula multiplied refunds by-1, which double-counted the refund.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 and AcknowledgeBillingDrift
/{id}/sync-with-stripeand/{id}/drift/acknowledgechain.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.Back-office query soft-delete handling
TenantRepository.GetAllUnfilteredAsync,GetByIdsUnfilteredAsync,GetCreatedSinceUnfilteredAsync, andGetMrrChangeUnfilteredAsyncskip the tenant filter (the back-office is cross-tenant by design) but re-apply the soft-delete filter, so soft-deleted tenants are excluded from dashboard counts, KPIs, and account listings.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(incl. the same-pass replay regression test that pins the just-arrived webhook + in-memory union behaviour),StripeClientTests(proration plan resolution).StripeEventReplayerTests— unit tests for the classification rules: PaymentFailed for first-attempt subscription_cycle failures, SubscriptionRenewed via invoice.payment_succeeded, SubscriptionPastDue on status active→past_due and active→unpaid, latest_invoice→NoOp invariant.EventLogReconciliationTests— events.list reconciliation: missing event in local archive is recovered asProcessedwithrecovery_source = "events_list".StripeEventPayloadDivergenceTests— same id arriving twice with different payloads preserves the existing row and emits theStripeEventPayloadMismatchtelemetry event.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
20260509180000_AddBillingEventsAndDriftDetection: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, 17 columns includingstripe_event_id,committed_mrr,amount_delta,from_plan,to_plan,previous_amount,new_amount,currency,cancellation_reason,suspension_reason,occurred_at. Indexes onstripe_event_id(unique),tenant_id+occurred_at DESC,occurred_at DESC,subscription_id.api_version,recovered_at,recovery_source,payload_hashcolumns tostripe_eventsplus a filtered index onrecovered_at IS NOT NULL.subscriptions.payment_transactionsenforcingAmountExcludingTaxandTaxAmountare present on every JSONB element.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 small 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