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