Skip to content

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

Merged
tjementum merged 158 commits into
mainfrom
back-office-tenant-overview
May 12, 2026
Merged

Add back-office tenant overview with dashboard, accounts, users, billing events, and Stripe reconciliation#888
tjementum merged 158 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 change adds every operator-facing page on top of that shell, an append-only BillingEvent ledger 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 the PUBLIC_SUBSCRIPTION_ENABLED runtime 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 BackOfficeAvatarMenu hides 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.tsx through the shared BannerPortal. 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 use LinkCard so the full tile is a navigation target.

  • KPI tiles (/api/back-office/dashboard/kpis): Total accounts (with +N new in last <period> days and 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.
  • MRR trend (/api/back-office/dashboard/mrr-trend): Current vs Prior period overlay. Each daily point reads CommittedMrr on the latest BillingEvent per subscription up to end-of-day.
  • Revenue chart (/api/back-office/dashboard/revenue-trend): cumulative ex-VAT area chart with the same period selector and prior-period overlay. Each successful charge adds AmountExcludingTax to 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.
  • Plan distribution, Account growth, User logins, Recent signups, Recent Stripe events round out the dashboard.
  • All charts go through shared Chart.tsx wrappers that default accessibilityLayer={true} and ship Stripe-style tooltips (per-point date + signed delta vs prior period; green / red coloring consistent across charts). Direct recharts imports 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; unsynced and driftDetected params (set by the banners and IssueFilterBadges) are preserved across paging, sorting, and other filter changes. Sortable columns (Name, Plan, MRR, Renewal date, Status) via a shared SortableTableHead primitive — 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, and AccountSidePaneSections — 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 AccountActionsMenu kebab (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 of AmountExcludingTax over un-reversed Succeeded transactions; 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. CardBrandLogo renders 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's Renewal date. Terminated subscriptions show Expired with the last active date. Cancelled plan transitions on the per-account billing history row render as cancelled-plan → Basis so 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 — parseUserAgent correctly disambiguates Chromium-based Opera from Chrome and Android from desktop Linux), Sessions (active sessions with a Revoked badge). userDisplay.ts handles 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).

  • MRR impact: every event that moves committed MRR up or down — Subscribed, Upgraded, Reactivated, Cancelled, Downgrade scheduled, Downgrade cancelled, Downgraded, Expired, Cancelled immediately.
  • Subscription state: strict effective-plan changes only — Subscribed, Upgraded, Downgraded (when effective), Cancelled immediately, Expired, Suspended. Excludes scheduled or reversed events and Renewed.
  • Other: payment-flow, billing-metadata, and same-plan renewals.

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_events row, enforced by a 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 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 (hidden from the timeline) and Unclassified (payload combining multiple changes we can't decompose; flips the drift flag).
  • 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 is the canonical source for replay. StripeEventReplayer is the only writer to billing_events; it walks stripe_events chronologically, dispatches each row through an IStripeEventPayloadResolver chosen by the event's api_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 correct SubscriptionDowngradeCancelled event. Hard rule: rows in billing_events and stripe_events are never deleted, never updated after insert.
  • StripeEvent immutability: rows are immutable after MarkProcessed(now, tenantId, stripeSubscriptionId). The public SetTenantId / SetStripeSubscriptionId setters are removed so the type system enforces "Processed = work done; never mutate again."
  • Hot-path optimization: the webhook acknowledgement path no longer reads stripe_events.payload. Payload reads are deferred to the background replayer; payload-derived columns (e.g. api_version) are nullable until backfilled.
  • Reconciliation (ReconcileEventLogFromEventsListAsync): runs on every webhook and every admin Sync. Diffs events.list against the local archive and inserts missing events as StripeEvent.CreateRecovered(...) with recovery_source = "events_list" and recovered_at = now — the forensic marker that a webhook delivery was missed. CheckResourceCoverageAsync audits Stripe resources against the event log; gaps surface as MissingHistoricalEvent (auto-recoverable inside the 30-day window) or MissingHistoricalEventUnrecoverable (older, must be investigated).
  • Payload divergence detection (StripeEventPayloadHasher): every webhook payload is SHA-256 hashed. Same event id with different payload preserves the existing row and emits a StripeEventPayloadDivergence discrepancy + StripeEventPayloadMismatch telemetry event.
  • Plan resolution (StripeClient): PaymentTransaction is 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.

  • Step 1 — Reconcile (SyncTenantWithStripe, admin-gated): runs the live sync forcefully (even with no pending events), pulls events.list, appends any missing stripe_events, runs the replayer to repopulate billing_events, then runs the drift detector. Result dialog shows appended count plus any discrepancies. Clean syncs land on a green check.
  • Step 2 — Disaster recovery red-alert dialog: surfaces only when Reconcile detected MissingHistoricalEventUnrecoverable discrepancies (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.

BillingDriftDetector compares local subscription state against a Stripe-derived snapshot. Discrepancies are categorized by DriftDiscrepancyKind (MissingEvent, MissingHistoricalEvent, MissingHistoricalEventUnrecoverable, UnclassifiedStripeEvent, UnsupportedStripeApiVersion, StripeEventPayloadDivergence, plus SubscriptionStateMismatch and ExtraEvent reserved for future growth) with severity (Warning, Critical). Result is stored on the subscription as HasDriftDetected + 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-second subscription_cycle bursts don't false-positive, and skips boundary rows so a clean Reconcile clears the MissingEvent flag. 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 — when STRIPE_SECRET_KEY is 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

  • Net revenue rule (accrual model): Total Revenue tile, Revenue chart, and per-tenant Lifetime Value sum only 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.
  • PaymentTransaction gains CreditNotedAt, RefundedAt, InvoiceTotal, and AmountFromCredit so 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.
  • MRR trend reads CommittedMrr from 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.AmountExcludingTax and TaxAmount are non-nullable decimal in the C# domain record; the TenantPaymentTransaction DTO mirrors the change. The previous ?? Amount fallback in the LTV formula is gone. MockStripeClient and 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, and BillingEvent lost 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-stripe and /{id}/drift/acknowledge chain .RequireAuthorization(BackOfficeIdentityDefaults.AdminPolicyName) on top of the group's regular policy. Non-admins receive 403; AccountActionsMenu hides the kebab trigger when me.isAdmin is false. Banner endpoints stay on the regular policy so every authenticated user sees the warnings.
  • The cross-tenant repository reads (GetAllUnfilteredAsync etc.) 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: accountEnvironmentVariables is a single merged set used by the account API, workers, and back-office container, so Stripe config, PUBLIC_SUBSCRIPTION_ENABLED, and PUBLIC_GOOGLE_OAUTH_ENABLED propagate consistently. PUBLIC_SUBSCRIPTION_ENABLED=false hides every back-office billing surface.

Banner portal, shared UI primitives, translations

BannerPortal renders a fixed-top <div id="banner-root"> (z-40) and measures its height into a --banner-offset CSS variable. BackOfficeBanners portals into that slot via createPortal. AppLayout and Sidebar viewport-height calcs subtract --banner-offset so bottom-pinned elements stay visible when a banner is active. Benefits the user-facing app's existing banner stack as well.

New LinkCard wraps Card with a TanStack Router <Link>. Chart wraps recharts with accessibilityLayer={true} defaulted. TablePagination accepts trackingTitle. TenantLogo proxies through BackOfficeBlobProxy. MultiSelect gains clearAllLabel and an "Apply" affordance. Alert adds an info variant. useSmartDate adds locale-aware formatLongDate and 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 including GetDashboardRevenueTrendTests (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 with TelemetryEventsCollectorSpy assertions), 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.ts extended with reconciliation paths.

Database migration

20260509180000_AddBillingEventsAndDriftDetection: adds drift-tracking columns and subscribed_since / scheduled_price_amount to subscriptions; creates billing_events (17 columns, 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, stripe_created_at to stripe_events with a filtered index on recovered_at IS NOT NULL. CHECK constraints enforce numeric non-negative AmountExcludingTax / TaxAmount / InvoiceTotal / AmountFromCredit on every JSONB payment transaction, and ^[A-Z]{3}$ currency format on billing_events.currency, subscriptions.current_price_currency, and payment_transactions[*].Currency.

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 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 23 commits May 12, 2026 23:49
@tjementum tjementum force-pushed the back-office-tenant-overview branch from cda652c to de545cf Compare May 12, 2026 21:50
@sonarqubecloud
Copy link
Copy Markdown

@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") || !(@.InvoiceTotal.type() == "number") || !(@.AmountFromCredit.type() == "number") || @.AmountExcludingTax < 0 || @.TaxAmount < 0 || @.InvoiceTotal < 0 || @.AmountFromCredit < 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;

@tjementum tjementum merged commit 1da4403 into main May 12, 2026
34 checks passed
@tjementum tjementum deleted the back-office-tenant-overview branch May 12, 2026 22:17
@github-project-automation github-project-automation Bot moved this from 🏗 In Progress to ✅ Done in Kanban board May 12, 2026
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: ✅ Done

Development

Successfully merging this pull request may close these issues.

Back office

1 participant