7fe8bc81c6
Companies with null StripeSubscriptionId are on trial and have no real subscription income. They were being counted as paying Active/GracePeriod customers, inflating MRR, ARR, plan distribution, and 12-month trend. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
260 lines
12 KiB
C#
260 lines
12 KiB
C#
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>
|
|
// 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;
|
|
}
|
|
|
|
/// <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 — exclude trials (no StripeSubscriptionId = on trial)
|
|
var payingActive = companies
|
|
.Where(c =>
|
|
!string.IsNullOrEmpty(c.StripeSubscriptionId) &&
|
|
(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 &&
|
|
!string.IsNullOrEmpty(c.StripeSubscriptionId) &&
|
|
(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 &&
|
|
!string.IsNullOrEmpty(c.StripeSubscriptionId) &&
|
|
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; }
|
|
}
|