Initial commit
This commit is contained in:
@@ -0,0 +1,255 @@
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// SuperAdmin-only revenue intelligence dashboard that aggregates subscription
|
||||
/// metrics across all tenant companies. Uses <see cref="ApplicationDbContext"/>
|
||||
/// directly (bypassing the UoW) because it queries across company boundaries and
|
||||
/// joins plan configs — a pattern that would require multiple unrelated repositories.
|
||||
/// </summary>
|
||||
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
|
||||
public class RevenueController : Controller
|
||||
{
|
||||
private readonly ApplicationDbContext _db;
|
||||
|
||||
public RevenueController(ApplicationDbContext db)
|
||||
{
|
||||
_db = db;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renders the platform revenue dashboard with MRR, ARR, plan distribution,
|
||||
/// a 12-month MRR trend, and a list of companies needing subscription attention.
|
||||
/// <para>
|
||||
/// Design decisions:
|
||||
/// <list type="bullet">
|
||||
/// <item>Demo company (Id = 1) and comped companies are excluded from all
|
||||
/// revenue figures because they do not represent real subscription income.</item>
|
||||
/// <item>The 12-month MRR trend is an approximation — it estimates active
|
||||
/// subscribers per historical month using <c>CreatedAt</c> and current status,
|
||||
/// not a ledger of actual billing events, because no billing-transactions table
|
||||
/// exists yet.</item>
|
||||
/// <item><c>IgnoreQueryFilters()</c> is required throughout so that the global
|
||||
/// multi-tenancy filter does not restrict visibility to a single company.</item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public async Task<IActionResult> 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<MrrTrendPoint>();
|
||||
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<PlanMetricRow> PlanDistribution { get; set; } = new();
|
||||
public List<MrrTrendPoint> TrendMonths { get; set; } = new();
|
||||
public List<SubscriptionAlertRow> 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; }
|
||||
}
|
||||
Reference in New Issue
Block a user