6569d9c4ea
- Three-tier SMS gate: platform kill-switch → admin force-disable → plan AllowSms → company opt-in - CompanySmsAgreement entity records admin acceptance of TCPA terms with IP, user agent, and terms version - SMS terms of service modal on Company Settings with versioned re-agreement (AppConstants.SmsTermsVersion) - Dev redirect: non-production SMS routed to Twilio:DevRedirectPhone to protect real customer numbers - Removed redundant Ready for Pickup SMS (Job Completed covers it) - Role-based compose modal on job completion: Admin/Manager reviews and edits before send; ShopFloor auto-sends - Send SMS button on job details for ad-hoc messages (Admin/Manager only) - SendJobSmsAsync auto-appends STOP opt-out language if missing - Migrations: AddSmsGating, AddCompanySmsAgreement Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
215 lines
8.5 KiB
C#
215 lines
8.5 KiB
C#
using PowderCoating.Core.Interfaces;
|
|
using PowderCoating.Shared.Constants;
|
|
|
|
namespace PowderCoating.Web.Middleware;
|
|
|
|
/// <summary>
|
|
/// Per-request middleware that enforces subscription status and gates plan-specific
|
|
/// features for every authenticated non-SuperAdmin user.
|
|
/// <para>
|
|
/// On each request this middleware:
|
|
/// <list type="number">
|
|
/// <item>Sets <c>HttpContext.Items["AllowOnlinePayments"]</c> and
|
|
/// <c>HttpContext.Items["AllowAccounting"]</c> from the company's
|
|
/// subscription plan configuration (or per-company overrides).
|
|
/// Controllers and views read these flags via helper methods such as
|
|
/// <c>AllowAccounting()</c> to show/hide plan-gated UI elements.</item>
|
|
/// <item>Redirects inactive companies to <c>/Billing/Inactive</c>.</item>
|
|
/// <item>Redirects expired companies (past the grace period) to <c>/Billing/Expired</c>.</item>
|
|
/// <item>Sets <c>HttpContext.Items["SubscriptionDaysRemaining"]</c> for companies
|
|
/// within the expiry warning window (≤ 14 days) so the layout can render
|
|
/// a countdown banner.</item>
|
|
/// </list>
|
|
/// </para>
|
|
/// <para>
|
|
/// SuperAdmins bypass subscription enforcement entirely but still receive the
|
|
/// feature-flag <c>Items</c> entries (set to <c>true</c>) so that views relying
|
|
/// on those flags render correctly when a SuperAdmin impersonates a tenant.
|
|
/// </para>
|
|
/// </summary>
|
|
public class SubscriptionMiddleware
|
|
{
|
|
private readonly RequestDelegate _next;
|
|
private readonly ILogger<SubscriptionMiddleware> _logger;
|
|
|
|
/// <summary>
|
|
/// 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).
|
|
/// </summary>
|
|
private static readonly string[] SkipPaths =
|
|
{
|
|
"/Identity/",
|
|
"/Billing",
|
|
"/api/",
|
|
"/stripe/",
|
|
"/Profile/Photo",
|
|
"/CompanyLogo",
|
|
"/AccountDataExport"
|
|
};
|
|
|
|
/// <summary>
|
|
/// Initialises the middleware with the next delegate and a logger for
|
|
/// subscription-check errors.
|
|
/// </summary>
|
|
public SubscriptionMiddleware(RequestDelegate next, ILogger<SubscriptionMiddleware> logger)
|
|
{
|
|
_next = next;
|
|
_logger = logger;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Evaluates subscription status and sets feature-flag <c>HttpContext.Items</c>
|
|
/// entries for the current request.
|
|
/// <para>
|
|
/// <see cref="ISubscriptionService"/> and <see cref="ITenantContext"/> 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.
|
|
/// </para>
|
|
/// </summary>
|
|
/// <param name="context">The current HTTP context.</param>
|
|
/// <param name="subscriptionService">
|
|
/// Scoped service that computes subscription expiry dates and grace periods.
|
|
/// </param>
|
|
/// <param name="tenantContext">
|
|
/// Scoped service that resolves the authenticated user's company ID from claims.
|
|
/// </param>
|
|
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<IUnitOfWork>();
|
|
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)
|
|
{
|
|
if (daysUntil < -AppConstants.SubscriptionConstants.GracePeriodDays)
|
|
{
|
|
// 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns <c>true</c> if the request path begins with any of the prefixes
|
|
/// in <see cref="SkipPaths"/>, meaning subscription enforcement should be
|
|
/// skipped for this request.
|
|
/// </summary>
|
|
/// <param name="path">The request path value from <c>HttpContext.Request.Path</c>.</param>
|
|
private static bool ShouldSkip(string path)
|
|
{
|
|
foreach (var skip in SkipPaths)
|
|
{
|
|
if (path.StartsWith(skip, StringComparison.OrdinalIgnoreCase))
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
/// <param name="context">The current HTTP context (used for service resolution).</param>
|
|
/// <param name="companyId">The tenant company's database ID.</param>
|
|
/// <returns>The company entity, or <c>null</c> if not found.</returns>
|
|
private static async Task<Core.Entities.Company?> GetCompanyAsync(HttpContext context, int companyId)
|
|
{
|
|
var unitOfWork = context.RequestServices.GetRequiredService<IUnitOfWork>();
|
|
return await unitOfWork.Companies.GetByIdAsync(companyId, ignoreQueryFilters: true);
|
|
}
|
|
}
|