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,393 @@
@using PowderCoating.Core.Entities
@using PowderCoating.Core.Enums
@model List<Company>
@section Styles {
<style>
tr.cursor-pointer { cursor: pointer; }
[data-bs-theme="dark"] .card-header.bg-white { background-color: var(--bs-card-cap-bg) !important; }
</style>
}
@{
ViewData["Title"] = "Subscription Management";
int page = ViewBag.Page;
int totalPages = ViewBag.TotalPages;
int totalCount = ViewBag.TotalCount;
int pageSize = ViewBag.PageSize;
var planConfigs = (dynamic)ViewBag.PlanConfigs;
string PlanName(int plan)
{
foreach (var p in planConfigs)
if (p.Plan == plan) return p.DisplayName;
return plan.ToString();
}
string StatusBadge(SubscriptionStatus s) => s switch {
SubscriptionStatus.Active => "bg-success",
SubscriptionStatus.GracePeriod => "bg-warning",
SubscriptionStatus.Expired => "bg-danger",
SubscriptionStatus.Canceled => "bg-secondary",
SubscriptionStatus.Inactive => "bg-secondary",
_ => "bg-secondary"
};
string SortLink(string col) {
var dir = ViewBag.SortCol == col && ViewBag.SortDir == "asc" ? "desc" : "asc";
return Url.Action("Index", new { search = ViewBag.Search, status = ViewBag.StatusFilter,
plan = ViewBag.PlanFilter, sortCol = col, sortDir = dir, pageSize })!;
}
string PageLink(int p) => Url.Action("Index", new { search = ViewBag.Search, status = ViewBag.StatusFilter,
plan = ViewBag.PlanFilter, sortCol = ViewBag.SortCol, sortDir = ViewBag.SortDir, page = p, pageSize })!;
}
<div class="container-fluid py-3">
<div class="d-flex align-items-center justify-content-between mb-3">
<div>
<h4 class="mb-0"><i class="bi bi-credit-card me-2 text-primary"></i>Subscription Management</h4>
<small class="text-muted">@totalCount.ToString("N0") companies</small>
</div>
<a href="@Url.Action("ExportCsv", new { search = ViewBag.Search, status = ViewBag.StatusFilter, plan = ViewBag.PlanFilter })"
class="btn btn-outline-secondary btn-sm">
<i class="bi bi-download me-1"></i>Export CSV
</a>
</div>
@* Bulk Actions Toolbar (hidden until rows selected) *@
<div id="bulk-toolbar" class="alert alert-primary d-none d-flex align-items-center gap-3 py-2 mb-3">
<span class="fw-semibold small"><span id="selected-count">0</span> selected</span>
<div class="d-flex gap-2 flex-wrap">
<form method="post" asp-action="BulkExtendTrial" class="d-flex gap-1 align-items-center" id="extend-form">
@Html.AntiForgeryToken()
<select name="days" class="form-select form-select-sm" style="width:auto">
<option value="7">+7 days</option>
<option value="14">+14 days</option>
<option value="30">+30 days</option>
<option value="90">+90 days</option>
</select>
<button type="submit" class="btn btn-sm btn-primary">Extend Trial</button>
</form>
<form method="post" asp-action="BulkToggleActive" asp-route-active="true" id="activate-form">
@Html.AntiForgeryToken()
<input type="hidden" name="active" value="true" />
<button type="submit" class="btn btn-sm btn-success">Activate</button>
</form>
<form method="post" asp-action="BulkToggleActive" asp-route-active="false" id="deactivate-form">
@Html.AntiForgeryToken()
<input type="hidden" name="active" value="false" />
<button type="submit" class="btn btn-sm btn-danger">Deactivate</button>
</form>
</div>
<button class="btn btn-sm btn-outline-secondary ms-auto" onclick="clearSelection()">Clear</button>
</div>
@* Plan Feature Flags *@
<div class="card border-0 shadow-sm mb-3">
<div class="card-header border-0 py-2 d-flex align-items-center gap-2"
style="cursor:pointer;" data-bs-toggle="collapse" data-bs-target="#planFeaturesPanel">
<i class="bi bi-toggles me-1 text-secondary"></i>
<span class="fw-semibold small">Plan Feature Flags</span>
<i class="bi bi-chevron-down ms-auto small text-muted"></i>
</div>
<div class="collapse" id="planFeaturesPanel">
<div class="card-body p-0">
<table class="table table-sm align-middle mb-0 small">
<thead class="table-light">
<tr>
<th>Plan</th>
<th class="text-center">Online Payments (Stripe Connect)</th>
</tr>
</thead>
<tbody>
@foreach (var p in planConfigs)
{
<tr>
<td class="fw-medium ps-3">@p.DisplayName</td>
<td class="text-center">
<div class="form-check form-switch d-inline-block mb-0">
<input class="form-check-input plan-feature-toggle"
type="checkbox" role="switch"
data-plan-id="@p.Id"
data-feature="AllowOnlinePayments"
@(p.AllowOnlinePayments ? "checked" : "")
title="Toggle online payment capability for @p.DisplayName plan" />
</div>
</td>
</tr>
}
</tbody>
</table>
<div class="px-3 py-2 text-muted" style="font-size:0.75rem;">
Changes take effect immediately. Companies on a plan with Online Payments enabled can connect their Stripe account in Company Settings.
</div>
</div>
</div>
</div>
@* Filters *@
<div class="card border-0 shadow-sm mb-3">
<div class="card-body py-2">
<form method="get" class="row g-2 align-items-end">
<div class="col-md-4">
<input name="search" value="@ViewBag.Search" class="form-control form-control-sm"
placeholder="Company name, email, Stripe ID…" />
</div>
<div class="col-md-2">
<select name="status" class="form-select form-select-sm">
<option value="">All statuses</option>
@foreach (var s in Enum.GetValues<SubscriptionStatus>())
{
<option value="@s" selected="@(ViewBag.StatusFilter == s.ToString())">@s</option>
}
</select>
</div>
<div class="col-md-2">
<select name="plan" class="form-select form-select-sm">
<option value="">All plans</option>
@foreach (var p in planConfigs)
{
<option value="@p.Plan" selected="@(ViewBag.PlanFilter == p.Plan.ToString())">@p.DisplayName</option>
}
</select>
</div>
<div class="col-auto">
<button class="btn btn-sm btn-primary">Filter</button>
<a asp-action="Index" class="btn btn-sm btn-outline-secondary ms-1">Clear</a>
</div>
<input type="hidden" name="pageSize" value="@pageSize" />
<input type="hidden" name="sortCol" value="@ViewBag.SortCol" />
<input type="hidden" name="sortDir" value="@ViewBag.SortDir" />
</form>
</div>
</div>
<div class="card border-0 shadow-sm">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0 small">
<thead class="table-light">
<tr>
<th style="width:36px"><input type="checkbox" id="select-all" title="Select all" /></th>
<th><a href="@SortLink("CompanyName")" class="text-decoration-none text-body">Company</a></th>
<th><a href="@SortLink("Plan")" class="text-decoration-none text-body">Plan</a></th>
<th><a href="@SortLink("Status")" class="text-decoration-none text-body">Status</a></th>
<th><a href="@SortLink("Active")" class="text-decoration-none text-body">Active</a></th>
<th><a href="@SortLink("EndDate")" class="text-decoration-none text-body">End Date</a></th>
<th>Stripe Customer</th>
<th></th>
</tr>
</thead>
<tbody>
@if (!Model.Any())
{
<tr><td colspan="8" class="text-center text-muted py-5">No companies found.</td></tr>
}
@foreach (var c in Model)
{
var isExpired = c.SubscriptionEndDate.HasValue && c.SubscriptionEndDate < DateTime.UtcNow;
var manageUrl = Url.Action("Manage", new { id = c.Id });
<tr class="@(isExpired ? "table-danger" : "") cursor-pointer"
onclick="rowClick(event, @c.Id, '@manageUrl')">
<td onclick="event.stopPropagation()">
<input type="checkbox" class="row-check" value="@c.Id" />
</td>
<td>
<div class="fw-medium">@c.CompanyName</div>
<small class="text-muted">@c.PrimaryContactEmail</small>
</td>
<td>@PlanName(c.SubscriptionPlan)</td>
<td><span class="badge @StatusBadge(c.SubscriptionStatus)">@c.SubscriptionStatus</span></td>
<td>
@if (c.IsActive) { <i class="bi bi-check-circle-fill text-success"></i> }
else { <i class="bi bi-x-circle-fill text-danger"></i> }
</td>
<td>
@if (c.SubscriptionEndDate.HasValue)
{
<span class="@(isExpired ? "text-danger fw-semibold" : "")">
@c.SubscriptionEndDate.Value.Tz(ViewBag.CompanyTimeZone as string).ToString("MM/dd/yyyy")
</span>
}
else { <span class="text-muted">—</span> }
</td>
<td>
@if (!string.IsNullOrEmpty(c.StripeCustomerId))
{ <code class="small">@c.StripeCustomerId</code> }
else { <span class="text-muted">—</span> }
</td>
<td onclick="event.stopPropagation()">
<a asp-action="Manage" asp-route-id="@c.Id"
class="btn btn-sm btn-outline-primary py-0 px-2">
<i class="bi bi-pencil me-1"></i>Manage
</a>
</td>
</tr>
}
</tbody>
</table>
</div>
<!-- Mobile card view — shown on screens < 992px (table-responsive hidden by mobile-cards.css) -->
<div class="mobile-card-view">
<div class="mobile-card-list">
@if (!Model.Any())
{
<div class="text-center text-muted py-5">No companies found.</div>
}
@foreach (var c in Model)
{
var isExpiredMobile = c.SubscriptionEndDate.HasValue && c.SubscriptionEndDate < DateTime.UtcNow;
<a href="@Url.Action("Manage", new { id = c.Id })" class="mobile-data-card text-decoration-none">
<div class="mobile-card-header">
<div class="mobile-card-icon bg-primary"><i class="bi bi-building"></i></div>
<div class="mobile-card-title">
<h6>@c.CompanyName</h6>
<small class="text-muted">@PlanName(c.SubscriptionPlan)</small>
</div>
<span class="badge @StatusBadge(c.SubscriptionStatus)">@c.SubscriptionStatus</span>
</div>
<div class="mobile-card-body">
<div class="mobile-card-row">
<span class="mobile-card-label">Active</span>
<span class="mobile-card-value">
@if (c.IsActive) { <i class="bi bi-check-circle-fill text-success"></i> }
else { <i class="bi bi-x-circle-fill text-danger"></i> }
</span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">End Date</span>
<span class="mobile-card-value @(isExpiredMobile ? "text-danger fw-semibold" : "")">
@if (c.SubscriptionEndDate.HasValue)
{
@c.SubscriptionEndDate.Value.Tz(ViewBag.CompanyTimeZone as string).ToString("MM/dd/yyyy")
}
else
{
<span class="text-muted">—</span>
}
</span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Email</span>
<span class="mobile-card-value">@c.PrimaryContactEmail</span>
</div>
</div>
<div class="mobile-card-footer">
<span class="btn btn-sm btn-outline-primary">Manage →</span>
</div>
</a>
}
</div>
</div>
@if (totalPages > 1)
{
<div class="card-footer d-flex align-items-center justify-content-between py-2">
<small class="text-muted">
Showing @((page - 1) * pageSize + 1)@Math.Min(page * pageSize, totalCount) of @totalCount.ToString("N0")
</small>
<nav>
<ul class="pagination pagination-sm mb-0">
<li class="page-item @(page == 1 ? "disabled" : "")">
<a class="page-link" href="@PageLink(page - 1)"><i class="bi bi-chevron-left"></i></a>
</li>
@for (int p = Math.Max(1, page - 2); p <= Math.Min(totalPages, page + 2); p++)
{
<li class="page-item @(p == page ? "active" : "")">
<a class="page-link" href="@PageLink(p)">@p</a>
</li>
}
<li class="page-item @(page == totalPages ? "disabled" : "")">
<a class="page-link" href="@PageLink(page + 1)"><i class="bi bi-chevron-right"></i></a>
</li>
</ul>
</nav>
<select class="form-select form-select-sm" style="width:auto"
onchange="window.location='@PageLink(1)'.replace('pageSize=@pageSize','pageSize='+this.value)">
@foreach (var ps in new[] { 10, 25, 50, 100 })
{
<option value="@ps" selected="@(pageSize == ps)">@ps / page</option>
}
</select>
</div>
}
</div>
</div>
@Html.AntiForgeryToken()
<script>
(function () {
// ── Plan feature toggles ──────────────────────────────────────────
document.querySelectorAll('.plan-feature-toggle').forEach(function (toggle) {
toggle.addEventListener('change', async function () {
const planId = this.dataset.planId, feature = this.dataset.feature;
const enabled = this.checked, original = !enabled;
try {
const resp = await fetch('/SubscriptionManagement/TogglePlanFeature', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'RequestVerificationToken': document.querySelector('input[name="__RequestVerificationToken"]').value
},
body: `planId=${planId}&feature=${encodeURIComponent(feature)}&enabled=${enabled}`
});
const data = await resp.json();
if (!data.success) { this.checked = original; alert('Failed to update plan feature.'); }
} catch { this.checked = original; alert('Network error updating plan feature.'); }
});
});
// ── Bulk selection ────────────────────────────────────────────────
const toolbar = document.getElementById('bulk-toolbar');
const countEl = document.getElementById('selected-count');
const selectAll = document.getElementById('select-all');
function getSelected() {
return Array.from(document.querySelectorAll('.row-check:checked')).map(c => c.value);
}
function updateToolbar() {
const ids = getSelected();
countEl.textContent = ids.length;
toolbar.classList.toggle('d-none', ids.length === 0);
toolbar.classList.toggle('d-flex', ids.length > 0);
// Inject hidden id inputs into each bulk form
['extend-form', 'activate-form', 'deactivate-form'].forEach(formId => {
const form = document.getElementById(formId);
form.querySelectorAll('input[name="ids"]').forEach(i => i.remove());
ids.forEach(id => {
const inp = document.createElement('input');
inp.type = 'hidden'; inp.name = 'ids'; inp.value = id;
form.appendChild(inp);
});
});
}
document.querySelectorAll('.row-check').forEach(cb =>
cb.addEventListener('change', updateToolbar));
selectAll.addEventListener('change', function () {
document.querySelectorAll('.row-check').forEach(cb => cb.checked = this.checked);
updateToolbar();
});
window.clearSelection = function () {
document.querySelectorAll('.row-check').forEach(cb => cb.checked = false);
selectAll.checked = false;
updateToolbar();
};
window.rowClick = function (e, id, url) {
if (e.target.type === 'checkbox' || e.target.closest('a,button,form')) return;
window.location = url;
};
// Confirm bulk destructive actions
['activate-form', 'deactivate-form'].forEach(formId => {
document.getElementById(formId).addEventListener('submit', function (e) {
const count = getSelected().length;
if (!confirm(`This will affect ${count} company(ies). Continue?`)) e.preventDefault();
});
});
})();
</script>
@@ -0,0 +1,540 @@
@using PowderCoating.Core.Entities
@using PowderCoating.Core.Enums
@model Company
@{
ViewData["Title"] = $"Manage {Model.CompanyName}";
var planConfigs = (dynamic)ViewBag.PlanConfigs;
string PlanName(int plan)
{
foreach (var p in planConfigs) if (p.Plan == plan) return p.DisplayName;
return plan.ToString();
}
PowderCoating.Core.Entities.SubscriptionPlanConfig? currentPlanConfig = null;
foreach (var p in planConfigs)
{
if (p.Plan == Model.SubscriptionPlan) { currentPlanConfig = p; break; }
}
string PlanLimit(int value) => value == -1 ? "Unlimited" : value == 0 ? "None" : value.ToString();
}
<div class="container-fluid py-3" style="max-width:900px">
<div class="d-flex align-items-center gap-3 mb-3">
<a asp-action="Index" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-arrow-left me-1"></i>Back
</a>
<h4 class="mb-0">
<i class="bi bi-credit-card me-2 text-primary"></i>@Model.CompanyName
</h4>
@if (Model.IsComped)
{
<span class="badge bg-success"><i class="bi bi-star-fill me-1"></i>Comped</span>
}
@if (!Model.IsActive)
{
<span class="badge bg-danger">Inactive</span>
}
</div>
@if (TempData["Success"] != null)
{
<div class="alert alert-success alert-dismissible mb-3" role="alert">
@TempData["Success"]
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
}
<div class="row g-3">
@* Usage stats *@
<div class="col-md-4">
<div class="card border-0 shadow-sm mb-3">
<div class="card-header bg-white border-0 py-3">
<h6 class="mb-0 fw-semibold">Usage</h6>
</div>
<div class="card-body">
<dl class="row small mb-0">
<dt class="col-7 text-muted">Jobs</dt>
<dd class="col-5 fw-semibold">@ViewBag.JobCount</dd>
<dt class="col-7 text-muted">Customers</dt>
<dd class="col-5 fw-semibold">@ViewBag.CustomerCount</dd>
<dt class="col-7 text-muted">Users</dt>
<dd class="col-5 fw-semibold">@ViewBag.UserCount</dd>
<dt class="col-7 text-muted">Stripe Customer</dt>
<dd class="col-5"><code class="small">@(Model.StripeCustomerId ?? "—")</code></dd>
<dt class="col-7 text-muted">Stripe Sub</dt>
<dd class="col-5"><code class="small">@(Model.StripeSubscriptionId ?? "—")</code></dd>
</dl>
</div>
</div>
@* Quick extend buttons *@
<div class="card border-0 shadow-sm">
<div class="card-header bg-white border-0 py-3">
<h6 class="mb-0 fw-semibold">Quick Extend</h6>
</div>
<div class="card-body d-flex flex-wrap gap-2">
@foreach (var days in new[] { 7, 14, 30, 90 })
{
<form method="post" asp-action="ExtendTrial" asp-route-id="@Model.Id" class="d-inline">
@Html.AntiForgeryToken()
<input type="hidden" name="days" value="@days" />
<button class="btn btn-sm btn-outline-primary">+@days days</button>
</form>
}
</div>
<div class="card-footer small text-muted py-2">
Extends from current end date (or today if expired). Sets status to Active.
</div>
</div>
</div>
@* Edit form *@
<div class="col-md-8">
<form method="post" asp-action="UpdateSubscription" asp-route-id="@Model.Id">
@Html.AntiForgeryToken()
@* Comped / Internal card — prominent *@
<div class="card border-0 shadow-sm mb-3 @(Model.IsComped ? "border-success border-2" : "")">
<div class="card-header border-0 py-3 @(Model.IsComped ? "bg-success bg-opacity-10" : "bg-white")">
<h6 class="mb-0 fw-semibold">
<i class="bi bi-star me-2 text-success"></i>Comped / Internal Access
</h6>
</div>
<div class="card-body">
<div class="form-check form-switch mb-2">
<input class="form-check-input" type="checkbox" role="switch"
name="isComped" value="true" id="isComped"
@(Model.IsComped ? "checked" : "") />
<label class="form-check-label fw-medium" for="isComped">
Mark as Comped (complimentary/internal)
</label>
</div>
<p class="text-muted small mb-0">
When enabled: all plan limits become unlimited, expiry banners and lockouts are suppressed,
and the subscription end date is ignored. Use for internal/demo tenants.
</p>
</div>
</div>
@* Subscription settings *@
<div class="card border-0 shadow-sm mb-3">
<div class="card-header bg-white border-0 py-3">
<h6 class="mb-0 fw-semibold">Subscription Settings</h6>
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label fw-medium">Plan</label>
<select name="subscriptionPlan" class="form-select">
@foreach (var p in planConfigs)
{
<option value="@p.Plan" selected="@(Model.SubscriptionPlan == p.Plan)">@p.DisplayName</option>
}
</select>
</div>
<div class="col-md-6">
<label class="form-label fw-medium">Status</label>
<select name="subscriptionStatus" class="form-select">
@foreach (var s in Enum.GetValues<SubscriptionStatus>())
{
<option value="@s" selected="@(Model.SubscriptionStatus == s)">@s</option>
}
</select>
</div>
<div class="col-md-6">
<label class="form-label fw-medium">End Date</label>
<input type="date" name="subscriptionEndDate" class="form-control"
value="@(Model.SubscriptionEndDate?.Tz(ViewBag.CompanyTimeZone as string).ToString("yyyy-MM-dd"))" />
<div class="form-text">Leave blank for no expiry.</div>
</div>
<div class="col-md-6">
<label class="form-label fw-medium">Account Active</label>
<div class="form-check form-switch mt-2">
<input class="form-check-input" type="checkbox" name="isActive" value="true"
id="isActive" @(Model.IsActive ? "checked" : "") />
<label class="form-check-label" for="isActive">
Company can log in and use the app
</label>
</div>
</div>
<div class="col-12">
<label class="form-label fw-medium">Internal Notes <span class="text-muted fw-normal small">(not shown to company)</span></label>
<textarea name="subscriptionNotes" class="form-control" rows="2"
placeholder="e.g. 'Comped per sales agreement', 'Trial extended by request'">@Model.SubscriptionNotes</textarea>
</div>
<div class="col-12">
<label class="form-label fw-medium">Change Reason <span class="text-muted fw-normal small">(logged to audit trail)</span></label>
<textarea name="notes" class="form-control" rows="1"
placeholder="Brief reason for this manual change"></textarea>
</div>
</div>
</div>
</div>
@* Per-company limit overrides *@
<div class="card border-0 shadow-sm mb-3">
<div class="card-header bg-white border-0 py-3">
<h6 class="mb-0 fw-semibold">
<i class="bi bi-sliders me-2 text-secondary"></i>Plan Limit Overrides
</h6>
</div>
<div class="card-body">
<p class="text-muted small mb-3">
Leave blank to use the plan default. Enter <strong>-1</strong> for unlimited.
@if (currentPlanConfig != null)
{
<span>Current plan: <strong>@currentPlanConfig.DisplayName</strong></span>
}
</p>
<div class="row g-3">
<div class="col-sm-6 col-lg-4">
<label class="form-label small fw-medium">Max Users</label>
<input type="number" name="maxUsersOverride" class="form-control form-control-sm"
value="@Model.MaxUsersOverride" placeholder="Plan default" />
@if (currentPlanConfig != null)
{
<div class="form-text">Plan default: <strong>@PlanLimit(currentPlanConfig.MaxUsers)</strong></div>
}
</div>
<div class="col-sm-6 col-lg-4">
<label class="form-label small fw-medium">Max Active Jobs</label>
<input type="number" name="maxActiveJobsOverride" class="form-control form-control-sm"
value="@Model.MaxActiveJobsOverride" placeholder="Plan default" />
@if (currentPlanConfig != null)
{
<div class="form-text">Plan default: <strong>@PlanLimit(currentPlanConfig.MaxActiveJobs)</strong></div>
}
</div>
<div class="col-sm-6 col-lg-4">
<label class="form-label small fw-medium">Max Customers</label>
<input type="number" name="maxCustomersOverride" class="form-control form-control-sm"
value="@Model.MaxCustomersOverride" placeholder="Plan default" />
@if (currentPlanConfig != null)
{
<div class="form-text">Plan default: <strong>@PlanLimit(currentPlanConfig.MaxCustomers)</strong></div>
}
</div>
<div class="col-sm-6 col-lg-4">
<label class="form-label small fw-medium">Max Quotes / Month</label>
<input type="number" name="maxQuotesOverride" class="form-control form-control-sm"
value="@Model.MaxQuotesOverride" placeholder="Plan default" />
@if (currentPlanConfig != null)
{
<div class="form-text">Plan default: <strong>@PlanLimit(currentPlanConfig.MaxQuotes)</strong></div>
}
</div>
<div class="col-sm-6 col-lg-4">
<label class="form-label small fw-medium">Max Catalog Items</label>
<input type="number" name="maxCatalogItemsOverride" class="form-control form-control-sm"
value="@Model.MaxCatalogItemsOverride" placeholder="Plan default" />
@if (currentPlanConfig != null)
{
<div class="form-text">Plan default: <strong>@PlanLimit(currentPlanConfig.MaxCatalogItems)</strong></div>
}
</div>
<div class="col-sm-6 col-lg-4">
<label class="form-label small fw-medium">Max Photos / Job</label>
<input type="number" name="maxJobPhotosOverride" class="form-control form-control-sm"
value="@Model.MaxJobPhotosOverride" placeholder="Plan default" />
@if (currentPlanConfig != null)
{
<div class="form-text">Plan default: <strong>@PlanLimit(currentPlanConfig.MaxJobPhotos)</strong></div>
}
</div>
<div class="col-sm-6 col-lg-4">
<label class="form-label small fw-medium">Max Photos / Quote</label>
<input type="number" name="maxQuotePhotosOverride" class="form-control form-control-sm"
value="@Model.MaxQuotePhotosOverride" placeholder="Plan default" />
@if (currentPlanConfig != null)
{
<div class="form-text">Plan default: <strong>@PlanLimit(currentPlanConfig.MaxQuotePhotos)</strong></div>
}
</div>
</div>
</div>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-lg me-1"></i>Save Changes
</button>
<a asp-action="Index" class="btn btn-outline-secondary">Cancel</a>
</div>
</form>
</div>
</div>
@* Feature Flags *@
<div class="card border-0 shadow-sm mt-3">
<div class="card-header bg-white border-0 py-3">
<h6 class="mb-0 fw-semibold"><i class="bi bi-toggles me-2 text-secondary"></i>Feature Flags</h6>
</div>
<div class="card-body">
<form method="post" asp-action="UpdateFeatureFlags" asp-route-id="@Model.Id">
@Html.AntiForgeryToken()
<div class="row g-3">
<div class="col-md-4">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch"
name="aiPhotoQuotesEnabled" value="true" id="aiPhotoQuotes"
@(Model.AiPhotoQuotesEnabled ? "checked" : "") />
<label class="form-check-label" for="aiPhotoQuotes">AI Photo Quotes</label>
</div>
<div class="form-text">Allow this company to use AI photo quoting.</div>
</div>
<div class="col-md-4">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch"
name="aiInventoryAssistEnabled" value="true" id="aiInventory"
@(Model.AiInventoryAssistEnabled ? "checked" : "") />
<label class="form-check-label" for="aiInventory">AI Inventory Assist</label>
</div>
<div class="form-text">Allow AI-powered inventory lookups.</div>
</div>
<div class="col-md-4">
<label class="form-label small fw-medium">AI Photo Quotes / Month Override</label>
<input type="number" name="maxAiPhotoQuotesPerMonthOverride" class="form-control form-control-sm"
value="@Model.MaxAiPhotoQuotesPerMonthOverride" placeholder="Plan default" />
<div class="form-text">0 = disabled, -1 = unlimited, blank = plan default.</div>
</div>
</div>
<div class="mt-3">
<button type="submit" class="btn btn-sm btn-primary">Save Feature Flags</button>
</div>
</form>
</div>
</div>
@* Stripe Payment History + Refunds *@
@if (!string.IsNullOrEmpty(Model.StripeCustomerId))
{
<div class="card border-0 shadow-sm mt-3">
<div class="card-header bg-white border-0 py-3 d-flex justify-content-between align-items-center">
<h6 class="mb-0 fw-semibold"><i class="bi bi-stripe me-2 text-primary"></i>Stripe Payment History</h6>
<button class="btn btn-sm btn-outline-secondary" onclick="loadPaymentHistory()">Load</button>
</div>
<div class="card-body p-0" id="payment-history-container">
<p class="text-muted small p-3 mb-0">Click Load to fetch payment history from Stripe.</p>
</div>
</div>
}
<!-- Refund Modal -->
<div class="modal fade" id="refundModal" tabindex="-1" aria-labelledby="refundModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="refundModalLabel">
<i class="bi bi-arrow-counterclockwise me-2 text-danger"></i>Issue Subscription Refund
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="alert alert-warning small mb-3">
<i class="bi bi-exclamation-triangle me-1"></i>
This will immediately issue a refund via Stripe. This action cannot be undone.
</div>
<dl class="row small mb-3">
<dt class="col-5 text-muted">Invoice</dt>
<dd class="col-7 font-monospace" id="refund-invoice-number">—</dd>
<dt class="col-5 text-muted">Amount Paid</dt>
<dd class="col-7 fw-semibold" id="refund-amount-paid">—</dd>
<dt class="col-5 text-muted">Max Refundable</dt>
<dd class="col-7 fw-semibold text-success" id="refund-max-amount">—</dd>
</dl>
<div class="mb-3">
<label class="form-label fw-medium">Refund Amount</label>
<div class="input-group">
<span class="input-group-text">$</span>
<input type="number" id="refund-amount-input" class="form-control"
step="0.01" min="0.01" placeholder="0.00" />
</div>
<div class="form-text">Enter a partial amount or leave the default for a full refund.</div>
</div>
<div class="mb-3">
<label class="form-label fw-medium">Reason <span class="text-muted fw-normal">(logged to audit trail)</span></label>
<textarea id="refund-reason-input" class="form-control" rows="2"
placeholder="e.g. Customer requested cancellation refund, duplicate charge, etc."></textarea>
</div>
<div id="refund-result" class="d-none"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-danger" id="refund-submit-btn" onclick="submitRefund()">
<i class="bi bi-arrow-counterclockwise me-1"></i>Issue Refund
</button>
</div>
</div>
</div>
</div>
@* Recent audit entries for this company *@
<div class="card border-0 shadow-sm mt-3">
<div class="card-header bg-white border-0 py-3 d-flex justify-content-between align-items-center">
<h6 class="mb-0 fw-semibold">Recent Audit Activity</h6>
<a asp-controller="AuditLog" asp-action="Index" asp-route-companyId="@Model.Id"
class="btn btn-sm btn-outline-secondary">View All</a>
</div>
<div class="card-body p-0">
<p class="text-muted small p-3 mb-0">
<a asp-controller="AuditLog" asp-action="Index" asp-route-companyId="@Model.Id">
View audit log for @Model.CompanyName <i class="bi bi-arrow-right ms-1"></i>
</a>
</p>
</div>
</div>
</div>
@section Scripts {
<script>
// ── State for the refund modal ────────────────────────────────────────────────
let _refundPaymentIntentId = null;
let _refundMaxCents = 0;
let _refundAmountPaid = '';
let _refundInvoiceNumber = '';
const _antiForgery = document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
async function loadPaymentHistory() {
const container = document.getElementById('payment-history-container');
container.innerHTML = '<p class="text-muted small p-3 mb-0"><span class="spinner-border spinner-border-sm me-2"></span>Loading from Stripe...</p>';
try {
const resp = await fetch('@Url.Action("PaymentHistory", new { id = Model.Id })');
const data = await resp.json();
if (data.error) {
container.innerHTML = `<p class="text-danger small p-3 mb-0">${data.error}</p>`;
return;
}
if (!data.charges || data.charges.length === 0) {
container.innerHTML = '<p class="text-muted small p-3 mb-0">No Stripe charges found.</p>';
return;
}
let html = `<div class="table-responsive">
<table class="table table-sm mb-0">
<thead class="table-light">
<tr>
<th>Date</th><th>Description</th><th>Amount</th>
<th>Refunded</th><th>Status</th><th></th>
</tr>
</thead><tbody>`;
for (const ch of data.charges) {
const statusBadge = ch.status === 'succeeded'
? 'bg-success'
: ch.status === 'pending' ? 'bg-warning text-dark' : 'bg-secondary';
const receiptLink = ch.receipt
? `<a href="${ch.receipt}" target="_blank" class="btn btn-outline-secondary btn-sm py-0 me-1">Receipt</a>`
: '';
const refundedCell = ch.amountRefunded
? `<span class="text-danger small">${ch.amountRefunded}</span>`
: '<span class="text-muted small">—</span>';
const desc = ch.description ? `<span class="text-muted">${ch.description}</span>` : `<code class="small">${ch.id}</code>`;
// Show Refund button only for succeeded charges that still have something refundable
const refundBtn = (ch.status === 'succeeded' && ch.refundableCents > 0)
? `<button class="btn btn-outline-danger btn-sm py-0"
onclick="openRefundModal('${ch.id}', ${ch.refundableCents}, '${ch.amount}', '${ch.id}')">
Refund</button>`
: '';
html += `<tr>
<td class="small">${ch.created}</td>
<td class="small">${desc}</td>
<td class="small fw-semibold">${ch.amount}</td>
<td>${refundedCell}</td>
<td><span class="badge ${statusBadge}">${ch.status}</span></td>
<td class="text-end">${receiptLink}${refundBtn}</td>
</tr>`;
}
html += '</tbody></table></div>';
container.innerHTML = html;
} catch (e) {
container.innerHTML = '<p class="text-danger small p-3 mb-0">Failed to load payment history.</p>';
}
}
function openRefundModal(chargeId, refundableCents, amountPaid, displayLabel) {
_refundPaymentIntentId = chargeId; // reusing variable — now holds charge ID
_refundMaxCents = refundableCents;
_refundAmountPaid = amountPaid;
_refundInvoiceNumber = displayLabel;
const maxDollars = (refundableCents / 100).toFixed(2);
document.getElementById('refund-invoice-number').textContent = displayLabel;
document.getElementById('refund-amount-paid').textContent = amountPaid;
document.getElementById('refund-max-amount').textContent = `$${maxDollars}`;
document.getElementById('refund-amount-input').value = maxDollars;
document.getElementById('refund-amount-input').max = maxDollars;
document.getElementById('refund-reason-input').value = '';
document.getElementById('refund-result').className = 'd-none';
document.getElementById('refund-result').innerHTML = '';
document.getElementById('refund-submit-btn').disabled = false;
new bootstrap.Modal(document.getElementById('refundModal')).show();
}
async function submitRefund() {
const amount = parseFloat(document.getElementById('refund-amount-input').value);
const reason = document.getElementById('refund-reason-input').value.trim();
const resultEl = document.getElementById('refund-result');
const submitBtn = document.getElementById('refund-submit-btn');
if (!amount || amount <= 0) {
showRefundResult('error', 'Please enter a valid refund amount.');
return;
}
if (amount * 100 > _refundMaxCents + 0.5) { // +0.5 for floating-point tolerance
showRefundResult('error', `Amount cannot exceed the refundable amount of $${(_refundMaxCents / 100).toFixed(2)}.`);
return;
}
if (!reason) {
showRefundResult('error', 'Please enter a reason for the refund.');
return;
}
submitBtn.disabled = true;
submitBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Processing…';
try {
const formData = new FormData();
formData.append('chargeId', _refundPaymentIntentId);
formData.append('amount', amount.toFixed(2));
formData.append('reason', reason);
formData.append('__RequestVerificationToken', _antiForgery);
const resp = await fetch('@Url.Action("IssueSubscriptionRefund", new { id = Model.Id })', {
method: 'POST',
body: formData
});
const data = await resp.json();
if (data.error) {
showRefundResult('error', data.error);
submitBtn.disabled = false;
submitBtn.innerHTML = '<i class="bi bi-arrow-counterclockwise me-1"></i>Issue Refund';
} else {
showRefundResult('success',
`Refund of ${data.amountFormatted} issued successfully. Stripe Refund ID: ${data.refundId}`);
submitBtn.innerHTML = '<i class="bi bi-check-lg me-1"></i>Done';
// Reload payment history to reflect updated refunded amounts
setTimeout(() => loadPaymentHistory(), 1500);
}
} catch (e) {
showRefundResult('error', 'An unexpected error occurred. Please try again.');
submitBtn.disabled = false;
submitBtn.innerHTML = '<i class="bi bi-arrow-counterclockwise me-1"></i>Issue Refund';
}
}
function showRefundResult(type, message) {
const el = document.getElementById('refund-result');
el.className = type === 'success'
? 'alert alert-success small py-2'
: 'alert alert-danger small py-2';
el.innerHTML = message;
}
</script>
}