Initial commit
This commit is contained in:
@@ -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>
|
||||
}
|
||||
Reference in New Issue
Block a user