Files
PowderCoatingLogix/src/PowderCoating.Web/Views/SubscriptionManagement/Manage.cshtml
T
spouliot 64a9c1531b Fix — HTML entity rendering across 60 views
Razor's @() expression auto-encodes &, turning — into — which
rendered as literal text in the browser. Wrapped all such expressions in
@Html.Raw() so the em-dash entity is passed through unescaped.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 09:27:45 -04:00

554 lines
30 KiB
Plaintext

@using PowderCoating.Core.Entities
@using PowderCoating.Core.Enums
@model Company
@{
ViewData["Title"] = $"Manage &ndash; {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">
<i class="bi bi-arrow-left me-1"></i>Back
</a>
<a asp-controller="Companies" asp-action="Edit" asp-route-id="@Model.Id"
class="btn btn-outline-secondary">
<i class="bi bi-building me-1"></i>Edit Company
</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">@Html.Raw(Model.StripeCustomerId ?? "&mdash;")</code></dd>
<dt class="col-7 text-muted">Stripe Sub</dt>
<dd class="col-5"><code class="small">@Html.Raw(Model.StripeSubscriptionId ?? "&mdash;")</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 &mdash; 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">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch"
name="aiCatalogPriceCheckEnabled" value="true" id="aiCatalogPriceCheck"
@(Model.AiCatalogPriceCheckEnabled ? "checked" : "") />
<label class="form-check-label" for="aiCatalogPriceCheck">AI Catalog Price Check</label>
</div>
<div class="form-text">Override: grants access regardless of plan tier.</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 alert-permanent 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">&mdash;</dd>
<dt class="col-5 text-muted">Amount Paid</dt>
<dd class="col-7 fw-semibold" id="refund-amount-paid">&mdash;</dd>
<dt class="col-5 text-muted">Max Refundable</dt>
<dd class="col-7 fw-semibold text-success" id="refund-max-amount">&mdash;</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-outline-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">&mdash;</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 &mdash; 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&hellip;';
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>
}