using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Interfaces;
using PowderCoating.Shared.Constants;
namespace PowderCoating.Web.Middleware;
///
/// Per-request middleware that enforces subscription status and gates plan-specific
/// features for every authenticated non-SuperAdmin user.
///
/// On each request this middleware:
///
/// - Sets HttpContext.Items["AllowOnlinePayments"] and
/// HttpContext.Items["AllowAccounting"] from the company's
/// subscription plan configuration (or per-company overrides).
/// Controllers and views read these flags via helper methods such as
/// AllowAccounting() to show/hide plan-gated UI elements.
/// - Redirects inactive companies to /Billing/Inactive.
/// - Redirects expired companies (past the grace period) to /Billing/Expired.
/// - Sets HttpContext.Items["SubscriptionDaysRemaining"] for companies
/// within the expiry warning window (≤ 14 days) so the layout can render
/// a countdown banner.
///
///
///
/// SuperAdmins bypass subscription enforcement entirely but still receive the
/// feature-flag Items entries (set to true) so that views relying
/// on those flags render correctly when a SuperAdmin impersonates a tenant.
///
///
public class SubscriptionMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger _logger;
///
/// URL path prefixes that bypass subscription enforcement.
/// These are paths where the subscription status is either irrelevant
/// (Identity/auth routes, static assets) or where the user must be able
/// to reach even on an expired subscription (Billing, data export, Stripe
/// webhook, SignalR).
///
private static readonly string[] SkipPaths =
{
"/Identity/",
"/Billing",
"/api/",
"/stripe/",
"/hubs/",
"/Kiosk/",
"/Profile/Photo",
"/CompanyLogo",
"/AccountDataExport"
};
///
/// Initialises the middleware with the next delegate and a logger for
/// subscription-check errors.
///
public SubscriptionMiddleware(RequestDelegate next, ILogger logger)
{
_next = next;
_logger = logger;
}
///
/// Evaluates subscription status and sets feature-flag HttpContext.Items
/// entries for the current request.
///
/// and are
/// injected per-request (not via the constructor) because this middleware is
/// registered as a singleton — constructor injection of scoped services would
/// cause a captive-dependency bug.
///
///
/// The current HTTP context.
///
/// Scoped service that computes subscription expiry dates and grace periods.
///
///
/// Scoped service that resolves the authenticated user's company ID from claims.
///
public async Task InvokeAsync(HttpContext context, ISubscriptionService subscriptionService, ITenantContext tenantContext)
{
// Skip for unauthenticated users
if (context.User.Identity?.IsAuthenticated != true)
{
await _next(context);
return;
}
// SuperAdmins get all features but still need the feature flags set for views
if (context.User.IsInRole(AppConstants.Roles.SuperAdmin))
{
context.Items["AllowOnlinePayments"] = true;
context.Items["AllowAccounting"] = true;
context.Items["AllowSms"] = true;
await _next(context);
return;
}
// Skip specific paths
var path = context.Request.Path.Value ?? string.Empty;
if (ShouldSkip(path))
{
await _next(context);
return;
}
// Get current company
var companyId = tenantContext.GetCurrentCompanyId();
if (companyId == null)
{
await _next(context);
return;
}
try
{
var company = await GetCompanyAsync(context, companyId.Value);
if (company == null)
{
await _next(context);
return;
}
// Check if company is inactive
if (!company.IsActive)
{
context.Response.Redirect("/Billing/Inactive");
return;
}
// Resolve plan config (used for feature flags regardless of expiry state)
var unitOfWork = context.RequestServices.GetRequiredService();
var planConfig = await unitOfWork.SubscriptionPlanConfigs.FirstOrDefaultAsync(
p => p.Plan == company.SubscriptionPlan);
// Comped/internal companies get all features; per-company overrides take precedence over plan defaults
context.Items["AllowOnlinePayments"] = company.IsComped
|| (company.OnlinePaymentsOverride ?? (planConfig?.AllowOnlinePayments ?? false));
context.Items["AllowAccounting"] = company.IsComped
|| (company.AccountingOverride ?? (planConfig?.AllowAccounting ?? false));
// SMS: comped gets it; admin force-disable beats everything else; then plan; then company opt-in
context.Items["AllowSms"] = company.IsComped
|| (!company.SmsDisabledByAdmin && (planConfig?.AllowSms ?? false) && company.SmsEnabled);
if (company.IsComped)
{
await _next(context);
return;
}
// Check subscription expiry
var daysUntil = subscriptionService.DaysUntilExpiry(company);
if (daysUntil != null)
{
var platformSettings = context.RequestServices.GetRequiredService();
var isTrial = string.IsNullOrEmpty(company.StripeSubscriptionId);
var graceDays = 0;
if (!isTrial || await platformSettings.GetBoolAsync(PlatformSettingKeys.GracePeriodAppliesToTrials))
{
graceDays = await platformSettings.GetIntAsync(
PlatformSettingKeys.GracePeriodDays,
AppConstants.SubscriptionConstants.GracePeriodDays);
}
if (daysUntil < -graceDays)
{
// Past grace period — hard lockout
context.Response.Redirect("/Billing/Expired");
return;
}
if (daysUntil <= 0)
{
// In grace period — show warning banner
context.Items["SubscriptionDaysRemaining"] = daysUntil;
context.Items["SubscriptionPlan"] = company.SubscriptionPlan;
}
else if (daysUntil <= 14)
{
// Expiring soon — show warning banner
context.Items["SubscriptionDaysRemaining"] = daysUntil;
context.Items["SubscriptionPlan"] = company.SubscriptionPlan;
}
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error in SubscriptionMiddleware for company {CompanyId}", companyId);
}
await _next(context);
}
///
/// Returns true if the request path begins with any of the prefixes
/// in , meaning subscription enforcement should be
/// skipped for this request.
///
/// The request path value from HttpContext.Request.Path.
private static bool ShouldSkip(string path)
{
foreach (var skip in SkipPaths)
{
if (path.StartsWith(skip, StringComparison.OrdinalIgnoreCase))
return true;
}
return false;
}
///
/// Loads the company record from the database, bypassing global query filters
/// so that inactive/soft-deleted companies can still be retrieved — this is
/// necessary for the middleware to redirect inactive companies to the billing
/// page rather than returning a generic 404.
///
/// The current HTTP context (used for service resolution).
/// The tenant company's database ID.
/// The company entity, or null if not found.
private static async Task GetCompanyAsync(HttpContext context, int companyId)
{
var unitOfWork = context.RequestServices.GetRequiredService();
return await unitOfWork.Companies.GetByIdAsync(companyId, ignoreQueryFilters: true);
}
}