Initial commit

This commit is contained in:
2026-04-23 21:38:24 -04:00
commit 63e12a9636
1762 changed files with 1672620 additions and 0 deletions
@@ -0,0 +1,630 @@
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>
[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;
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="maxAiPhotoQuotesPerMonthOverride">Monthly AI photo quote limit override; 0 = plan default.</param>
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> UpdateFeatureFlags(
int id,
bool aiPhotoQuotesEnabled,
bool aiInventoryAssistEnabled,
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.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 &gt; 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;
}