using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using PowderCoating.Core.Enums; using PowderCoating.Infrastructure.Data; using PowderCoating.Shared.Constants; namespace PowderCoating.Web.Controllers; /// /// SuperAdmin-only revenue intelligence dashboard that aggregates subscription /// metrics across all tenant companies. Uses /// directly (bypassing the UoW) because it queries across company boundaries and /// joins plan configs — a pattern that would require multiple unrelated repositories. /// // Intentional exception: cross-tenant MRR/ARR metrics joining Company + SubscriptionPlanConfig; same pattern as CompanyHealthController. See docs/DATA_ACCESS_ARCHITECTURE.md — Permanent Exceptions. [Authorize(Policy = AppConstants.Policies.SuperAdminOnly)] public class RevenueController : Controller { private readonly ApplicationDbContext _db; public RevenueController(ApplicationDbContext db) { _db = db; } /// /// Renders the platform revenue dashboard with MRR, ARR, plan distribution, /// a 12-month MRR trend, and a list of companies needing subscription attention. /// /// Design decisions: /// /// Demo company (Id = 1) and comped companies are excluded from all /// revenue figures because they do not represent real subscription income. /// The 12-month MRR trend is an approximation — it estimates active /// subscribers per historical month using CreatedAt and current status, /// not a ledger of actual billing events, because no billing-transactions table /// exists yet. /// IgnoreQueryFilters() is required throughout so that the global /// multi-tenancy filter does not restrict visibility to a single company. /// /// /// public async Task Index() { var now = DateTime.UtcNow; var thisMonthStart = new DateTime(now.Year, now.Month, 1); var lastMonthStart = thisMonthStart.AddMonths(-1); // Plan configs (price lookup) var planConfigs = await _db.SubscriptionPlanConfigs .AsNoTracking().IgnoreQueryFilters() .Where(p => p.IsActive) .OrderBy(p => p.SortOrder) .ToListAsync(); var priceByPlan = planConfigs.ToDictionary(p => p.Plan, p => p.MonthlyPrice); string PlanName(int plan) => planConfigs.FirstOrDefault(p => p.Plan == plan)?.DisplayName ?? plan.ToString(); // Exclude demo company (Id 1) and comped companies from revenue calculations; // they don't represent real subscription income. const int DemoCompanyId = 1; var companies = await _db.Companies .AsNoTracking().IgnoreQueryFilters() .Where(c => !c.IsDeleted && c.Id != DemoCompanyId && !c.IsComped) .ToListAsync(); // Comped companies are excluded from revenue but must be counted separately // so the status table in the view sums to a correct total. var compedCount = await _db.Companies .AsNoTracking().IgnoreQueryFilters() .CountAsync(c => !c.IsDeleted && c.Id != DemoCompanyId && c.IsComped); // ── Revenue metrics ────────────────────────────────────────────── // Active paying companies var payingActive = companies .Where(c => (c.SubscriptionStatus == SubscriptionStatus.Active || c.SubscriptionStatus == SubscriptionStatus.GracePeriod)) .ToList(); var mrr = payingActive.Sum(c => priceByPlan.TryGetValue(c.SubscriptionPlan, out var price) ? price : 0m); var arr = mrr * 12; // Average revenue per paying company var avgRevenue = payingActive.Count > 0 ? mrr / payingActive.Count : 0m; // ── Company counts ─────────────────────────────────────────────── // totalCompanies includes comped so that Active + GracePeriod + Expired + Canceled + Comped = Total. var totalCompanies = companies.Count + compedCount; var activeCount = companies.Count(c => c.SubscriptionStatus == SubscriptionStatus.Active); var gracePeriodCount = companies.Count(c => c.SubscriptionStatus == SubscriptionStatus.GracePeriod); var expiredCount = companies.Count(c => c.SubscriptionStatus == SubscriptionStatus.Expired); var canceledCount = companies.Count(c => c.SubscriptionStatus == SubscriptionStatus.Canceled || c.SubscriptionStatus == SubscriptionStatus.Inactive); var stripeCount = companies.Count(c => !string.IsNullOrEmpty(c.StripeSubscriptionId)); // ── Month-over-month ───────────────────────────────────────────── var newThisMonth = companies.Count(c => c.CreatedAt >= thisMonthStart); var newLastMonth = companies.Count(c => c.CreatedAt >= lastMonthStart && c.CreatedAt < thisMonthStart); var churnThisMonth = companies.Count(c => c.UpdatedAt >= thisMonthStart && (c.SubscriptionStatus == SubscriptionStatus.Canceled || c.SubscriptionStatus == SubscriptionStatus.Expired)); // ── Plan distribution ──────────────────────────────────────────── var planDistribution = companies .Where(c => !c.IsComped && (c.SubscriptionStatus == SubscriptionStatus.Active || c.SubscriptionStatus == SubscriptionStatus.GracePeriod)) .GroupBy(c => c.SubscriptionPlan) .Select(g => new PlanMetricRow { PlanId = g.Key, PlanName = PlanName(g.Key), CompanyCount = g.Count(), MonthlyPrice = priceByPlan.TryGetValue(g.Key, out var p) ? p : 0m, Revenue = g.Sum(c => priceByPlan.TryGetValue(c.SubscriptionPlan, out var pr) ? pr : 0m) }) .OrderByDescending(r => r.Revenue) .ToList(); // ── Approximated 12-month trend ────────────────────────────────── // We estimate MRR per month by looking at company creation dates. // (No billing transactions table yet — this is an approximation.) var trendMonths = new List(); for (int i = 11; i >= 0; i--) { var monthStart = new DateTime(now.Year, now.Month, 1).AddMonths(-i); var monthEnd = monthStart.AddMonths(1); var activeInMonth = companies .Where(c => !c.IsComped && c.CreatedAt < monthEnd && (c.SubscriptionStatus == SubscriptionStatus.Active || c.SubscriptionStatus == SubscriptionStatus.GracePeriod || c.CreatedAt >= monthStart)) // simplification .ToList(); var mrrPoint = activeInMonth.Sum(c => priceByPlan.TryGetValue(c.SubscriptionPlan, out var pr) ? pr : 0m); trendMonths.Add(new MrrTrendPoint { Month = monthStart.ToString("MMM yy"), Mrr = mrrPoint, CompanyCount = activeInMonth.Count }); } // ── Companies needing attention ────────────────────────────────── var alerts = companies .Where(c => c.SubscriptionStatus == SubscriptionStatus.GracePeriod || c.SubscriptionStatus == SubscriptionStatus.Expired || (c.SubscriptionStatus == SubscriptionStatus.Active && c.SubscriptionEndDate.HasValue && c.SubscriptionEndDate.Value <= now.AddDays(14) && !c.IsComped)) .OrderBy(c => c.SubscriptionEndDate) .Take(20) .Select(c => new SubscriptionAlertRow { Id = c.Id, CompanyName = c.CompanyName, PlanName = PlanName(c.SubscriptionPlan), Status = c.SubscriptionStatus, EndDate = c.SubscriptionEndDate, DaysUntilExpiry = c.SubscriptionEndDate.HasValue ? (int)(c.SubscriptionEndDate.Value.Date - now.Date).TotalDays : 0, IsComped = c.IsComped }) .ToList(); var vm = new RevenueDashboardViewModel { Mrr = mrr, Arr = arr, AvgRevenuePerCompany = avgRevenue, TotalCompanies = totalCompanies, ActiveCount = activeCount, GracePeriodCount = gracePeriodCount, ExpiredCount = expiredCount, CanceledCount = canceledCount, CompedCount = compedCount, StripeManaged = stripeCount, NewThisMonth = newThisMonth, NewLastMonth = newLastMonth, ChurnThisMonth = churnThisMonth, PlanDistribution = planDistribution, TrendMonths = trendMonths, SubscriptionAlerts = alerts }; return View(vm); } } // ── Local view models (scoped to this controller file) ─────────────────────── public class RevenueDashboardViewModel { public decimal Mrr { get; set; } public decimal Arr { get; set; } public decimal AvgRevenuePerCompany { get; set; } public int TotalCompanies { get; set; } public int ActiveCount { get; set; } public int GracePeriodCount { get; set; } public int ExpiredCount { get; set; } public int CanceledCount { get; set; } public int CompedCount { get; set; } public int StripeManaged { get; set; } public int NewThisMonth { get; set; } public int NewLastMonth { get; set; } public int ChurnThisMonth { get; set; } public List PlanDistribution { get; set; } = new(); public List TrendMonths { get; set; } = new(); public List SubscriptionAlerts { get; set; } = new(); } public class PlanMetricRow { public int PlanId { get; set; } public string PlanName { get; set; } = string.Empty; public int CompanyCount { get; set; } public decimal MonthlyPrice { get; set; } public decimal Revenue { get; set; } } public class MrrTrendPoint { public string Month { get; set; } = string.Empty; public decimal Mrr { get; set; } public int CompanyCount { get; set; } } public class SubscriptionAlertRow { public int Id { get; set; } public string CompanyName { get; set; } = string.Empty; public string PlanName { get; set; } = string.Empty; public SubscriptionStatus Status { get; set; } public DateTime? EndDate { get; set; } public int DaysUntilExpiry { get; set; } public bool IsComped { get; set; } }