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; /// /// 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 IgnoreQueryFilters() to bypass the global EF multi-tenancy filter, /// which would otherwise scope results to a single company context. /// Changes are persisted directly via (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. /// // 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 _logger; public SubscriptionManagementController( ApplicationDbContext db, IConfiguration configuration, ILogger logger) { _db = db; _configuration = configuration; _logger = logger; } /// /// Displays the paginated, sortable, filterable list of all tenant companies with their /// current subscription state. Also loads all active SubscriptionPlanConfig 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. /// IgnoreQueryFilters() is required because deleted companies should be invisible /// here (excluded by explicit !c.IsDeleted) while active companies from all tenants /// must all be visible to the SuperAdmin, bypassing the single-tenant global filter. /// /// Optional text filter applied to company name, email, and Stripe customer ID. /// Optional enum name to filter by. /// Optional subscription plan integer to filter by. /// Column name to sort by (default: "CompanyName"). /// Sort direction: "asc" or "desc" (default: "asc"). /// 1-based page number (minimum 1). /// Rows per page; must be 10, 25, 50, or 100. public async Task 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(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); } /// /// 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. /// /// Primary key of the company to manage. public async Task 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); } /// /// Saves subscription changes for a single company and writes a manual entry to the /// AuditLogs table describing exactly what changed. /// Per-entity limit overrides are stored as nullable integers; a form value of 0 is /// converted to null via 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 ApplicationDbContext save pipeline, which applies global filters /// and could inadvertently scope the log entry to the wrong tenant. /// /// Primary key of the company to update. /// New plan integer identifier. /// New value. /// Whether the company account is active. /// Whether the company receives complimentary (free) access. /// New subscription end/expiry date. /// Internal notes visible only to SuperAdmins. /// Reason for this change; appended to the audit log entry. /// Per-company user limit override; 0 = use plan default. /// Per-company active jobs limit override; 0 = use plan default. /// Per-company customer limit override; 0 = use plan default. /// Per-company monthly quote limit override; 0 = use plan default. /// Per-company catalog item limit override; 0 = use plan default. /// Per-company job photo limit override; 0 = use plan default. /// Per-company quote photo limit override; 0 = use plan default. [HttpPost, ValidateAntiForgeryToken] public async Task 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(); 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 }); } /// /// 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 . /// 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. /// /// Primary key of the company to extend. /// Number of days to add to the current end date (or today if already expired). [HttpPost, ValidateAntiForgeryToken] public async Task 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 }); } /// /// AJAX endpoint that toggles a named boolean feature on a SubscriptionPlanConfig record. /// Currently only AllowOnlinePayments is supported; unknown feature names return /// 400 Bad Request 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. /// /// Primary key of the SubscriptionPlanConfig to update. /// Feature name string (e.g. "AllowOnlinePayments"). /// New boolean value for the feature flag. [HttpPost, ValidateAntiForgeryToken] public async Task 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 ─────────────────────────────────────────────────── /// /// Extends the subscription end date for multiple companies in a single operation, /// applying the same day-extension logic as : 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. /// /// Array of company primary keys to extend. /// Number of days to add to each company's end date. [HttpPost, ValidateAntiForgeryToken] public async Task 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)); } /// /// 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 Company.IsActive). /// 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. /// /// Array of company primary keys to toggle. /// Target active state: true to activate, false to deactivate. [HttpPost, ValidateAntiForgeryToken] public async Task 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)); } /// /// Exports the company subscription list to a CSV file, applying the same search, status, /// and plan filters as the view but without pagination. /// Plan names are resolved from the SubscriptionPlanConfig 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. /// /// Optional text filter applied to company name and email. /// Optional enum name to filter by. /// Optional subscription plan integer to filter by. public async Task 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(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"); } /// /// 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). /// private static string CsvEscape(string value) => value.Contains(',') || value.Contains('"') || value.Contains('\n') ? $"\"{value.Replace("\"", "\"\"")}\"" : value; /// /// 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 , matching the same /// convention used for the limit overrides in . /// /// Primary key of the company to update. /// Whether AI photo quoting is enabled for this company. /// Whether AI inventory assistance is enabled for this company. /// Whether AI catalog price check is enabled for this company. /// Monthly AI photo quote limit override; 0 = plan default. [HttpPost, ValidateAntiForgeryToken] public async Task 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 }); } /// /// 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 PaymentIntentId or a direct Charge reference — the new /// Invoice.Payments collection requires additional expansion round-trips. /// The Charge object has Amount, AmountRefunded, ReceiptUrl, and /// Id (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. /// /// Primary key of the company whose Stripe charge history to fetch. public async Task 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 }); } } /// /// Issues a full or partial refund against a Stripe subscription payment on behalf of a /// SuperAdmin. The refund is created via Stripe's RefundService against the invoice's /// chargeId (Stripe Charge ID); partial refunds are supported by passing an explicit /// in dollars (converted to cents for Stripe). /// The action is written to AuditLogs 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. /// /// Company primary key — used for audit logging only. /// Stripe Charge ID for the payment being refunded. /// Refund amount in dollars; must be > 0 and ≤ remaining refundable amount. /// Human-readable reason recorded in the audit log. [HttpPost, ValidateAntiForgeryToken] public async Task 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 }); } } /// /// Converts 0 to null for nullable integer limit override fields. /// HTML number inputs cannot represent null — they submit 0 when left empty — so /// this helper normalises 0 back to null, 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. /// /// Raw form-submitted integer override value. /// null when is 0; otherwise the original value. private static int? NullIfZero(int? value) => value == 0 ? null : value; }