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,194 @@
@using PowderCoating.Web.Controllers
@model DepositPaymentPageViewModel
@{
Layout = null;
var hasSurcharge = Model.SurchargeType != PowderCoating.Core.Enums.OnlinePaymentSurchargeType.None && Model.SurchargeValue > 0;
}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Pay Deposit — Quote @Model.QuoteNumber — @Model.CompanyName</title>
<link rel="stylesheet" href="~/lib/bootstrap/css/bootstrap.min.css" />
<style>
body { background: #f0f2f5; font-family: 'Segoe UI', sans-serif; }
.pay-card { max-width: 520px; margin: 60px auto; background: #fff; border-radius: 12px; box-shadow: 0 4px 24px rgba(0,0,0,.10); padding: 36px 40px; }
.company-name { font-size: 1.1rem; color: #6c757d; margin-bottom: 4px; }
.quote-number { font-size: 1.5rem; font-weight: 700; color: #1a1a2e; }
.deposit-badge { display: inline-block; background: #fff3cd; color: #856404; border-radius: 6px; font-size: .8rem; font-weight: 600; padding: 3px 10px; margin-bottom: 12px; }
.amount-display { background: #f8f9fa; border-radius: 8px; padding: 16px 20px; margin: 20px 0; }
.amount-display .label { font-size: .85rem; color: #6c757d; }
.amount-display .value { font-size: 1.1rem; font-weight: 600; }
.total-line { border-top: 2px solid #dee2e6; margin-top: 8px; padding-top: 10px; }
.total-line .value { font-size: 1.4rem; color: #f59e0b; font-weight: 700; }
#payment-element { margin: 20px 0; }
#pay-btn { font-size: 1.05rem; padding: 12px; }
#error-msg { font-size: .9rem; }
.surcharge-notice { font-size: .78rem; color: #6c757d; margin-top: 6px; }
.expires-note { font-size: .8rem; color: #6c757d; text-align: center; margin-top: 16px; }
#processing-overlay { display: none; position: fixed; inset: 0; background: rgba(255,255,255,.7); z-index: 9999; align-items: center; justify-content: center; }
</style>
</head>
<body>
<div id="processing-overlay" aria-live="polite">
<div class="text-center">
<div class="spinner-border text-warning" role="status"></div>
<p class="mt-2 fw-semibold">Processing deposit…</p>
</div>
</div>
<div class="pay-card">
<p class="company-name">@Model.CompanyName</p>
<p class="quote-number">Quote #@Model.QuoteNumber</p>
<span class="deposit-badge">Deposit Required — @Model.DepositPercent.ToString("0.##")% of quote total</span>
<p class="text-muted mb-0">Hello, @Model.CustomerName</p>
<div class="amount-display">
<div class="d-flex justify-content-between mb-1">
<span class="label">Quote Total</span>
<span class="value text-muted">@Model.QuoteTotal.ToString("C")</span>
</div>
<div class="d-flex justify-content-between mb-1 total-line">
<span class="label fw-semibold">Deposit Due</span>
<span class="value">@Model.DepositAmount.ToString("C")</span>
</div>
</div>
<form id="payment-form">
@if (hasSurcharge)
{
<div id="surchargeRow" class="amount-display py-2 d-none">
<div class="d-flex justify-content-between mb-1">
<span class="label">Deposit</span>
<span id="subtotalDisplay" class="value"></span>
</div>
<div class="d-flex justify-content-between mb-1">
<span class="label">
@if (Model.SurchargeType == PowderCoating.Core.Enums.OnlinePaymentSurchargeType.Percent)
{
@($"Online payment fee ({Model.SurchargeValue:0.##}%)")
}
else
{
<text>Online payment fee</text>
}
</span>
<span id="surchargeDisplay" class="value"></span>
</div>
<div class="d-flex justify-content-between total-line">
<span class="label fw-semibold">Total Charged</span>
<span id="totalDisplay" class="value"></span>
</div>
</div>
<p class="surcharge-notice">
A convenience fee applies to online payments. You may pay in person to avoid this fee.
Surcharges do not apply to debit card transactions in states where prohibited.
</p>
}
<div id="payment-element"></div>
<div id="error-msg" class="alert alert-danger d-none mt-3" role="alert"></div>
<button id="pay-btn" type="submit" class="btn btn-warning w-100 mt-3" disabled>
<span id="btn-text">Loading payment form…</span>
</button>
</form>
<p class="expires-note">
This payment link expires on @Model.ExpiresAt.Tz(ViewBag.CompanyTimeZone as string).ToString("MMMM d, yyyy").
</p>
</div>
<script src="https://js.stripe.com/v3/"></script>
<script>
const STRIPE_KEY = '@Model.StripePublishableKey';
const ACCOUNT_ID = '@Model.StripeAccountId';
const TOKEN = '@Model.Token';
const DEPOSIT_AMOUNT = @Model.DepositAmount.ToString("F2", System.Globalization.CultureInfo.InvariantCulture);
const HAS_SURCHARGE = @(hasSurcharge ? "true" : "false");
const stripe = Stripe(STRIPE_KEY, { stripeAccount: ACCOUNT_ID });
let elements = null;
let paymentElement = null;
const form = document.getElementById('payment-form');
const payBtn = document.getElementById('pay-btn');
const btnText = document.getElementById('btn-text');
const errorMsg = document.getElementById('error-msg');
const surchargeRow = document.getElementById('surchargeRow');
const subtotalDisplay = document.getElementById('subtotalDisplay');
const surchargeDisplay = document.getElementById('surchargeDisplay');
const totalDisplay = document.getElementById('totalDisplay');
const overlay = document.getElementById('processing-overlay');
function formatCurrency(val) {
return '$' + parseFloat(val).toFixed(2);
}
function showError(msg) {
errorMsg.textContent = msg;
errorMsg.classList.remove('d-none');
overlay.style.display = 'none';
payBtn.disabled = false;
btnText.textContent = 'Try Again';
}
async function initPaymentElement() {
errorMsg.classList.add('d-none');
payBtn.disabled = true;
btnText.textContent = 'Preparing…';
if (paymentElement) { paymentElement.destroy(); paymentElement = null; }
document.getElementById('payment-element').innerHTML = '';
const resp = await fetch('/pay/deposit/' + TOKEN + '/intent', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ amount: DEPOSIT_AMOUNT })
});
const data = await resp.json();
if (!resp.ok) { showError(data.error || 'Could not initialize payment.'); return; }
const surcharge = data.surchargeAmount || 0;
if (HAS_SURCHARGE && surcharge > 0) {
subtotalDisplay.textContent = formatCurrency(DEPOSIT_AMOUNT);
surchargeDisplay.textContent = formatCurrency(surcharge);
totalDisplay.textContent = formatCurrency(DEPOSIT_AMOUNT + surcharge);
surchargeRow.classList.remove('d-none');
}
elements = stripe.elements({ clientSecret: data.clientSecret });
paymentElement = elements.create('payment');
paymentElement.mount('#payment-element');
paymentElement.on('ready', () => {
payBtn.disabled = false;
btnText.textContent = 'Pay Deposit ' + formatCurrency(DEPOSIT_AMOUNT + surcharge);
});
}
form.addEventListener('submit', async (e) => {
e.preventDefault();
errorMsg.classList.add('d-none');
payBtn.disabled = true;
overlay.style.display = 'flex';
const { error } = await stripe.confirmPayment({
elements,
confirmParams: {
return_url: window.location.origin + '/pay/deposit/' + TOKEN + '/success'
}
});
if (error) {
showError(error.message || 'Payment failed. Please try again.');
}
});
initPaymentElement();
</script>
</body>
</html>
@@ -0,0 +1,26 @@
@{
Layout = null;
}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Deposit Received</title>
<link rel="stylesheet" href="~/lib/bootstrap/css/bootstrap.min.css" />
<style>
body { background: #f0f2f5; font-family: 'Segoe UI', sans-serif; }
.success-card { max-width: 460px; margin: 80px auto; background: #fff; border-radius: 12px; box-shadow: 0 4px 24px rgba(0,0,0,.10); padding: 40px; text-align: center; }
.check-icon { font-size: 3.5rem; margin-bottom: 12px; }
</style>
</head>
<body>
<div class="success-card">
<div class="check-icon">✅</div>
<h1 class="h4 fw-bold mb-2">Deposit Received</h1>
<p class="text-muted">Thank you! Your deposit was successfully processed. The shop has been notified and will be in touch to schedule your job.</p>
<hr />
<p class="text-muted small mb-0">You may close this window.</p>
</div>
</body>
</html>
@@ -0,0 +1,273 @@
@using PowderCoating.Web.Controllers
@model PaymentPageViewModel
@{
Layout = null; // Standalone page — no app chrome
var hasSurcharge = Model.SurchargeType != PowderCoating.Core.Enums.OnlinePaymentSurchargeType.None && Model.SurchargeValue > 0;
}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Pay Invoice @Model.InvoiceNumber — @Model.CompanyName</title>
<link rel="stylesheet" href="~/lib/bootstrap/css/bootstrap.min.css" />
<style>
body { background: #f0f2f5; font-family: 'Segoe UI', sans-serif; }
.pay-card { max-width: 520px; margin: 60px auto; background: #fff; border-radius: 12px; box-shadow: 0 4px 24px rgba(0,0,0,.10); padding: 36px 40px; }
.company-name { font-size: 1.1rem; color: #6c757d; margin-bottom: 4px; }
.invoice-number { font-size: 1.5rem; font-weight: 700; color: #1a1a2e; }
.amount-display { background: #f8f9fa; border-radius: 8px; padding: 16px 20px; margin: 20px 0; }
.amount-display .label { font-size: .85rem; color: #6c757d; }
.amount-display .value { font-size: 1.1rem; font-weight: 600; }
.total-line { border-top: 2px solid #dee2e6; margin-top: 8px; padding-top: 10px; }
.total-line .value { font-size: 1.4rem; color: #198754; font-weight: 700; }
#payment-element { margin: 20px 0; }
#pay-btn { font-size: 1.05rem; padding: 12px; }
#error-msg { font-size: .9rem; }
.surcharge-notice { font-size: .78rem; color: #6c757d; margin-top: 6px; }
.partial-section { margin-bottom: 16px; }
.expires-note { font-size: .8rem; color: #6c757d; text-align: center; margin-top: 16px; }
#processing-overlay { display: none; position: fixed; inset: 0; background: rgba(255,255,255,.7); z-index: 9999; align-items: center; justify-content: center; }
</style>
</head>
<body>
<div id="processing-overlay" aria-live="polite">
<div class="text-center">
<div class="spinner-border text-success" role="status"></div>
<p class="mt-2 fw-semibold">Processing payment…</p>
</div>
</div>
<div class="pay-card">
<p class="company-name">@Model.CompanyName</p>
<p class="invoice-number">Invoice #@Model.InvoiceNumber</p>
<p class="text-muted mb-0">Hello, @Model.CustomerName</p>
<div class="amount-display">
@if (Model.AmountPaid > 0)
{
<div class="d-flex justify-content-between mb-1">
<span class="label">Invoice Total</span>
<span class="value">@Model.InvoiceTotal.ToString("C")</span>
</div>
<div class="d-flex justify-content-between mb-1">
<span class="label">Previously Paid</span>
<span class="value text-muted">-@Model.AmountPaid.ToString("C")</span>
</div>
<div class="d-flex justify-content-between mb-1 total-line">
<span class="label fw-semibold">Balance Due</span>
<span class="value">@Model.BalanceDue.ToString("C")</span>
</div>
}
else
{
<div class="d-flex justify-content-between total-line">
<span class="label fw-semibold">Amount Due</span>
<span class="value">@Model.BalanceDue.ToString("C")</span>
</div>
}
</div>
<form id="payment-form">
@* Partial payment option *@
<div class="partial-section">
<label class="form-label fw-semibold mb-1">Payment amount</label>
<div class="form-check">
<input class="form-check-input" type="radio" name="payOption" id="optFull" value="full" checked />
<label class="form-check-label" for="optFull">
Pay full balance — <strong>@Model.BalanceDue.ToString("C")</strong>
</label>
</div>
<div class="form-check mt-1">
<input class="form-check-input" type="radio" name="payOption" id="optPartial" value="partial" />
<label class="form-check-label" for="optPartial">Pay a different amount</label>
</div>
<div id="partialAmountRow" class="mt-2 d-none">
<div class="input-group">
<span class="input-group-text">$</span>
<input type="number" id="partialAmount" class="form-control" min="1"
step="0.01" max="@Model.BalanceDue.ToString("F2")"
placeholder="Enter amount" />
</div>
</div>
</div>
@if (hasSurcharge)
{
<div id="surchargeRow" class="amount-display py-2 d-none">
<div class="d-flex justify-content-between mb-1">
<span class="label">Subtotal</span>
<span id="subtotalDisplay" class="value"></span>
</div>
<div class="d-flex justify-content-between mb-1">
<span class="label">
@if (Model.SurchargeType == PowderCoating.Core.Enums.OnlinePaymentSurchargeType.Percent)
{
@($"Online payment fee ({Model.SurchargeValue:0.##}%)")
}
else
{
<text>Online payment fee</text>
}
</span>
<span id="surchargeDisplay" class="value"></span>
</div>
<div class="d-flex justify-content-between total-line">
<span class="label fw-semibold">Total Charged</span>
<span id="totalDisplay" class="value"></span>
</div>
</div>
<p class="surcharge-notice">
A convenience fee applies to online payments. You may pay in person to avoid this fee.
Surcharges do not apply to debit card transactions in states where prohibited.
</p>
}
<div id="payment-element"></div>
<div id="error-msg" class="alert alert-danger d-none mt-3" role="alert"></div>
<button id="pay-btn" type="submit" class="btn btn-success w-100 mt-3" disabled>
<span id="btn-text">Loading payment form…</span>
</button>
</form>
<p class="expires-note">
This payment link expires on @Model.ExpiresAt.Tz(ViewBag.CompanyTimeZone as string).ToString("MMMM d, yyyy").
</p>
</div>
<script src="https://js.stripe.com/v3/"></script>
<script>
const STRIPE_KEY = '@Model.StripePublishableKey';
const ACCOUNT_ID = '@Model.StripeAccountId';
const TOKEN = '@Model.Token';
const BALANCE_DUE = @Model.BalanceDue.ToString("F2", System.Globalization.CultureInfo.InvariantCulture);
const HAS_SURCHARGE = @(hasSurcharge ? "true" : "false");
const stripe = Stripe(STRIPE_KEY, { stripeAccount: ACCOUNT_ID });
let elements = null;
let paymentElement = null;
let currentClientSecret = null;
const form = document.getElementById('payment-form');
const payBtn = document.getElementById('pay-btn');
const btnText = document.getElementById('btn-text');
const errorMsg = document.getElementById('error-msg');
const surchargeRow = document.getElementById('surchargeRow');
const subtotalDisplay = document.getElementById('subtotalDisplay');
const surchargeDisplay = document.getElementById('surchargeDisplay');
const totalDisplay = document.getElementById('totalDisplay');
const overlay = document.getElementById('processing-overlay');
const partialAmountRow = document.getElementById('partialAmountRow');
const partialAmountInput = document.getElementById('partialAmount');
let currentAmount = BALANCE_DUE;
function formatCurrency(val) {
return '$' + parseFloat(val).toFixed(2);
}
function showError(msg) {
errorMsg.textContent = msg;
errorMsg.classList.remove('d-none');
overlay.style.display = 'none';
payBtn.disabled = false;
btnText.textContent = 'Try Again';
}
function clearError() {
errorMsg.classList.add('d-none');
}
// Tear down and rebuild the Stripe Elements for the given amount
async function initPaymentElement(amountDollars) {
clearError();
payBtn.disabled = true;
btnText.textContent = 'Preparing…';
if (paymentElement) { paymentElement.destroy(); paymentElement = null; }
document.getElementById('payment-element').innerHTML = '';
const resp = await fetch('/pay/' + TOKEN + '/intent', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ amount: parseFloat(amountDollars) })
});
const data = await resp.json();
if (!resp.ok) { showError(data.error || 'Could not initialize payment.'); return; }
currentClientSecret = data.clientSecret;
const surcharge = data.surchargeAmount || 0;
if (HAS_SURCHARGE && surcharge > 0) {
subtotalDisplay.textContent = formatCurrency(amountDollars);
surchargeDisplay.textContent = formatCurrency(surcharge);
totalDisplay.textContent = formatCurrency(parseFloat(amountDollars) + surcharge);
surchargeRow.classList.remove('d-none');
} else if (surchargeRow) {
surchargeRow.classList.add('d-none');
}
elements = stripe.elements({ clientSecret: data.clientSecret });
paymentElement = elements.create('payment');
paymentElement.mount('#payment-element');
paymentElement.on('ready', () => {
payBtn.disabled = false;
btnText.textContent = 'Pay ' + formatCurrency(parseFloat(amountDollars) + surcharge);
});
}
// Handle radio toggle
document.querySelectorAll('input[name="payOption"]').forEach(r => {
r.addEventListener('change', () => {
if (r.value === 'partial') {
partialAmountRow.classList.remove('d-none');
partialAmountInput.focus();
} else {
partialAmountRow.classList.add('d-none');
currentAmount = BALANCE_DUE;
initPaymentElement(currentAmount);
}
});
});
// Debounce partial amount changes
let debounceTimer;
partialAmountInput.addEventListener('input', () => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
const val = parseFloat(partialAmountInput.value);
if (val > 0 && val <= BALANCE_DUE) {
currentAmount = val;
initPaymentElement(currentAmount);
}
}, 800);
});
// Handle form submit
form.addEventListener('submit', async (e) => {
e.preventDefault();
clearError();
payBtn.disabled = true;
overlay.style.display = 'flex';
const { error } = await stripe.confirmPayment({
elements,
confirmParams: {
return_url: window.location.origin + '/pay/' + TOKEN + '/success'
}
});
// If we get here, confirmPayment redirected or there was an error
if (error) {
showError(error.message || 'Payment failed. Please try again.');
}
});
// Boot
initPaymentElement(currentAmount);
</script>
</body>
</html>
@@ -0,0 +1,27 @@
@model string
@{
Layout = null;
}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Payment Unavailable</title>
<link rel="stylesheet" href="~/lib/bootstrap/css/bootstrap.min.css" />
<style>
body { background: #f0f2f5; font-family: 'Segoe UI', sans-serif; }
.error-card { max-width: 480px; margin: 80px auto; background: #fff; border-radius: 12px; box-shadow: 0 4px 24px rgba(0,0,0,.10); padding: 40px; text-align: center; }
.icon { font-size: 3rem; margin-bottom: 16px; }
</style>
</head>
<body>
<div class="error-card">
<div class="icon">⚠️</div>
<h1 class="h4 fw-bold mb-3">Payment Unavailable</h1>
<p class="text-muted">@Model</p>
<hr />
<p class="text-muted small mb-0">If you believe this is an error, please contact the shop directly.</p>
</div>
</body>
</html>
@@ -0,0 +1,26 @@
@{
Layout = null;
}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Payment Successful</title>
<link rel="stylesheet" href="~/lib/bootstrap/css/bootstrap.min.css" />
<style>
body { background: #f0f2f5; font-family: 'Segoe UI', sans-serif; }
.success-card { max-width: 460px; margin: 80px auto; background: #fff; border-radius: 12px; box-shadow: 0 4px 24px rgba(0,0,0,.10); padding: 40px; text-align: center; }
.check-icon { font-size: 3.5rem; margin-bottom: 12px; }
</style>
</head>
<body>
<div class="success-card">
<div class="check-icon">✅</div>
<h1 class="h4 fw-bold mb-2">Payment Received</h1>
<p class="text-muted">Thank you! Your payment was successfully processed. You'll receive a confirmation email shortly.</p>
<hr />
<p class="text-muted small mb-0">You may close this window.</p>
</div>
</body>
</html>