6c2fe6e1c4
- New EmployeeClockEntry entity (facility-level attendance, separate from job time entries) - KioskPin added to ApplicationUser; TimeclockKioskToken added to Company - TimeclockController: clock in/out, who's in, 14-day history, manager edit/delete, tablet kiosk with device-cookie auth, PIN management via Users edit page - Kiosk UI: employee tile grid + 4-digit PIN pad + auto-detect clock-in vs clock-out - Attendance report at /Reports/Attendance with weekly subtotal rows - Payroll CSV export at /Reports/AttendanceCsv (flat, one row per segment) - AllowCustomFormulas wired through PlatformSubscriptionController + subscription views - Fix soft-delete bug on CustomItemTemplate (missing HasQueryFilter in OnModelCreating) - Help article (Help/Timeclock.cshtml) and AI knowledge base updated - Migrations: AddEmployeeTimeclock, AddTimeclockKioskToken Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
638 lines
32 KiB
C#
638 lines
32 KiB
C#
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using PowderCoating.Core.Enums;
|
|
using PowderCoating.Infrastructure.Data;
|
|
using PowderCoating.Shared.Constants;
|
|
using Stripe;
|
|
using System.Text;
|
|
|
|
namespace PowderCoating.Web.Controllers;
|
|
|
|
/// <summary>
|
|
/// SuperAdmin-only controller for viewing and managing tenant company subscriptions.
|
|
/// Provides a paginated/filterable company list, per-company subscription editing,
|
|
/// trial extension, AI feature flag toggling, bulk operations, and Stripe payment history lookup.
|
|
/// All queries use <c>IgnoreQueryFilters()</c> to bypass the global EF multi-tenancy filter,
|
|
/// which would otherwise scope results to a single company context.
|
|
/// Changes are persisted directly via <see cref="ApplicationDbContext"/> (not IUnitOfWork)
|
|
/// because subscription management is a platform-level concern, not a tenant-domain concern,
|
|
/// and requires direct access to the Company entity which lives outside the tenant data layer.
|
|
/// </summary>
|
|
// Intentional exception: cross-tenant Company management with raw SQL audit log writes that bypass the tenant pipeline; platform-level concern outside IUnitOfWork scope. See docs/DATA_ACCESS_ARCHITECTURE.md — Permanent Exceptions.
|
|
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
|
|
public class SubscriptionManagementController : Controller
|
|
{
|
|
private readonly ApplicationDbContext _db;
|
|
private readonly IConfiguration _configuration;
|
|
private readonly ILogger<SubscriptionManagementController> _logger;
|
|
|
|
public SubscriptionManagementController(
|
|
ApplicationDbContext db,
|
|
IConfiguration configuration,
|
|
ILogger<SubscriptionManagementController> logger)
|
|
{
|
|
_db = db;
|
|
_configuration = configuration;
|
|
_logger = logger;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Displays the paginated, sortable, filterable list of all tenant companies with their
|
|
/// current subscription state. Also loads all active <c>SubscriptionPlanConfig</c> records
|
|
/// so the view can render human-readable plan names in the filter dropdown and data table.
|
|
/// Page size is whitelisted to {10, 25, 50, 100} — any other submitted value is normalised
|
|
/// to 25 to prevent memory exhaustion from arbitrarily large page requests.
|
|
/// <c>IgnoreQueryFilters()</c> is required because deleted companies should be invisible
|
|
/// here (excluded by explicit <c>!c.IsDeleted</c>) while active companies from all tenants
|
|
/// must all be visible to the SuperAdmin, bypassing the single-tenant global filter.
|
|
/// </summary>
|
|
/// <param name="search">Optional text filter applied to company name, email, and Stripe customer ID.</param>
|
|
/// <param name="status">Optional <see cref="SubscriptionStatus"/> enum name to filter by.</param>
|
|
/// <param name="plan">Optional subscription plan integer to filter by.</param>
|
|
/// <param name="sortCol">Column name to sort by (default: "CompanyName").</param>
|
|
/// <param name="sortDir">Sort direction: "asc" or "desc" (default: "asc").</param>
|
|
/// <param name="page">1-based page number (minimum 1).</param>
|
|
/// <param name="pageSize">Rows per page; must be 10, 25, 50, or 100.</param>
|
|
public async Task<IActionResult> Index(
|
|
string? search,
|
|
string? status,
|
|
string? plan,
|
|
string sortCol = "CompanyName",
|
|
string sortDir = "asc",
|
|
int page = 1,
|
|
int pageSize = 25)
|
|
{
|
|
pageSize = pageSize is 10 or 25 or 50 or 100 ? pageSize : 25;
|
|
page = Math.Max(1, page);
|
|
|
|
var query = _db.Companies.AsNoTracking().IgnoreQueryFilters().Where(c => !c.IsDeleted);
|
|
|
|
if (!string.IsNullOrWhiteSpace(search))
|
|
query = query.Where(c =>
|
|
c.CompanyName.Contains(search) ||
|
|
(c.PrimaryContactEmail != null && c.PrimaryContactEmail.Contains(search)) ||
|
|
(c.StripeCustomerId != null && c.StripeCustomerId.Contains(search)));
|
|
|
|
if (!string.IsNullOrWhiteSpace(status) && Enum.TryParse<SubscriptionStatus>(status, out var statusEnum))
|
|
query = query.Where(c => c.SubscriptionStatus == statusEnum);
|
|
|
|
if (!string.IsNullOrWhiteSpace(plan) && int.TryParse(plan, out var planInt))
|
|
query = query.Where(c => c.SubscriptionPlan == planInt);
|
|
|
|
query = (sortCol, sortDir) switch
|
|
{
|
|
("CompanyName", "desc") => query.OrderByDescending(c => c.CompanyName),
|
|
("Plan", "asc") => query.OrderBy(c => c.SubscriptionPlan),
|
|
("Plan", "desc") => query.OrderByDescending(c => c.SubscriptionPlan),
|
|
("Status", "asc") => query.OrderBy(c => c.SubscriptionStatus),
|
|
("Status", "desc") => query.OrderByDescending(c => c.SubscriptionStatus),
|
|
("EndDate", "asc") => query.OrderBy(c => c.SubscriptionEndDate),
|
|
("EndDate", "desc") => query.OrderByDescending(c => c.SubscriptionEndDate),
|
|
("Active", "asc") => query.OrderBy(c => c.IsActive),
|
|
("Active", "desc") => query.OrderByDescending(c => c.IsActive),
|
|
_ => query.OrderBy(c => c.CompanyName)
|
|
};
|
|
|
|
var totalCount = await query.CountAsync();
|
|
var companies = await query.Skip((page - 1) * pageSize).Take(pageSize).ToListAsync();
|
|
|
|
var planConfigs = await _db.SubscriptionPlanConfigs.AsNoTracking().IgnoreQueryFilters()
|
|
.Where(p => p.IsActive).OrderBy(p => p.SortOrder).ToListAsync();
|
|
|
|
ViewBag.PlanConfigs = planConfigs;
|
|
ViewBag.Search = search;
|
|
ViewBag.StatusFilter = status;
|
|
ViewBag.PlanFilter = plan;
|
|
ViewBag.SortCol = sortCol;
|
|
ViewBag.SortDir = sortDir;
|
|
ViewBag.Page = page;
|
|
ViewBag.PageSize = pageSize;
|
|
ViewBag.TotalCount = totalCount;
|
|
ViewBag.TotalPages = (int)Math.Ceiling(totalCount / (double)pageSize);
|
|
|
|
return View(companies);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Renders the per-company subscription management detail page.
|
|
/// Loads current usage counts (users, jobs, customers) so the SuperAdmin can see whether
|
|
/// the company is approaching plan limits before adjusting the subscription.
|
|
/// Also loads all active plan configs so the plan dropdown is populated correctly.
|
|
/// </summary>
|
|
/// <param name="id">Primary key of the company to manage.</param>
|
|
public async Task<IActionResult> Manage(int id)
|
|
{
|
|
var company = await _db.Companies.IgnoreQueryFilters().FirstOrDefaultAsync(c => c.Id == id);
|
|
if (company == null) return NotFound();
|
|
|
|
var planConfigs = await _db.SubscriptionPlanConfigs.AsNoTracking().IgnoreQueryFilters()
|
|
.Where(p => p.IsActive).OrderBy(p => p.SortOrder).ToListAsync();
|
|
|
|
var userCount = await _db.Users.CountAsync(u => u.CompanyId == id);
|
|
var jobCount = await _db.Jobs.IgnoreQueryFilters().CountAsync(j => j.CompanyId == id && !j.IsDeleted);
|
|
var customerCount = await _db.Customers.IgnoreQueryFilters().CountAsync(c => c.CompanyId == id && !c.IsDeleted);
|
|
|
|
ViewBag.PlanConfigs = planConfigs;
|
|
ViewBag.UserCount = userCount;
|
|
ViewBag.JobCount = jobCount;
|
|
ViewBag.CustomerCount = customerCount;
|
|
|
|
return View(company);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Saves subscription changes for a single company and writes a manual entry to the
|
|
/// <c>AuditLogs</c> table describing exactly what changed.
|
|
/// Per-entity limit overrides are stored as nullable integers; a form value of 0 is
|
|
/// converted to <c>null</c> via <see cref="NullIfZero"/> so the system falls back to the
|
|
/// plan default rather than enforcing a hard zero limit.
|
|
/// The audit log is written via raw SQL (not EF) to avoid routing audit writes through
|
|
/// the multi-tenant <c>ApplicationDbContext</c> save pipeline, which applies global filters
|
|
/// and could inadvertently scope the log entry to the wrong tenant.
|
|
/// </summary>
|
|
/// <param name="id">Primary key of the company to update.</param>
|
|
/// <param name="subscriptionPlan">New plan integer identifier.</param>
|
|
/// <param name="subscriptionStatus">New <see cref="SubscriptionStatus"/> value.</param>
|
|
/// <param name="isActive">Whether the company account is active.</param>
|
|
/// <param name="isComped">Whether the company receives complimentary (free) access.</param>
|
|
/// <param name="subscriptionEndDate">New subscription end/expiry date.</param>
|
|
/// <param name="subscriptionNotes">Internal notes visible only to SuperAdmins.</param>
|
|
/// <param name="notes">Reason for this change; appended to the audit log entry.</param>
|
|
/// <param name="maxUsersOverride">Per-company user limit override; 0 = use plan default.</param>
|
|
/// <param name="maxActiveJobsOverride">Per-company active jobs limit override; 0 = use plan default.</param>
|
|
/// <param name="maxCustomersOverride">Per-company customer limit override; 0 = use plan default.</param>
|
|
/// <param name="maxQuotesOverride">Per-company monthly quote limit override; 0 = use plan default.</param>
|
|
/// <param name="maxCatalogItemsOverride">Per-company catalog item limit override; 0 = use plan default.</param>
|
|
/// <param name="maxJobPhotosOverride">Per-company job photo limit override; 0 = use plan default.</param>
|
|
/// <param name="maxQuotePhotosOverride">Per-company quote photo limit override; 0 = use plan default.</param>
|
|
[HttpPost, ValidateAntiForgeryToken]
|
|
public async Task<IActionResult> UpdateSubscription(int id,
|
|
int subscriptionPlan,
|
|
SubscriptionStatus subscriptionStatus,
|
|
bool isActive,
|
|
bool isComped,
|
|
DateTime? subscriptionEndDate,
|
|
string? subscriptionNotes,
|
|
string? notes,
|
|
int? maxUsersOverride,
|
|
int? maxActiveJobsOverride,
|
|
int? maxCustomersOverride,
|
|
int? maxQuotesOverride,
|
|
int? maxCatalogItemsOverride,
|
|
int? maxJobPhotosOverride,
|
|
int? maxQuotePhotosOverride)
|
|
{
|
|
var company = await _db.Companies.IgnoreQueryFilters().FirstOrDefaultAsync(c => c.Id == id);
|
|
if (company == null) return NotFound();
|
|
|
|
var userName = User.Identity?.Name ?? "SuperAdmin";
|
|
|
|
// Build audit description of changes
|
|
var changes = new List<string>();
|
|
if (company.SubscriptionPlan != subscriptionPlan)
|
|
changes.Add($"Plan: {company.SubscriptionPlan} → {subscriptionPlan}");
|
|
if (company.SubscriptionStatus != subscriptionStatus)
|
|
changes.Add($"Status: {company.SubscriptionStatus} → {subscriptionStatus}");
|
|
if (company.IsActive != isActive)
|
|
changes.Add($"Active: {company.IsActive} → {isActive}");
|
|
if (company.IsComped != isComped)
|
|
changes.Add($"Comped: {company.IsComped} → {isComped}");
|
|
if (company.SubscriptionEndDate != subscriptionEndDate)
|
|
changes.Add($"EndDate: {company.SubscriptionEndDate:d} → {subscriptionEndDate:d}");
|
|
|
|
company.SubscriptionPlan = subscriptionPlan;
|
|
company.SubscriptionStatus = subscriptionStatus;
|
|
company.IsActive = isActive;
|
|
company.IsComped = isComped;
|
|
company.SubscriptionEndDate = subscriptionEndDate;
|
|
company.SubscriptionNotes = subscriptionNotes;
|
|
company.MaxUsersOverride = NullIfZero(maxUsersOverride);
|
|
company.MaxActiveJobsOverride = NullIfZero(maxActiveJobsOverride);
|
|
company.MaxCustomersOverride = NullIfZero(maxCustomersOverride);
|
|
company.MaxQuotesOverride = NullIfZero(maxQuotesOverride);
|
|
company.MaxCatalogItemsOverride = NullIfZero(maxCatalogItemsOverride);
|
|
company.MaxJobPhotosOverride = NullIfZero(maxJobPhotosOverride);
|
|
company.MaxQuotePhotosOverride = NullIfZero(maxQuotePhotosOverride);
|
|
company.UpdatedAt = DateTime.UtcNow;
|
|
company.UpdatedBy = userName;
|
|
|
|
await _db.SaveChangesAsync();
|
|
|
|
// Write a manual audit entry
|
|
if (changes.Count > 0)
|
|
{
|
|
var summary = string.Join("; ", changes);
|
|
if (!string.IsNullOrWhiteSpace(notes)) summary += $". Notes: {notes}";
|
|
|
|
await _db.Database.ExecuteSqlRawAsync("""
|
|
INSERT INTO AuditLogs (UserId, UserName, CompanyId, CompanyName, Action, EntityType, EntityId,
|
|
EntityDescription, OldValues, NewValues, IpAddress, Timestamp)
|
|
VALUES ({0},{1},{2},{3},{4},{5},{6},{7},{8},{9},{10},{11})
|
|
""",
|
|
(object?)User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value,
|
|
userName,
|
|
(object?)null,
|
|
(object?)null,
|
|
"ManualChange",
|
|
"Company",
|
|
id.ToString(),
|
|
company.CompanyName,
|
|
(object?)null,
|
|
summary,
|
|
(object?)HttpContext.Connection.RemoteIpAddress?.ToString(),
|
|
DateTime.UtcNow);
|
|
}
|
|
|
|
TempData["Success"] = $"Subscription updated for {company.CompanyName}.";
|
|
return RedirectToAction(nameof(Manage), new { id });
|
|
}
|
|
|
|
/// <summary>
|
|
/// Extends a single company's subscription end date by the specified number of days and
|
|
/// ensures the account is marked Active with a status of <see cref="SubscriptionStatus.Active"/>.
|
|
/// Extension is calculated from the later of the existing end date or now, so adding days
|
|
/// to an already-future date compounds correctly rather than resetting to today + N days.
|
|
/// This covers common support scenarios such as granting a grace period to a customer whose
|
|
/// payment is late but who has contacted support.
|
|
/// </summary>
|
|
/// <param name="id">Primary key of the company to extend.</param>
|
|
/// <param name="days">Number of days to add to the current end date (or today if already expired).</param>
|
|
[HttpPost, ValidateAntiForgeryToken]
|
|
public async Task<IActionResult> ExtendTrial(int id, int days)
|
|
{
|
|
var company = await _db.Companies.IgnoreQueryFilters().FirstOrDefaultAsync(c => c.Id == id);
|
|
if (company == null) return NotFound();
|
|
|
|
var baseDate = company.SubscriptionEndDate.HasValue && company.SubscriptionEndDate > DateTime.UtcNow
|
|
? company.SubscriptionEndDate.Value
|
|
: DateTime.UtcNow;
|
|
|
|
company.SubscriptionEndDate = baseDate.AddDays(days);
|
|
company.SubscriptionStatus = SubscriptionStatus.Active;
|
|
company.IsActive = true;
|
|
company.UpdatedAt = DateTime.UtcNow;
|
|
company.UpdatedBy = User.Identity?.Name;
|
|
|
|
await _db.SaveChangesAsync();
|
|
|
|
TempData["Success"] = $"Extended subscription by {days} days. New end date: {company.SubscriptionEndDate:d}.";
|
|
return RedirectToAction(nameof(Manage), new { id });
|
|
}
|
|
|
|
/// <summary>
|
|
/// AJAX endpoint that toggles a named boolean feature on a <c>SubscriptionPlanConfig</c> record.
|
|
/// Currently only <c>AllowOnlinePayments</c> is supported; unknown feature names return
|
|
/// <c>400 Bad Request</c> to prevent silent no-ops from misspelled feature identifiers.
|
|
/// Returns JSON so the Manage view can update the UI toggle without a full page reload.
|
|
/// </summary>
|
|
/// <param name="planId">Primary key of the <c>SubscriptionPlanConfig</c> to update.</param>
|
|
/// <param name="feature">Feature name string (e.g. "AllowOnlinePayments").</param>
|
|
/// <param name="enabled">New boolean value for the feature flag.</param>
|
|
[HttpPost, ValidateAntiForgeryToken]
|
|
public async Task<IActionResult> TogglePlanFeature(int planId, string feature, bool enabled)
|
|
{
|
|
var config = await _db.SubscriptionPlanConfigs.IgnoreQueryFilters()
|
|
.FirstOrDefaultAsync(p => p.Id == planId);
|
|
if (config == null) return NotFound();
|
|
|
|
switch (feature)
|
|
{
|
|
case "AllowOnlinePayments":
|
|
config.AllowOnlinePayments = enabled;
|
|
break;
|
|
case "AllowCustomFormulas":
|
|
config.AllowCustomFormulas = enabled;
|
|
break;
|
|
default:
|
|
return BadRequest("Unknown feature.");
|
|
}
|
|
|
|
config.UpdatedAt = DateTime.UtcNow;
|
|
_db.Update(config);
|
|
await _db.SaveChangesAsync();
|
|
|
|
_logger.LogInformation("Plan {PlanId} ({Name}): {Feature} set to {Value} by {User}",
|
|
planId, config.DisplayName, feature, enabled, User.Identity?.Name);
|
|
|
|
return Json(new { success = true });
|
|
}
|
|
|
|
// ── Bulk Operations ───────────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Extends the subscription end date for multiple companies in a single operation,
|
|
/// applying the same day-extension logic as <see cref="ExtendTrial"/>: dates in the future
|
|
/// are extended from their current end date; expired dates are extended from today.
|
|
/// Validates that at least one company is selected and that the day count is positive
|
|
/// before issuing any database writes, to prevent accidental empty or no-op submissions.
|
|
/// </summary>
|
|
/// <param name="ids">Array of company primary keys to extend.</param>
|
|
/// <param name="days">Number of days to add to each company's end date.</param>
|
|
[HttpPost, ValidateAntiForgeryToken]
|
|
public async Task<IActionResult> BulkExtendTrial(int[] ids, int days)
|
|
{
|
|
if (ids.Length == 0 || days <= 0)
|
|
{
|
|
TempData["Error"] = "Select at least one company and a valid number of days.";
|
|
return RedirectToAction(nameof(Index));
|
|
}
|
|
|
|
var companies = await _db.Companies.IgnoreQueryFilters()
|
|
.Where(c => ids.Contains(c.Id)).ToListAsync();
|
|
|
|
foreach (var company in companies)
|
|
{
|
|
var baseDate = company.SubscriptionEndDate.HasValue && company.SubscriptionEndDate > DateTime.UtcNow
|
|
? company.SubscriptionEndDate.Value
|
|
: DateTime.UtcNow;
|
|
company.SubscriptionEndDate = baseDate.AddDays(days);
|
|
company.SubscriptionStatus = SubscriptionStatus.Active;
|
|
company.IsActive = true;
|
|
company.UpdatedAt = DateTime.UtcNow;
|
|
company.UpdatedBy = User.Identity?.Name;
|
|
}
|
|
|
|
await _db.SaveChangesAsync();
|
|
TempData["Success"] = $"Extended {companies.Count} company subscription(s) by {days} days.";
|
|
return RedirectToAction(nameof(Index));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Activates or deactivates multiple companies in a single batch.
|
|
/// Deactivating a company prevents its users from logging in (the subscription middleware
|
|
/// and login pipeline check <c>Company.IsActive</c>).
|
|
/// Requires at least one company to be selected; an empty array returns an error to prevent
|
|
/// accidental empty-selection submissions from having no visible effect.
|
|
/// </summary>
|
|
/// <param name="ids">Array of company primary keys to toggle.</param>
|
|
/// <param name="active">Target active state: <c>true</c> to activate, <c>false</c> to deactivate.</param>
|
|
[HttpPost, ValidateAntiForgeryToken]
|
|
public async Task<IActionResult> BulkToggleActive(int[] ids, bool active)
|
|
{
|
|
if (ids.Length == 0)
|
|
{
|
|
TempData["Error"] = "Select at least one company.";
|
|
return RedirectToAction(nameof(Index));
|
|
}
|
|
|
|
var companies = await _db.Companies.IgnoreQueryFilters()
|
|
.Where(c => ids.Contains(c.Id)).ToListAsync();
|
|
|
|
foreach (var company in companies)
|
|
{
|
|
company.IsActive = active;
|
|
company.UpdatedAt = DateTime.UtcNow;
|
|
company.UpdatedBy = User.Identity?.Name;
|
|
}
|
|
|
|
await _db.SaveChangesAsync();
|
|
TempData["Success"] = $"{(active ? "Activated" : "Deactivated")} {companies.Count} company(ies).";
|
|
return RedirectToAction(nameof(Index));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Exports the company subscription list to a CSV file, applying the same search, status,
|
|
/// and plan filters as the <see cref="Index"/> view but without pagination.
|
|
/// Plan names are resolved from the <c>SubscriptionPlanConfig</c> lookup table so the CSV
|
|
/// contains human-readable plan names (e.g. "Professional") rather than raw integer plan codes.
|
|
/// The CSV is returned as a byte array (not a stream) to avoid partial-write issues on slow clients.
|
|
/// </summary>
|
|
/// <param name="search">Optional text filter applied to company name and email.</param>
|
|
/// <param name="status">Optional <see cref="SubscriptionStatus"/> enum name to filter by.</param>
|
|
/// <param name="plan">Optional subscription plan integer to filter by.</param>
|
|
public async Task<IActionResult> ExportCsv(string? search, string? status, string? plan)
|
|
{
|
|
var query = _db.Companies.AsNoTracking().IgnoreQueryFilters().Where(c => !c.IsDeleted);
|
|
|
|
if (!string.IsNullOrWhiteSpace(search))
|
|
query = query.Where(c => c.CompanyName.Contains(search) ||
|
|
(c.PrimaryContactEmail != null && c.PrimaryContactEmail.Contains(search)));
|
|
|
|
if (!string.IsNullOrWhiteSpace(status) && Enum.TryParse<SubscriptionStatus>(status, out var statusEnum))
|
|
query = query.Where(c => c.SubscriptionStatus == statusEnum);
|
|
|
|
if (!string.IsNullOrWhiteSpace(plan) && int.TryParse(plan, out var planInt))
|
|
query = query.Where(c => c.SubscriptionPlan == planInt);
|
|
|
|
var planConfigs = await _db.SubscriptionPlanConfigs.AsNoTracking().IgnoreQueryFilters()
|
|
.Where(p => p.IsActive).ToListAsync();
|
|
var planNames = planConfigs.ToDictionary(p => p.Plan, p => p.DisplayName);
|
|
|
|
var companies = await query.OrderBy(c => c.CompanyName).ToListAsync();
|
|
|
|
var sb = new StringBuilder();
|
|
sb.AppendLine("Company,Contact,Email,Phone,Plan,Status,Active,Comped,End Date,Stripe Customer,Created");
|
|
foreach (var c in companies)
|
|
{
|
|
sb.AppendLine(string.Join(",",
|
|
CsvEscape(c.CompanyName),
|
|
CsvEscape(c.PrimaryContactName),
|
|
CsvEscape(c.PrimaryContactEmail),
|
|
CsvEscape(c.Phone ?? ""),
|
|
CsvEscape(planNames.GetValueOrDefault(c.SubscriptionPlan, c.SubscriptionPlan.ToString())),
|
|
c.SubscriptionStatus.ToString(),
|
|
c.IsActive ? "Yes" : "No",
|
|
c.IsComped ? "Yes" : "No",
|
|
c.SubscriptionEndDate.HasValue ? c.SubscriptionEndDate.Value.ToString("MM/dd/yyyy") : "",
|
|
CsvEscape(c.StripeCustomerId ?? ""),
|
|
c.CreatedAt.ToString("MM/dd/yyyy")));
|
|
}
|
|
|
|
var bytes = Encoding.UTF8.GetBytes(sb.ToString());
|
|
return File(bytes, "text/csv", $"companies-{DateTime.UtcNow:yyyyMMdd}.csv");
|
|
}
|
|
|
|
/// <summary>
|
|
/// RFC 4180-compliant CSV field escaper for string values.
|
|
/// Wraps the value in double-quotes and escapes embedded double-quotes when the value
|
|
/// contains a comma, double-quote, or newline. Unlike the overload in the export controllers,
|
|
/// this version accepts only non-null strings because all callers in this controller
|
|
/// guarantee non-null input (using null-coalescing before calling).
|
|
/// </summary>
|
|
private static string CsvEscape(string value) =>
|
|
value.Contains(',') || value.Contains('"') || value.Contains('\n')
|
|
? $"\"{value.Replace("\"", "\"\"")}\""
|
|
: value;
|
|
|
|
/// <summary>
|
|
/// Updates per-company AI feature flags for a single tenant company.
|
|
/// Feature flags control which AI capabilities are enabled beyond the plan default
|
|
/// (e.g., AI photo quoting, AI inventory assist). The monthly photo quote override allows
|
|
/// granting a specific company more AI calls than their plan tier normally permits.
|
|
/// Zero is treated as "use plan default" via <see cref="NullIfZero"/>, matching the same
|
|
/// convention used for the limit overrides in <see cref="UpdateSubscription"/>.
|
|
/// </summary>
|
|
/// <param name="id">Primary key of the company to update.</param>
|
|
/// <param name="aiPhotoQuotesEnabled">Whether AI photo quoting is enabled for this company.</param>
|
|
/// <param name="aiInventoryAssistEnabled">Whether AI inventory assistance is enabled for this company.</param>
|
|
/// <param name="aiCatalogPriceCheckEnabled">Whether AI catalog price check is enabled for this company.</param>
|
|
/// <param name="maxAiPhotoQuotesPerMonthOverride">Monthly AI photo quote limit override; 0 = plan default.</param>
|
|
[HttpPost, ValidateAntiForgeryToken]
|
|
public async Task<IActionResult> UpdateFeatureFlags(
|
|
int id,
|
|
bool aiPhotoQuotesEnabled,
|
|
bool aiInventoryAssistEnabled,
|
|
bool aiCatalogPriceCheckEnabled,
|
|
int? maxAiPhotoQuotesPerMonthOverride)
|
|
{
|
|
var company = await _db.Companies.IgnoreQueryFilters().FirstOrDefaultAsync(c => c.Id == id);
|
|
if (company == null) return NotFound();
|
|
|
|
company.AiPhotoQuotesEnabled = aiPhotoQuotesEnabled;
|
|
company.AiInventoryAssistEnabled = aiInventoryAssistEnabled;
|
|
company.AiCatalogPriceCheckEnabled = aiCatalogPriceCheckEnabled;
|
|
company.MaxAiPhotoQuotesPerMonthOverride = NullIfZero(maxAiPhotoQuotesPerMonthOverride);
|
|
company.UpdatedAt = DateTime.UtcNow;
|
|
company.UpdatedBy = User.Identity?.Name;
|
|
|
|
await _db.SaveChangesAsync();
|
|
|
|
TempData["Success"] = $"Feature flags updated for {company.CompanyName}.";
|
|
return RedirectToAction(nameof(Manage), new { id });
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns the last 24 Stripe charges for a company's Stripe customer as JSON,
|
|
/// allowing the Manage view to render a payment history panel (with refund buttons) via AJAX.
|
|
/// Charges are used rather than invoices because in Stripe.net 50.x the Invoice object no
|
|
/// longer exposes <c>PaymentIntentId</c> or a direct <c>Charge</c> reference — the new
|
|
/// <c>Invoice.Payments</c> collection requires additional expansion round-trips.
|
|
/// The Charge object has <c>Amount</c>, <c>AmountRefunded</c>, <c>ReceiptUrl</c>, and
|
|
/// <c>Id</c> (the charge ID used as the refund target), which is everything the refund
|
|
/// workflow needs. The API key is read at call time to support key rotation without a restart.
|
|
/// Returns a JSON error object (not an HTTP error status) on failures.
|
|
/// </summary>
|
|
/// <param name="id">Primary key of the company whose Stripe charge history to fetch.</param>
|
|
public async Task<IActionResult> PaymentHistory(int id)
|
|
{
|
|
var company = await _db.Companies.IgnoreQueryFilters().AsNoTracking()
|
|
.FirstOrDefaultAsync(c => c.Id == id);
|
|
if (company == null) return NotFound();
|
|
|
|
if (string.IsNullOrEmpty(company.StripeCustomerId))
|
|
return Json(new { error = "No Stripe customer ID on record for this company." });
|
|
|
|
try
|
|
{
|
|
StripeConfiguration.ApiKey = _configuration["Stripe:SecretKey"];
|
|
|
|
var chargeService = new ChargeService();
|
|
var charges = await chargeService.ListAsync(new ChargeListOptions
|
|
{
|
|
Customer = company.StripeCustomerId,
|
|
Limit = 24
|
|
});
|
|
|
|
var rows = charges.Data.Select(ch =>
|
|
{
|
|
var amountCents = ch.Amount;
|
|
var amountRefundedCents = ch.AmountRefunded;
|
|
var refundableCents = Math.Max(0L, amountCents - amountRefundedCents);
|
|
return new
|
|
{
|
|
id = ch.Id,
|
|
created = ch.Created.ToString("MM/dd/yyyy"),
|
|
amount = (amountCents / 100m).ToString("C"),
|
|
amountRefunded = amountRefundedCents > 0 ? (amountRefundedCents / 100m).ToString("C") : (string?)null,
|
|
refundableCents,
|
|
status = ch.Status,
|
|
receipt = ch.ReceiptUrl,
|
|
description = ch.Description
|
|
};
|
|
});
|
|
|
|
return Json(new { success = true, charges = rows });
|
|
}
|
|
catch (StripeException ex)
|
|
{
|
|
_logger.LogWarning(ex, "Stripe charge lookup failed for company {Id}", id);
|
|
return Json(new { error = ex.StripeError?.Message ?? ex.Message });
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Issues a full or partial refund against a Stripe subscription payment on behalf of a
|
|
/// SuperAdmin. The refund is created via Stripe's <c>RefundService</c> against the invoice's
|
|
/// <c>chargeId</c> (Stripe Charge ID); partial refunds are supported by passing an explicit
|
|
/// <paramref name="amount"/> in dollars (converted to cents for Stripe).
|
|
/// The action is written to <c>AuditLogs</c> with the Stripe refund ID so there is a
|
|
/// durable record of who authorized the refund and when. Returns JSON so the Manage view
|
|
/// can show an inline result without a full page reload.
|
|
/// </summary>
|
|
/// <param name="id">Company primary key — used for audit logging only.</param>
|
|
/// <param name="chargeId">Stripe Charge ID for the payment being refunded.</param>
|
|
/// <param name="amount">Refund amount in dollars; must be > 0 and ≤ remaining refundable amount.</param>
|
|
/// <param name="reason">Human-readable reason recorded in the audit log.</param>
|
|
[HttpPost, ValidateAntiForgeryToken]
|
|
public async Task<IActionResult> IssueSubscriptionRefund(int id, string chargeId, decimal amount, string reason)
|
|
{
|
|
var company = await _db.Companies.IgnoreQueryFilters().AsNoTracking()
|
|
.FirstOrDefaultAsync(c => c.Id == id);
|
|
if (company == null) return Json(new { error = "Company not found." });
|
|
|
|
if (string.IsNullOrWhiteSpace(chargeId))
|
|
return Json(new { error = "Charge ID is required." });
|
|
|
|
if (amount <= 0)
|
|
return Json(new { error = "Refund amount must be greater than zero." });
|
|
|
|
try
|
|
{
|
|
StripeConfiguration.ApiKey = _configuration["Stripe:SecretKey"];
|
|
|
|
var refundService = new RefundService();
|
|
var refund = await refundService.CreateAsync(new RefundCreateOptions
|
|
{
|
|
Charge = chargeId,
|
|
Amount = (long)Math.Round(amount * 100), // dollars → cents
|
|
Reason = RefundReasons.RequestedByCustomer
|
|
});
|
|
|
|
var userName = User.Identity?.Name ?? "SuperAdmin";
|
|
_logger.LogInformation(
|
|
"SuperAdmin {User} issued {Amount} subscription refund for company {CompanyId} ({CompanyName}). " +
|
|
"ChargeId={ChargeId} StripeRefundId={RefundId} Reason={Reason}",
|
|
userName, amount.ToString("C"), id, company.CompanyName,
|
|
chargeId, refund.Id, reason);
|
|
|
|
// Durable audit trail so the action is visible without opening Stripe
|
|
await _db.Database.ExecuteSqlRawAsync("""
|
|
INSERT INTO AuditLogs (UserId, UserName, CompanyId, CompanyName, Action, EntityType, EntityId,
|
|
EntityDescription, OldValues, NewValues, IpAddress, Timestamp)
|
|
VALUES ({0},{1},{2},{3},{4},{5},{6},{7},{8},{9},{10},{11})
|
|
""",
|
|
(object?)User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value,
|
|
userName,
|
|
(object?)null,
|
|
(object?)null,
|
|
"SubscriptionRefund",
|
|
"Company",
|
|
id.ToString(),
|
|
company.CompanyName,
|
|
(object?)null,
|
|
$"Issued {amount:C} subscription refund. Reason: {reason}. Stripe Charge: {chargeId}. Stripe Refund ID: {refund.Id}.",
|
|
(object?)HttpContext.Connection.RemoteIpAddress?.ToString(),
|
|
DateTime.UtcNow);
|
|
|
|
return Json(new { success = true, refundId = refund.Id, amountFormatted = amount.ToString("C") });
|
|
}
|
|
catch (StripeException ex)
|
|
{
|
|
_logger.LogWarning(ex, "Stripe refund failed for company {Id}: {Message}", id, ex.StripeError?.Message);
|
|
return Json(new { error = ex.StripeError?.Message ?? ex.Message });
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Converts 0 to <c>null</c> for nullable integer limit override fields.
|
|
/// HTML number inputs cannot represent <c>null</c> — they submit 0 when left empty — so
|
|
/// this helper normalises 0 back to <c>null</c>, which the application interprets as
|
|
/// "use the plan's default limit" rather than "enforce a hard zero limit".
|
|
/// Any non-zero value passes through unchanged.
|
|
/// </summary>
|
|
/// <param name="value">Raw form-submitted integer override value.</param>
|
|
/// <returns><c>null</c> when <paramref name="value"/> is 0; otherwise the original value.</returns>
|
|
private static int? NullIfZero(int? value) => value == 0 ? null : value;
|
|
}
|