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>
165 lines
7.1 KiB
C#
165 lines
7.1 KiB
C#
using AutoMapper;
|
|
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using PowderCoating.Application.DTOs.Subscription;
|
|
using PowderCoating.Core.Interfaces;
|
|
using PowderCoating.Shared.Constants;
|
|
|
|
namespace PowderCoating.Web.Controllers;
|
|
|
|
/// <summary>
|
|
/// SuperAdmin-only interface for managing subscription plan configurations
|
|
/// (limits, pricing, feature flags, and Stripe price IDs). Changes here
|
|
/// affect every new tenant assignment of the plan and any quota checks that
|
|
/// read plan limits at runtime. Plan <c>DisplayName</c> and <c>Plan</c> enum
|
|
/// value are intentionally not editable here to prevent breaking existing company
|
|
/// subscription records that reference them by integer value.
|
|
/// </summary>
|
|
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
|
|
public class PlatformSubscriptionController : Controller
|
|
{
|
|
private readonly IUnitOfWork _unitOfWork;
|
|
private readonly ILogger<PlatformSubscriptionController> _logger;
|
|
|
|
public PlatformSubscriptionController(IUnitOfWork unitOfWork, ILogger<PlatformSubscriptionController> logger)
|
|
{
|
|
_unitOfWork = unitOfWork;
|
|
_logger = logger;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Lists all subscription plan configurations ordered by display sort order.
|
|
/// Uses <c>ignoreQueryFilters: true</c> because plan configs carry
|
|
/// <c>CompanyId = 0</c> and are otherwise hidden by the multi-tenancy filter.
|
|
/// Projects to <see cref="SubscriptionPlanConfigDto"/> to avoid exposing the
|
|
/// full entity (including internal Stripe secrets) to the view layer.
|
|
/// </summary>
|
|
[HttpGet]
|
|
public async Task<IActionResult> Index()
|
|
{
|
|
var configs = (await _unitOfWork.SubscriptionPlanConfigs.GetAllAsync(ignoreQueryFilters: true))
|
|
.OrderBy(c => c.SortOrder)
|
|
.ToList();
|
|
|
|
var dtos = configs.Select(c => new SubscriptionPlanConfigDto
|
|
{
|
|
Id = c.Id,
|
|
Plan = c.Plan,
|
|
DisplayName = c.DisplayName,
|
|
Description = c.Description,
|
|
MaxUsers = c.MaxUsers,
|
|
MaxActiveJobs = c.MaxActiveJobs,
|
|
MaxCustomers = c.MaxCustomers,
|
|
MaxQuotes = c.MaxQuotes,
|
|
MaxCatalogItems = c.MaxCatalogItems,
|
|
MaxJobPhotos = c.MaxJobPhotos,
|
|
MaxQuotePhotos = c.MaxQuotePhotos,
|
|
MaxAiPhotoQuotesPerMonth = c.MaxAiPhotoQuotesPerMonth,
|
|
MonthlyPrice = c.MonthlyPrice,
|
|
AnnualPrice = c.AnnualPrice,
|
|
StripePriceIdMonthly = c.StripePriceIdMonthly,
|
|
StripePriceIdAnnual = c.StripePriceIdAnnual,
|
|
AllowOnlinePayments = c.AllowOnlinePayments,
|
|
AllowAccounting = c.AllowAccounting,
|
|
AllowAiPhotoQuotes = c.AllowAiPhotoQuotes,
|
|
AllowAiInventoryAssist = c.AllowAiInventoryAssist,
|
|
AllowAiCatalogPriceCheck = c.AllowAiCatalogPriceCheck,
|
|
AllowSms = c.AllowSms,
|
|
IsActive = c.IsActive,
|
|
SortOrder = c.SortOrder
|
|
}).ToList();
|
|
|
|
return View(dtos);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns the Edit form for a plan config, loaded into an
|
|
/// <see cref="UpdateSubscriptionPlanConfigDto"/> to prevent over-posting of
|
|
/// immutable fields such as <c>Plan</c>, <c>DisplayName</c>, and <c>SortOrder</c>.
|
|
/// The plan display name is placed in ViewBag for the page heading.
|
|
/// </summary>
|
|
[HttpGet]
|
|
public async Task<IActionResult> Edit(int id)
|
|
{
|
|
var config = await _unitOfWork.SubscriptionPlanConfigs.GetByIdAsync(id, ignoreQueryFilters: true);
|
|
if (config == null) return NotFound();
|
|
|
|
var dto = new UpdateSubscriptionPlanConfigDto
|
|
{
|
|
Id = config.Id,
|
|
Description = config.Description,
|
|
MaxUsers = config.MaxUsers,
|
|
MaxActiveJobs = config.MaxActiveJobs,
|
|
MaxCustomers = config.MaxCustomers,
|
|
MaxQuotes = config.MaxQuotes,
|
|
MaxCatalogItems = config.MaxCatalogItems,
|
|
MaxJobPhotos = config.MaxJobPhotos,
|
|
MaxQuotePhotos = config.MaxQuotePhotos,
|
|
MaxAiPhotoQuotesPerMonth = config.MaxAiPhotoQuotesPerMonth,
|
|
MonthlyPrice = config.MonthlyPrice,
|
|
AnnualPrice = config.AnnualPrice,
|
|
StripePriceIdMonthly = config.StripePriceIdMonthly,
|
|
StripePriceIdAnnual = config.StripePriceIdAnnual,
|
|
AllowOnlinePayments = config.AllowOnlinePayments,
|
|
AllowAccounting = config.AllowAccounting,
|
|
AllowAiPhotoQuotes = config.AllowAiPhotoQuotes,
|
|
AllowAiInventoryAssist = config.AllowAiInventoryAssist,
|
|
AllowAiCatalogPriceCheck = config.AllowAiCatalogPriceCheck,
|
|
AllowSms = config.AllowSms,
|
|
IsActive = config.IsActive
|
|
};
|
|
|
|
ViewBag.PlanName = config.DisplayName;
|
|
return View(dto);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Applies the updated plan configuration values and saves. Uses explicit field
|
|
/// mapping from <paramref name="dto"/> to the tracked entity so that immutable
|
|
/// identity fields (<c>Plan</c>, <c>DisplayName</c>, <c>SortOrder</c>) are never
|
|
/// overwritten. Logs the change at Information level for the audit trail.
|
|
/// </summary>
|
|
[HttpPost]
|
|
[ValidateAntiForgeryToken]
|
|
public async Task<IActionResult> Edit(int id, UpdateSubscriptionPlanConfigDto dto)
|
|
{
|
|
if (!ModelState.IsValid)
|
|
{
|
|
var configForView = await _unitOfWork.SubscriptionPlanConfigs.GetByIdAsync(id, ignoreQueryFilters: true);
|
|
ViewBag.PlanName = configForView?.DisplayName ?? "Unknown";
|
|
return View(dto);
|
|
}
|
|
|
|
var config = await _unitOfWork.SubscriptionPlanConfigs.GetByIdAsync(id, ignoreQueryFilters: true);
|
|
if (config == null) return NotFound();
|
|
|
|
config.Description = dto.Description;
|
|
config.MaxUsers = dto.MaxUsers;
|
|
config.MaxActiveJobs = dto.MaxActiveJobs;
|
|
config.MaxCustomers = dto.MaxCustomers;
|
|
config.MaxQuotes = dto.MaxQuotes;
|
|
config.MaxCatalogItems = dto.MaxCatalogItems;
|
|
config.MaxJobPhotos = dto.MaxJobPhotos;
|
|
config.MaxQuotePhotos = dto.MaxQuotePhotos;
|
|
config.MaxAiPhotoQuotesPerMonth = dto.MaxAiPhotoQuotesPerMonth;
|
|
config.MonthlyPrice = dto.MonthlyPrice;
|
|
config.AnnualPrice = dto.AnnualPrice;
|
|
config.StripePriceIdMonthly = dto.StripePriceIdMonthly;
|
|
config.StripePriceIdAnnual = dto.StripePriceIdAnnual;
|
|
config.AllowOnlinePayments = dto.AllowOnlinePayments;
|
|
config.AllowAccounting = dto.AllowAccounting;
|
|
config.AllowAiPhotoQuotes = dto.AllowAiPhotoQuotes;
|
|
config.AllowAiInventoryAssist = dto.AllowAiInventoryAssist;
|
|
config.AllowAiCatalogPriceCheck = dto.AllowAiCatalogPriceCheck;
|
|
config.AllowSms = dto.AllowSms;
|
|
config.IsActive = dto.IsActive;
|
|
|
|
await _unitOfWork.SubscriptionPlanConfigs.UpdateAsync(config);
|
|
await _unitOfWork.CompleteAsync();
|
|
|
|
_logger.LogInformation("SuperAdmin updated subscription plan config: {Plan}", config.DisplayName);
|
|
TempData["Success"] = $"{config.DisplayName} plan configuration updated successfully.";
|
|
return RedirectToAction(nameof(Index));
|
|
}
|
|
}
|