279 lines
12 KiB
C#
279 lines
12 KiB
C#
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.Identity;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using PowderCoating.Application.DTOs.Subscription;
|
|
using PowderCoating.Application.Interfaces;
|
|
using PowderCoating.Core.Entities;
|
|
using PowderCoating.Core.Enums;
|
|
using PowderCoating.Core.Interfaces;
|
|
using PowderCoating.Shared.Constants;
|
|
|
|
namespace PowderCoating.Web.Controllers;
|
|
|
|
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
|
|
public class BillingController : Controller
|
|
{
|
|
private readonly ISubscriptionService _subscriptionService;
|
|
private readonly IStripeService _stripeService;
|
|
private readonly IUnitOfWork _unitOfWork;
|
|
private readonly ILogger<BillingController> _logger;
|
|
private readonly UserManager<ApplicationUser> _userManager;
|
|
private readonly SignInManager<ApplicationUser> _signInManager;
|
|
|
|
public BillingController(
|
|
ISubscriptionService subscriptionService,
|
|
IStripeService stripeService,
|
|
IUnitOfWork unitOfWork,
|
|
ILogger<BillingController> logger,
|
|
UserManager<ApplicationUser> userManager,
|
|
SignInManager<ApplicationUser> signInManager)
|
|
{
|
|
_subscriptionService = subscriptionService;
|
|
_stripeService = stripeService;
|
|
_unitOfWork = unitOfWork;
|
|
_logger = logger;
|
|
_userManager = userManager;
|
|
_signInManager = signInManager;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Displays the billing dashboard with current subscription status, per-resource usage vs limits, and available upgrade plans. Deactivated plans are omitted from the upgrade list except for the company's own active plan so existing subscribers always see their plan details.
|
|
/// </summary>
|
|
[HttpGet]
|
|
public async Task<IActionResult> Index()
|
|
{
|
|
var companyId = GetCompanyId();
|
|
if (companyId == 0) return RedirectToAction("Index", "Home");
|
|
|
|
var company = await _unitOfWork.Companies.GetByIdAsync(companyId, ignoreQueryFilters: true);
|
|
if (company == null) return NotFound();
|
|
|
|
var (userUsed, userMax) = await _subscriptionService.GetUserCountAsync(companyId);
|
|
var (jobUsed, jobMax) = await _subscriptionService.GetJobCountAsync(companyId);
|
|
var (customerUsed, customerMax) = await _subscriptionService.GetCustomerCountAsync(companyId);
|
|
var (quoteUsed, quoteMax) = await _subscriptionService.GetQuoteCountAsync(companyId);
|
|
var (catalogUsed, catalogMax) = await _subscriptionService.GetCatalogItemCountAsync(companyId);
|
|
var status = await _subscriptionService.GetStatusAsync(companyId);
|
|
var daysUntil = _subscriptionService.DaysUntilExpiry(company);
|
|
|
|
// Load active plans for upgrade options, but always include the company's current plan
|
|
// so existing subscribers can see their plan details even if it has been deactivated.
|
|
var currentPlan = company.SubscriptionPlan;
|
|
var planConfigs = (await _unitOfWork.SubscriptionPlanConfigs.FindAsync(
|
|
c => c.IsActive || c.Plan == currentPlan, ignoreQueryFilters: true))
|
|
.OrderBy(c => c.SortOrder)
|
|
.ToList();
|
|
|
|
var statusDto = new SubscriptionStatusDto(
|
|
Status: status,
|
|
Plan: company.SubscriptionPlan,
|
|
EndDate: company.SubscriptionEndDate,
|
|
DaysRemaining: daysUntil,
|
|
IsGracePeriod: status == SubscriptionStatus.GracePeriod,
|
|
IsExpired: status == SubscriptionStatus.Expired
|
|
);
|
|
|
|
var limitsDto = new PlanLimitsDto(
|
|
MaxUsers: userMax,
|
|
MaxActiveJobs: jobMax,
|
|
MaxCustomers: customerMax,
|
|
MaxQuotes: quoteMax,
|
|
MaxCatalogItems: catalogMax,
|
|
CurrentUsers: userUsed,
|
|
CurrentJobs: jobUsed,
|
|
CurrentCustomers: customerUsed,
|
|
CurrentQuotes: quoteUsed,
|
|
CurrentCatalogItems: catalogUsed,
|
|
Plan: company.SubscriptionPlan
|
|
);
|
|
|
|
ViewBag.StatusDto = statusDto;
|
|
ViewBag.LimitsDto = limitsDto;
|
|
ViewBag.PlanConfigs = planConfigs;
|
|
ViewBag.Company = company;
|
|
|
|
return View();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates a Stripe Checkout session and redirects the company admin to the Stripe-hosted payment page. Guards against upgrading to a deactivated plan via a crafted POST; Stripe configuration errors are surfaced verbatim to the admin (not the public) so they know exactly what to fix.
|
|
/// </summary>
|
|
[HttpPost]
|
|
[ValidateAntiForgeryToken]
|
|
public async Task<IActionResult> Checkout(int plan, bool isAnnual = false)
|
|
{
|
|
var companyId = GetCompanyId();
|
|
if (companyId == 0) return RedirectToAction("Index", "Home");
|
|
|
|
// Prevent upgrading TO a deactivated plan (e.g. via a crafted POST)
|
|
var targetConfig = await _unitOfWork.SubscriptionPlanConfigs.FirstOrDefaultAsync(
|
|
c => c.Plan == plan, ignoreQueryFilters: true);
|
|
if (targetConfig == null || !targetConfig.IsActive)
|
|
{
|
|
TempData["Error"] = "That subscription plan is no longer available. Please choose a different plan.";
|
|
return RedirectToAction(nameof(Index));
|
|
}
|
|
|
|
var successUrl = Url.Action("Success", "Billing", null, Request.Scheme)!;
|
|
var cancelUrl = Url.Action("Index", "Billing", null, Request.Scheme)!;
|
|
|
|
try
|
|
{
|
|
var checkoutUrl = await _stripeService.CreateCheckoutSessionAsync(
|
|
companyId, plan, isAnnual, successUrl, cancelUrl);
|
|
return Redirect(checkoutUrl);
|
|
}
|
|
catch (InvalidOperationException ex)
|
|
{
|
|
// Configuration problems — give the admin the exact message so they know what to fix
|
|
_logger.LogError(ex, "Stripe configuration error for company {CompanyId}", companyId);
|
|
TempData["Error"] = ex.Message;
|
|
return RedirectToAction(nameof(Index));
|
|
}
|
|
catch (Stripe.StripeException ex)
|
|
{
|
|
_logger.LogError(ex, "Stripe API error for company {CompanyId}: {StripeError}", companyId, ex.Message);
|
|
TempData["Error"] = "A payment processor error occurred. Please try again or contact support.";
|
|
return RedirectToAction(nameof(Index));
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Failed to create Stripe checkout session for company {CompanyId}", companyId);
|
|
TempData["Error"] = "Unable to start checkout. Please try again or contact support.";
|
|
return RedirectToAction(nameof(Index));
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Landing page after a successful Stripe Checkout. Fulfills the checkout server-side (updates subscription in DB) and refreshes the auth cookie so the new subscription plan claim is active immediately. Fulfillment errors are logged but do not block the success page since the Stripe webhook will retry.
|
|
/// </summary>
|
|
[HttpGet]
|
|
public async Task<IActionResult> Success(string? session_id)
|
|
{
|
|
if (!string.IsNullOrEmpty(session_id))
|
|
{
|
|
try
|
|
{
|
|
await _stripeService.FulfillCheckoutAsync(session_id);
|
|
await RefreshClaimsAsync();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Failed to fulfill checkout for session {SessionId}", session_id);
|
|
}
|
|
}
|
|
|
|
TempData["Success"] = "Your subscription has been updated successfully!";
|
|
return View();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Return URL when the user cancels out of Stripe Checkout. Simply redirects back to the billing dashboard without modifying any subscription data.
|
|
/// </summary>
|
|
[HttpGet]
|
|
public IActionResult Cancel()
|
|
{
|
|
return RedirectToAction(nameof(Index));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Full-page interstitial shown when a company's subscription has expired. AllowAnonymous because the middleware redirects here before the user can authenticate past the subscription guard.
|
|
/// </summary>
|
|
[HttpGet]
|
|
[AllowAnonymous]
|
|
public IActionResult Expired()
|
|
{
|
|
return View();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Full-page interstitial shown when a company account has been deactivated by a SuperAdmin. AllowAnonymous for the same reason as Expired — the auth pipeline redirects here before normal login completes.
|
|
/// </summary>
|
|
[HttpGet]
|
|
[AllowAnonymous]
|
|
public IActionResult Inactive()
|
|
{
|
|
return View();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Manually pulls the current subscription state from Stripe and updates the database. Useful when a webhook was missed or delayed; also refreshes the auth cookie so the corrected plan claim takes effect immediately.
|
|
/// </summary>
|
|
[HttpPost]
|
|
[ValidateAntiForgeryToken]
|
|
public async Task<IActionResult> SyncWithStripe()
|
|
{
|
|
var companyId = GetCompanyId();
|
|
if (companyId == 0) return RedirectToAction("Index", "Home");
|
|
|
|
try
|
|
{
|
|
await _stripeService.SyncSubscriptionAsync(companyId);
|
|
await RefreshClaimsAsync();
|
|
TempData["Success"] = "Subscription synced successfully from Stripe.";
|
|
}
|
|
catch (InvalidOperationException ex)
|
|
{
|
|
TempData["Error"] = ex.Message;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Failed to sync Stripe subscription for company {CompanyId}", companyId);
|
|
TempData["Error"] = "Unable to sync with Stripe. Please try again.";
|
|
}
|
|
|
|
return RedirectToAction(nameof(Index));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates a Stripe Customer Portal session and redirects the admin to the Stripe-hosted portal for self-service invoice history, payment method updates, and cancellation. Requires that a StripeCustomerId exists on the company record (set when the first subscription is created).
|
|
/// </summary>
|
|
[HttpPost]
|
|
[ValidateAntiForgeryToken]
|
|
public async Task<IActionResult> ManageBilling()
|
|
{
|
|
var companyId = GetCompanyId();
|
|
if (companyId == 0) return RedirectToAction("Index", "Home");
|
|
|
|
var company = await _unitOfWork.Companies.GetByIdAsync(companyId, ignoreQueryFilters: true);
|
|
if (company == null || string.IsNullOrEmpty(company.StripeCustomerId))
|
|
{
|
|
TempData["Error"] = "No billing account found. Please set up a subscription first.";
|
|
return RedirectToAction(nameof(Index));
|
|
}
|
|
|
|
var returnUrl = Url.Action("Index", "Billing", null, Request.Scheme)!;
|
|
try
|
|
{
|
|
var portalUrl = await _stripeService.CreateCustomerPortalSessionAsync(
|
|
company.StripeCustomerId, returnUrl);
|
|
return Redirect(portalUrl);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Failed to create Stripe portal session for company {CompanyId}", companyId);
|
|
TempData["Error"] = "Unable to open billing portal. Please try again.";
|
|
return RedirectToAction(nameof(Index));
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reads the CompanyId claim from the current user's auth cookie. Returns 0 when absent (e.g., SuperAdmin has no company) so callers can redirect away gracefully.
|
|
/// </summary>
|
|
private int GetCompanyId()
|
|
{
|
|
var claim = User.FindFirst("CompanyId")?.Value;
|
|
return int.TryParse(claim, out var id) ? id : 0;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Re-issues the auth cookie so claims such as SubscriptionPlan reflect the latest DB values. Called after any action that changes the subscription so the updated limits are enforced immediately without requiring a logout/login.
|
|
/// </summary>
|
|
private async Task RefreshClaimsAsync()
|
|
{
|
|
var user = await _userManager.GetUserAsync(User);
|
|
if (user != null)
|
|
await _signInManager.RefreshSignInAsync(user);
|
|
}
|
|
}
|