Skip to content

Add back-office tenant overview with dashboard, accounts, users, billing events, and Stripe reconciliation#888

Open
tjementum wants to merge 115 commits into
mainfrom
back-office-tenant-overview
Open

Add back-office tenant overview with dashboard, accounts, users, billing events, and Stripe reconciliation#888
tjementum wants to merge 115 commits into
mainfrom
back-office-tenant-overview

Conversation

@tjementum
Copy link
Copy Markdown
Member

@tjementum tjementum commented May 8, 2026

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 BillingEvent ledger 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 existing BackOfficeIdentityDefaults.AdminPolicyName.

Back-office shell additions

  • Side menu entries for the new pages (Accounts, Users, Billing events) wired into the existing BackOfficeSideMenu, plus a Coming soon group for Feature flags, Support, and Wait list.
  • Kiosk mode toggle in BackOfficeAvatarMenu that 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.
  • The data-quality banner stack (described below) is wired into __root.tsx through the shared BannerPortal.
  • An authenticated tenant-logo blob proxy (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 DashboardCardShell for consistent borders, padding, and section headings. KPI tiles use LinkCard so the full tile is a navigation target; non-link tiles use Card.

  • KPI tiles (/api/back-office/dashboard/kpis): Total accounts (with +N new in last <period> days subtitle 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.
  • MRR trend (/api/back-office/dashboard/mrr-trend): area chart with Current period vs Prior period overlay. Each daily point reads the CommittedMrr denormalized column on the latest BillingEvent per subscription up to end-of-day. The card header shows the latest blended value.
  • Plan distribution (/api/back-office/dashboard/plan-distribution): donut chart of paid vs trial vs canceled accounts.
  • Account growth (/api/back-office/dashboard/trends): stacked bar chart of new tenant signups per day for the period, with prior-period comparison.
  • User logins (/api/back-office/dashboard/trends): area chart of distinct user logins per day with success/failure split.
  • Recent signups (/api/back-office/dashboard/recent-signups): table of the most recent 5 paid signups with tenant name, owner email, plan, and time-since.
  • Recent Stripe events (/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.
  • All charts are rendered through new shared wrappers (Chart.tsx) that default accessibilityLayer={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">. Direct recharts imports are now lint-blocked.

Accounts list (/accounts)

  • Toolbar with search-by-name, multi-select Plan filter (Premium / Standard / Basis), multi-select Status filter (Active / Downgrading / Canceling / Canceled / Free), and a Clear filters button. Filter state is persisted in the URL and survives navigation. URL params for unsynced and driftDetected (set by the data-quality banners and IssueFilterBadges) are preserved across paging, sorting, searches, and other filter changes.
  • Table with sortable columns: Name (default sort), Plan, MRR (with strike-through display when a downgrade is scheduled or the subscription is cancelling at period end), Renewal date, Status. Column headers use a shared SortableTableHead primitive.
  • Each row has a tenant logo (resolved through a new authenticated blob proxy at /back-office/blob), a TenantStatusBadge (combines plan + planned change), and a Subscribed since value.
  • Click a row to open the side pane preview (AccountSidePane): tenant logo, plan badge, status, country flag, and AccountSidePaneSections:
    • Plan & revenue: Renewal date · MRR (with forward-MRR strike-through for cancellations and downgrades), Subscribed since · Lifetime value, Last invoice date · Last invoice amount.
    • Owners list with avatar, name, email.
    • Users preview (top three with SidePaneUsersRow) plus a total · active · inactive · pending summary bar.
    • Created with formatted date and a relative <n> days ago.
    • Footer action: "Open account" navigates to the full detail page.
  • Side pane debounces the detail fetch by 200 ms to avoid hammering the API while a user arrows through rows.

Account detail (/accounts/$tenantId)

Header with tenant logo, name, plan and status badges, country flag, "Created" date, and an AccountActionsMenu kebab (admin-only) with the Sync with Stripe action. Three KPI tiles below the header (AccountHealthTiles):

  • Users: <active> / <total> with activation progress bar and <percent>% activation subtitle.
  • Lifetime value: sum of AmountExcludingTax over Succeeded payment transactions, with Since <subscribed date> subtitle.
  • MRR: forward-MRR with strike-through display when downgrading or cancelling, Renews <date> subtitle.

Tabs:

  • Overview: Owners list, Current plan card (price, period end, subscribed since, billing address, status indicator), Invoices preview (latest 2 with a "View all invoices" link), Billing events preview (latest with a link to the full tab).
  • Users: paginated table of all users on the tenant (AccountUsersTab) — name, email, role, last sign-in, status. The Role multi-select filter is passed to the backend so paging is correct under filters.
  • Invoices: full payment history (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).
  • Billing events: full lifecycle log filtered to this tenant (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)

  • List toolbar with search by email / first name / last name / account name (debounced, minimum 2 characters), with empty and loading states. Pagination uses a shared TablePagination component.
  • Table columns: Name + email, Account, Role, Last sign-in, Status (UsersTable + UsersTableRow).
  • Detail page header: avatar, full name, email, last-seen relative date.
  • KPI tiles (UserActivityTiles): Tenant memberships count, Active sessions, Logins in last 30 days.
  • Tabs:
    • Overview: Tenant memberships table (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.
    • Logins: full login history with method (Email, Google, Microsoft), outcome, IP, when, device. parseUserAgent correctly disambiguates Chromium-based Opera from Chrome and Android from desktop Linux by ordering the more-specific patterns first.
    • Sessions: Active sessions table (UserSessionsSection) — device, browser, IP, last seen, with a Revoked badge 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.

  • Toolbar (BillingEventsToolbar): search by account name, multi-select Event type filter (covers all 20 BillingEventType values), 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.
  • Table (BillingEventsTable): Date, Event (badge), Account, Plan transition, MRR impact (signed delta), MRR after, with deep-link to the source account on click.
  • Reachable from the dashboard's "View all events" link, the dashboard MRR tile click-through, and direct navigation.

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_events row; every row maps back to exactly one Stripe event id.

  • Aggregate (Core/Features/Subscriptions/Domain/BillingEvent.cs): BillingEventId is a StronglyTypedUlid (bilevt_…). Idempotency is enforced by the unique index on stripe_event_id — webhook redelivery and reconciliation re-pulls collide on insert and are silently skipped. Each row carries a denormalized CommittedMrr (state-after MRR) and AmountDelta so paginated reads and the MRR trend don't have to walk history.
  • 20 event types: SubscriptionCreated/Renewed/Upgraded/DowngradeScheduled/DowngradeCancelled/Downgraded/Cancelled/Reactivated/Expired/ImmediatelyCancelled/Suspended/PastDue, PaymentFailed/Recovered/Refunded, BillingInfoAdded/Updated, PaymentMethodUpdated, plus NoOp (recognized event that doesn't move state we care about — hidden from the timeline) and Unclassified (event whose payload combines multiple changes we can't decompose into one transition; flips the drift flag for admin review).
  • Source of truth: local stripe_events archive. 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 to billing_events; the live ProcessPendingStripeEvents does not write BillingEvents directly anymore.
  • Replayer (StripeEventReplayer): walks stripe_events chronologically, dispatches each row through an IStripeEventPayloadResolver chosen by the event's api_version so 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 in billing_events and stripe_events are never deleted, never updated after insert — schema changes use ALTER TABLE ADD/DROP COLUMN only.
  • StripeEvent immutability: rows are immutable after MarkProcessed(now, tenantId, stripeSubscriptionId) — the backfill is folded into the same call, and the public SetTenantId / SetStripeSubscriptionId setters are removed so the type system enforces "Processed = work done; never mutate again."
  • Reconciliation (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 as StripeEvent.CreateRecovered(...) with recovery_source = "events_list" and recovered_at = now. A StripeEvent row's non-null recovered_at is the forensic marker that a webhook delivery was missed.
  • Resource coverage audit (CheckResourceCoverageAsync): per sync, audits Stripe resources against the event log — subscription created date should have a customer.subscription.created event, every succeeded payment should have an invoice.payment_succeeded, every refund a charge.refunded, schedule changes a subscription_schedule.updated, payment methods a payment_method.attached. Each gap surfaces as a MissingHistoricalEvent discrepancy (within the 30-day window — auto-recoverable on next sync) or MissingHistoricalEventUnrecoverable (older than the window — bug, must be investigated).
  • Payload divergence detection (AcknowledgeStripeWebhook + StripeEventPayloadHasher): every incoming webhook payload is hashed with SHA-256. If the same Stripe event id arrives twice with different payloads, the existing stripe_events row is preserved unchanged and a StripeEventPayloadDivergence drift discrepancy + StripeEventPayloadMismatch telemetry event are emitted for admin investigation.
  • Sync command (SyncTenantWithStripe): admin-gated back-office action that runs the live sync forcefully (even with no pending events), then runs the drift detector. Returns BillingEventsAppended, HasDriftDetected, DriftDiscrepancyCount, SyncedAt. Result dialog shows a green check on a clean sync, an amber alert when drift is detected.
  • Plan resolution (StripeClient): PaymentTransaction is 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

BillingDriftDetector compares 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).

  • Discrepancies are categorized by DriftDiscrepancyKind with severity (Warning, Critical):
    • MissingEvent — a payment transaction exists with no matching BillingEvent
    • MissingHistoricalEvent — 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 decompose
    • UnsupportedStripeApiVersion — Stripe sent an event in an api_version we don't have a resolver for; add one
    • StripeEventPayloadDivergence — same event id, different payload — bug or upstream tampering
    • SubscriptionStateMismatch and ExtraEvent reserved for future detector growth
  • Detection result is stored on the subscription as HasDriftDetected + DriftCheckedAt + DriftDiscrepancies (JSONB) so the next sync starts from a known baseline.
  • AcknowledgeBillingDrift command — gated on BackOfficeIdentityDefaults.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 has HasDriftDetected = 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 zero billing_events rows.
  • 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

  • MRR trend reads CommittedMrr from the BillingEvent log: GetDashboardMrrTrendHandler groups events by subscription, sorts by OccurredAt, and for each day picks the committed_mrr value of the latest event up to end-of-day. The denormalized column means the trend doesn't have to walk transition history per query.
  • Lifetime value fix (GetTenantDetailHandler): filter to Status == Succeeded, sum AmountExcludingTax. 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 by GetDashboardKpisHandler, GetDashboardMrrConsistencySummaryHandler, and the per-account MrrAmount tile 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.AmountExcludingTax and TaxAmount are non-nullable decimal in the C# domain record. The TenantPaymentTransaction DTO mirrors the change.
  • A CHECK constraint on subscriptions.payment_transactions JSONB enforces the invariant at the database level: NOT jsonb_path_exists(payment_transactions, '$[*] ? (!(@.AmountExcludingTax.type() == "number") || !(@.TaxAmount.type() == "number"))').
  • All construction sites (StripeClient, MockStripeClient, eight test files) updated to pass values. The previous ?? Amount fallback in the LTV formula is gone.

Admin authorization on Sync with Stripe and AcknowledgeBillingDrift

/{id}/sync-with-stripe and /{id}/drift/acknowledge chain .RequireAuthorization(BackOfficeIdentityDefaults.AdminPolicyName) on top of the group's regular BackOfficeIdentityDefaults.PolicyName. Authenticated non-admins receive 403; the front-end AccountActionsMenu mirrors the gate by hiding the kebab trigger when me.isAdmin is 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, and GetMrrChangeUnfilteredAsync skip 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

  • The shared BannerPortal renders a <div id="banner-root"> fixed at the top of the viewport with z-40 and measures its own height into a --banner-offset CSS variable.
  • BackOfficeBanners portals into that slot via createPortal, with a useEffect-based target lookup that defers the DOM read until after the parent's commit so the synchronous mount doesn't race the portal target.
  • AppLayout and Sidebar viewport-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

  • New LinkCard wraps Card with a TanStack Router <Link> so an entire card is a navigable surface.
  • Card consolidated to a single primitive used by both apps.
  • Chart wrapper around recharts defaulting accessibilityLayer={true} and re-exporting Bar, Line, Area, Pie, Donut, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer. Direct recharts imports are lint-blocked. A global tailwind.css rule suppresses the unstyleable Chrome focus ring on svg.recharts-surface.
  • Table styling consolidated; TablePagination accepts trackingTitle for telemetry.
  • TenantLogo resolves and proxies tenant blobs through BackOfficeBlobProxy (host-scoped, admin-policy-protected) so logos load with admin auth instead of leaking signed URLs.
  • MultiSelect gains clearAllLabel and an "Apply" affordance. Sidebar gains the --banner-offset-aware height calc described above. Alert adds an info variant.
  • useSmartDate adds locale-aware formatLongDate for 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 as Processed with recovery_source = "events_list".
  • StripeEventPayloadDivergenceTests — same id arriving twice with different payloads preserves the existing row and emits the StripeEventPayloadMismatch telemetry event.
  • SyncTenantWithStripeTests (incl. admin-required / non-admin-forbidden / unconfigured-Stripe cases) with TelemetryEventsCollectorSpy assertions 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.ts extended with reconciliation paths.

Database migration

20260509180000_AddBillingEventsAndDriftDetection:

  • Adds subscribed_since and scheduled_price_amount columns to subscriptions.
  • Adds has_drift_detected, drift_checked_at, drift_discrepancies (JSONB) columns and a filtered index on has_drift_detected = true.
  • Backfills subscribed_since = created_at for active paid subscriptions (those with a Stripe subscription id and a non-Basis plan).
  • Creates the billing_events table — strongly-typed-string PK, 17 columns including stripe_event_id, committed_mrr, amount_delta, from_plan, to_plan, previous_amount, new_amount, currency, cancellation_reason, suspension_reason, occurred_at. Indexes on stripe_event_id (unique), tenant_id+occurred_at DESC, occurred_at DESC, subscription_id.
  • Adds api_version, recovered_at, recovery_source, payload_hash columns to stripe_events plus a filtered index on recovered_at IS NOT NULL.
  • Adds a CHECK constraint on subscriptions.payment_transactions enforcing AmountExcludingTax and TaxAmount are present on every JSONB element.

Dev tooling

  • .mcp.json adds Stripe MCP servers for development, staging, and production, each with a restricted-key guard so a leaked key can't be used outside its environment. API keys sourced from dotnet user-secrets via small wrapper scripts so no key is committed.
  • .claude/skills/db-query/SKILL.md adds a guided way to run psql against the local Aspire Postgres for inspection, with an explicit destructive-operations rule: any DROP, TRUNCATE, DELETE, or ALTER requires extreme care and a clarifying question if the request is even slightly ambiguous — assume the most conservative interpretation.

Checklist

  • I have added tests, or done manual regression tests
  • I have updated the documentation, if necessary

@tjementum tjementum self-assigned this May 8, 2026
@tjementum tjementum requested a review from a team as a code owner May 8, 2026 02:08
@tjementum tjementum added the Enhancement New feature or request label May 8, 2026
@tjementum tjementum added the Deploy to Staging Set this label on pull requests to deploy code or infrastructure to the Staging environment label May 8, 2026
@tjementum tjementum force-pushed the back-office-tenant-overview branch 2 times, most recently from 6ef04b8 to edc9f2d Compare May 8, 2026 10:10
@tjementum tjementum force-pushed the back-office-tenant-overview branch 2 times, most recently from 54f9b4e to fc0b457 Compare May 8, 2026 19:52
@tjementum tjementum removed the Deploy to Staging Set this label on pull requests to deploy code or infrastructure to the Staging environment label May 8, 2026
@tjementum tjementum force-pushed the back-office-tenant-overview branch 2 times, most recently from ac23c03 to 8b2c185 Compare May 8, 2026 22:51
@tjementum tjementum moved this to 🏗 In Progress in Kanban board May 8, 2026
@tjementum tjementum force-pushed the back-office-tenant-overview branch from b942167 to 48cc6cb Compare May 9, 2026 11:57
@tjementum tjementum added the Deploy to Staging Set this label on pull requests to deploy code or infrastructure to the Staging environment label May 9, 2026
@tjementum tjementum force-pushed the back-office-tenant-overview branch from 48cc6cb to 92caade Compare May 9, 2026 12:49
@tjementum tjementum force-pushed the back-office-tenant-overview branch from 92caade to b95f7b5 Compare May 9, 2026 13:40
@tjementum tjementum force-pushed the back-office-tenant-overview branch from b95f7b5 to b11d490 Compare May 9, 2026 21:30
@tjementum tjementum force-pushed the back-office-tenant-overview branch from b11d490 to a317253 Compare May 9, 2026 22:01
@tjementum tjementum removed the Deploy to Staging Set this label on pull requests to deploy code or infrastructure to the Staging environment label May 9, 2026
tjementum added 28 commits May 11, 2026 12:59
@sonarqubecloud
Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
C Reliability Rating on New Code (required ≥ A)

See analysis details on SonarQube Cloud

Catch issues before they fail your Quality Gate with our IDE extension SonarQube for IDE

@github-actions
Copy link
Copy Markdown

Approve Database Migration account database on stage

The following pending migration(s) will be applied to the database when approved:

  • AddBillingEventsAndDriftDetection (20260509180000_AddBillingEventsAndDriftDetection)

Migration Script

START TRANSACTION;

DO $EF$
BEGIN
    IF NOT EXISTS(SELECT 1 FROM __ef_migrations_history WHERE "migration_id" = '20260509180000_AddBillingEventsAndDriftDetection') THEN
    ALTER TABLE subscriptions ADD subscribed_since timestamptz;
    END IF;
END $EF$;

DO $EF$
BEGIN
    IF NOT EXISTS(SELECT 1 FROM __ef_migrations_history WHERE "migration_id" = '20260509180000_AddBillingEventsAndDriftDetection') THEN
    ALTER TABLE subscriptions ADD last_synced_stripe_event_created_at timestamptz;
    END IF;
END $EF$;

DO $EF$
BEGIN
    IF NOT EXISTS(SELECT 1 FROM __ef_migrations_history WHERE "migration_id" = '20260509180000_AddBillingEventsAndDriftDetection') THEN
    ALTER TABLE subscriptions ADD scheduled_price_amount numeric(18,2);
    END IF;
END $EF$;

DO $EF$
BEGIN
    IF NOT EXISTS(SELECT 1 FROM __ef_migrations_history WHERE "migration_id" = '20260509180000_AddBillingEventsAndDriftDetection') THEN
    ALTER TABLE subscriptions ADD has_drift_detected boolean NOT NULL DEFAULT FALSE;
    END IF;
END $EF$;

DO $EF$
BEGIN
    IF NOT EXISTS(SELECT 1 FROM __ef_migrations_history WHERE "migration_id" = '20260509180000_AddBillingEventsAndDriftDetection') THEN
    ALTER TABLE subscriptions ADD drift_checked_at timestamptz;
    END IF;
END $EF$;

DO $EF$
BEGIN
    IF NOT EXISTS(SELECT 1 FROM __ef_migrations_history WHERE "migration_id" = '20260509180000_AddBillingEventsAndDriftDetection') THEN
    ALTER TABLE subscriptions ADD drift_discrepancies jsonb NOT NULL DEFAULT '[]';
    END IF;
END $EF$;

DO $EF$
BEGIN
    IF NOT EXISTS(SELECT 1 FROM __ef_migrations_history WHERE "migration_id" = '20260509180000_AddBillingEventsAndDriftDetection') THEN
    CREATE INDEX ix_subscriptions_has_drift_detected ON subscriptions (has_drift_detected) WHERE has_drift_detected = true;
    END IF;
END $EF$;

DO $EF$
BEGIN
    IF NOT EXISTS(SELECT 1 FROM __ef_migrations_history WHERE "migration_id" = '20260509180000_AddBillingEventsAndDriftDetection') THEN
    ALTER TABLE subscriptions ADD CONSTRAINT chk_subscriptions_payment_transactions_amounts_non_negative CHECK (NOT jsonb_path_exists(payment_transactions, '$[*] ? (!(@.AmountExcludingTax.type() == "number") || !(@.TaxAmount.type() == "number") || @.AmountExcludingTax < 0 || @.TaxAmount < 0)'));
    END IF;
END $EF$;

DO $EF$
BEGIN
    IF NOT EXISTS(SELECT 1 FROM __ef_migrations_history WHERE "migration_id" = '20260509180000_AddBillingEventsAndDriftDetection') THEN
    CREATE TABLE billing_events (
        tenant_id bigint NOT NULL,
        id text NOT NULL,
        subscription_id text NOT NULL,
        created_at timestamptz NOT NULL,
        modified_at timestamptz,
        stripe_event_id text NOT NULL,
        event_type text NOT NULL,
        from_plan text,
        to_plan text,
        previous_amount numeric(18,2),
        new_amount numeric(18,2),
        amount_delta numeric(18,2),
        committed_mrr numeric(18,2) NOT NULL,
        currency text,
        occurred_at timestamptz NOT NULL,
        cancellation_reason text,
        suspension_reason text,
        CONSTRAINT pk_billing_events PRIMARY KEY (id)
    );
    END IF;
END $EF$;

DO $EF$
BEGIN
    IF NOT EXISTS(SELECT 1 FROM __ef_migrations_history WHERE "migration_id" = '20260509180000_AddBillingEventsAndDriftDetection') THEN
    CREATE UNIQUE INDEX ix_billing_events_stripe_event_id ON billing_events (stripe_event_id);
    END IF;
END $EF$;

DO $EF$
BEGIN
    IF NOT EXISTS(SELECT 1 FROM __ef_migrations_history WHERE "migration_id" = '20260509180000_AddBillingEventsAndDriftDetection') THEN
    CREATE INDEX ix_billing_events_tenant_id_occurred_at ON billing_events (tenant_id, occurred_at DESC);
    END IF;
END $EF$;

DO $EF$
BEGIN
    IF NOT EXISTS(SELECT 1 FROM __ef_migrations_history WHERE "migration_id" = '20260509180000_AddBillingEventsAndDriftDetection') THEN
    CREATE INDEX ix_billing_events_occurred_at ON billing_events (occurred_at DESC);
    END IF;
END $EF$;

DO $EF$
BEGIN
    IF NOT EXISTS(SELECT 1 FROM __ef_migrations_history WHERE "migration_id" = '20260509180000_AddBillingEventsAndDriftDetection') THEN
    CREATE INDEX ix_billing_events_subscription_id ON billing_events (subscription_id);
    END IF;
END $EF$;

DO $EF$
BEGIN
    IF NOT EXISTS(SELECT 1 FROM __ef_migrations_history WHERE "migration_id" = '20260509180000_AddBillingEventsAndDriftDetection') THEN
    ALTER TABLE stripe_events ADD api_version text;
    END IF;
END $EF$;

DO $EF$
BEGIN
    IF NOT EXISTS(SELECT 1 FROM __ef_migrations_history WHERE "migration_id" = '20260509180000_AddBillingEventsAndDriftDetection') THEN
    ALTER TABLE stripe_events ADD recovered_at timestamptz;
    END IF;
END $EF$;

DO $EF$
BEGIN
    IF NOT EXISTS(SELECT 1 FROM __ef_migrations_history WHERE "migration_id" = '20260509180000_AddBillingEventsAndDriftDetection') THEN
    ALTER TABLE stripe_events ADD recovery_source text;
    END IF;
END $EF$;

DO $EF$
BEGIN
    IF NOT EXISTS(SELECT 1 FROM __ef_migrations_history WHERE "migration_id" = '20260509180000_AddBillingEventsAndDriftDetection') THEN
    ALTER TABLE stripe_events ADD payload_hash text;
    END IF;
END $EF$;

DO $EF$
BEGIN
    IF NOT EXISTS(SELECT 1 FROM __ef_migrations_history WHERE "migration_id" = '20260509180000_AddBillingEventsAndDriftDetection') THEN
    ALTER TABLE stripe_events ADD stripe_created_at timestamptz;
    END IF;
END $EF$;

DO $EF$
BEGIN
    IF NOT EXISTS(SELECT 1 FROM __ef_migrations_history WHERE "migration_id" = '20260509180000_AddBillingEventsAndDriftDetection') THEN
    CREATE INDEX ix_stripe_events_recovered_at ON stripe_events (recovered_at) WHERE recovered_at IS NOT NULL;
    END IF;
END $EF$;

DO $EF$
BEGIN
    IF NOT EXISTS(SELECT 1 FROM __ef_migrations_history WHERE "migration_id" = '20260509180000_AddBillingEventsAndDriftDetection') THEN
    ALTER TABLE billing_events ADD CONSTRAINT chk_billing_events_currency_format CHECK (currency ~ '^[A-Z]{3}$');
    END IF;
END $EF$;

DO $EF$
BEGIN
    IF NOT EXISTS(SELECT 1 FROM __ef_migrations_history WHERE "migration_id" = '20260509180000_AddBillingEventsAndDriftDetection') THEN
    ALTER TABLE subscriptions ADD CONSTRAINT chk_subscriptions_current_price_currency_format CHECK (current_price_currency IS NULL OR current_price_currency ~ '^[A-Z]{3}$');
    END IF;
END $EF$;

DO $EF$
BEGIN
    IF NOT EXISTS(SELECT 1 FROM __ef_migrations_history WHERE "migration_id" = '20260509180000_AddBillingEventsAndDriftDetection') THEN
    ALTER TABLE subscriptions ADD CONSTRAINT chk_subscriptions_payment_transactions_currency_format CHECK (NOT jsonb_path_exists(payment_transactions, '$[*] ? (!(@.Currency.type() == "string") || !(@.Currency like_regex "^[A-Z]{3}$"))'));
    END IF;
END $EF$;

DO $EF$
BEGIN
    IF NOT EXISTS(SELECT 1 FROM __ef_migrations_history WHERE "migration_id" = '20260509180000_AddBillingEventsAndDriftDetection') THEN
    INSERT INTO __ef_migrations_history (migration_id, product_version)
    VALUES ('20260509180000_AddBillingEventsAndDriftDetection', '10.0.7');
    END IF;
END $EF$;
COMMIT;

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Enhancement New feature or request

Projects

Status: 🏗 In Progress

Development

Successfully merging this pull request may close these issues.

Back office

1 participant