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); } }