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,244 @@
@model PowderCoating.Web.Controllers.PaymentPageViewModel
@using PowderCoating.Core.Enums
@{
Layout = "~/Views/Shared/_QuoteApprovalLayout.cshtml";
ViewData["Title"] = $"Pay Invoice {Model.InvoiceNumber}";
ViewBag.CompanyName = Model.CompanyName;
var showSurcharge = Model.SurchargeType != OnlinePaymentSurchargeType.None && Model.SurchargeAmount > 0;
}
<div class="container py-5" style="max-width:520px;">
<!-- Company / Invoice header -->
<div class="text-center mb-4">
<h4 class="fw-bold mb-1">@Model.CompanyName</h4>
<p class="text-muted mb-0">Invoice <span class="fw-semibold text-dark">@Model.InvoiceNumber</span></p>
</div>
@if (Model.IsFullyPaid)
{
<div class="card p-4 text-center">
<i class="bi bi-check-circle-fill text-success" style="font-size:3rem;"></i>
<h5 class="mt-3 fw-bold">Invoice Paid</h5>
<p class="text-muted">This invoice has already been paid in full. Thank you!</p>
</div>
}
else
{
<!-- Invoice summary -->
<div class="card mb-4">
<div class="card-body">
<div class="d-flex justify-content-between mb-2">
<span class="text-muted">Customer</span>
<span class="fw-semibold">@Model.CustomerName</span>
</div>
<div class="d-flex justify-content-between mb-2">
<span class="text-muted">Invoice Total</span>
<span>@Model.InvoiceTotal.ToString("C")</span>
</div>
@if (Model.AmountPaid > 0)
{
<div class="d-flex justify-content-between mb-2 text-success">
<span>Previously Paid</span>
<span>(@Model.AmountPaid.ToString("C"))</span>
</div>
}
<div class="d-flex justify-content-between border-top pt-2 fw-bold">
<span>Balance Due</span>
<span class="text-danger fs-5">@Model.BalanceDue.ToString("C")</span>
</div>
@if (showSurcharge)
{
<div class="d-flex justify-content-between mt-1 small text-muted">
<span>
Convenience fee
@if (Model.SurchargeType == OnlinePaymentSurchargeType.Percent)
{
<span>(@Model.SurchargeValue.ToString("G")%)</span>
}
</span>
<span id="surchargeDisplay">@Model.SurchargeAmount.ToString("C")</span>
</div>
<div class="d-flex justify-content-between small fw-semibold mt-1" id="totalWithSurchargeRow">
<span>Total charged</span>
<span id="totalWithSurchargeDisplay">@Model.TotalWithSurcharge.ToString("C")</span>
</div>
}
</div>
</div>
<!-- Payment amount -->
<div class="card mb-4">
<div class="card-body">
<label class="form-label fw-semibold">Payment Amount</label>
<div class="input-group mb-1">
<span class="input-group-text">$</span>
<input type="number" id="paymentAmount" class="form-control"
value="@Model.BalanceDue.ToString("F2")"
min="0.01" max="@Model.BalanceDue.ToString("F2")" step="0.01"
oninput="updateSurcharge()" />
</div>
<div class="form-text">Max: @Model.BalanceDue.ToString("C")</div>
</div>
</div>
<!-- Stripe payment element -->
<div class="card mb-4">
<div class="card-body">
<label class="form-label fw-semibold">Card Details</label>
<div id="payment-element" class="py-2"></div>
<div id="payment-message" class="text-danger small mt-2" style="display:none;"></div>
</div>
</div>
<button id="submitBtn" class="btn btn-success w-100 btn-lg fw-semibold" onclick="submitPayment()">
<span id="submitText"><i class="bi bi-lock me-2"></i>Pay <span id="payBtnAmount">@Model.TotalWithSurcharge.ToString("C")</span> Securely</span>
<span id="submitSpinner" style="display:none;">
<span class="spinner-border spinner-border-sm me-2"></span>Processing...
</span>
</button>
<p class="text-center text-muted small mt-3">
<i class="bi bi-shield-lock me-1"></i>Payments secured by <strong>Stripe</strong>.
Your card details are never stored on our servers.
</p>
<p class="text-center text-muted small">
Link expires @Model.ExpiresAt.Tz(ViewBag.CompanyTimeZone as string).ToString("MMMM d, yyyy").
</p>
}
</div>
@section Scripts {
<script src="https://js.stripe.com/v3/"></script>
<script>
const STRIPE_PK = '@Model.StripePublishableKey';
const ACCOUNT_ID = '@Model.StripeAccountId';
const TOKEN = '@Model.Token';
const MAX_AMOUNT = @Model.BalanceDue.ToString("F2");
const SURCHARGE_TYPE = '@Model.SurchargeType';
const SURCHARGE_VALUE = @Model.SurchargeValue.ToString("F4");
const SUCCESS_URL = `/pay/${TOKEN}/success`;
const stripe = Stripe(STRIPE_PK, { stripeAccount: ACCOUNT_ID });
let elements, paymentElement;
async function initStripe() {
const amount = parseFloat(document.getElementById('paymentAmount').value) || MAX_AMOUNT;
const surcharge = calcSurcharge(amount);
const total = Math.round((amount + surcharge) * 100) / 100;
elements = stripe.elements({
mode: 'payment',
amount: Math.round(total * 100),
currency: 'usd',
appearance: { theme: 'stripe' },
paymentMethodOrder: ['card']
});
paymentElement = elements.create('payment');
paymentElement.mount('#payment-element');
}
function calcSurcharge(amount) {
if (SURCHARGE_TYPE === 'Percent')
return Math.round(amount * (SURCHARGE_VALUE / 100) * 100) / 100;
if (SURCHARGE_TYPE === 'Flat')
return SURCHARGE_VALUE;
return 0;
}
function formatCurrency(val) {
return '$' + val.toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
}
function updateSurcharge() {
const amount = Math.min(parseFloat(document.getElementById('paymentAmount').value) || 0, MAX_AMOUNT);
const surcharge = calcSurcharge(amount);
const total = amount + surcharge;
const surchargeEl = document.getElementById('surchargeDisplay');
const totalEl = document.getElementById('totalWithSurchargeDisplay');
const btnAmtEl = document.getElementById('payBtnAmount');
if (surchargeEl) surchargeEl.textContent = formatCurrency(surcharge);
if (totalEl) totalEl.textContent = formatCurrency(total);
if (btnAmtEl) btnAmtEl.textContent = surcharge > 0 ? formatCurrency(total) : formatCurrency(amount);
}
async function submitPayment() {
const btn = document.getElementById('submitBtn');
const msgEl = document.getElementById('payment-message');
const amountInput = document.getElementById('paymentAmount');
const amount = parseFloat(amountInput.value);
if (!amount || amount <= 0 || amount > MAX_AMOUNT) {
msgEl.textContent = 'Please enter a valid payment amount.';
msgEl.style.display = '';
return;
}
btn.disabled = true;
document.getElementById('submitText').style.display = 'none';
document.getElementById('submitSpinner').style.display = '';
msgEl.style.display = 'none';
// Submit the Elements form to get a confirmationToken
const { error: submitError } = await elements.submit();
if (submitError) {
msgEl.textContent = submitError.message;
msgEl.style.display = '';
btn.disabled = false;
document.getElementById('submitText').style.display = '';
document.getElementById('submitSpinner').style.display = 'none';
return;
}
// Create PaymentIntent on server
let clientSecret;
try {
const resp = await fetch(`/pay/${TOKEN}/intent`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ amount })
});
const data = await resp.json();
if (!resp.ok) {
msgEl.textContent = data.error || 'Unable to initialize payment.';
msgEl.style.display = '';
btn.disabled = false;
document.getElementById('submitText').style.display = '';
document.getElementById('submitSpinner').style.display = 'none';
return;
}
clientSecret = data.clientSecret;
} catch {
msgEl.textContent = 'Network error. Please try again.';
msgEl.style.display = '';
btn.disabled = false;
document.getElementById('submitText').style.display = '';
document.getElementById('submitSpinner').style.display = 'none';
return;
}
// Confirm the payment
const { error } = await stripe.confirmPayment({
elements,
clientSecret,
confirmParams: { return_url: window.location.origin + SUCCESS_URL }
});
if (error) {
msgEl.textContent = error.message;
msgEl.style.display = '';
btn.disabled = false;
document.getElementById('submitText').style.display = '';
document.getElementById('submitSpinner').style.display = 'none';
}
// On success Stripe redirects to SUCCESS_URL automatically
}
document.addEventListener('DOMContentLoaded', initStripe);
</script>
}
@@ -0,0 +1,19 @@
@model string
@{
Layout = "~/Views/Shared/_QuoteApprovalLayout.cshtml";
ViewData["Title"] = "Payment Link Unavailable";
ViewBag.CompanyName = "";
}
<div class="container py-5" style="max-width:480px;">
<div class="card p-4 text-center">
<i class="bi bi-exclamation-triangle-fill text-warning" style="font-size:3rem;"></i>
<h4 class="mt-3 fw-bold">Payment Unavailable</h4>
<p class="text-muted mb-0">@(Model ?? "This payment link is not available.")</p>
<hr class="my-3" />
<p class="text-muted small mb-0">
Please contact the shop for assistance.
</p>
</div>
</div>
@@ -0,0 +1,20 @@
@{
Layout = "~/Views/Shared/_QuoteApprovalLayout.cshtml";
ViewData["Title"] = "Payment Successful";
ViewBag.CompanyName = "";
}
<div class="container py-5" style="max-width:480px;">
<div class="card p-4 text-center">
<i class="bi bi-check-circle-fill text-success" style="font-size:3.5rem;"></i>
<h4 class="mt-3 fw-bold">Payment Received!</h4>
<p class="text-muted mb-0">
Thank you — your payment has been processed successfully.
You'll receive a confirmation once your payment is verified by the shop.
</p>
<hr class="my-3" />
<p class="text-muted small mb-0">
If you have questions, please contact the shop directly.
</p>
</div>
</div>