Files
PowderCoatingLogix/src/PowderCoating.Web/wwwroot/js/terminal-payment.js
T
spouliot f671f7e62e Add WisePOS E in-person card payments (Stripe Terminal)
Server-driven Stripe Terminal integration for taking in-person card payments
against an invoice, running on the same Stripe Connect connected account used
for online payments. No native app or Terminal SDK — the WisePOS E is driven
from the web backend via Stripe's REST API.

- Domain: TerminalReader entity + status enum, PaymentMethod.CardReader,
  Company.StripeTerminalLocationId / TerminalSurchargeEnabled, DbSet + tenant
  filter + indexes, IUnitOfWork repo, migration AddTerminalReaders (additive).
- StripeConnectService: location/reader registration, list, delete, process
  payment on reader, status poll, cancel, and a test-mode simulated tap. All
  routed to the connected account like the existing online-payment methods.
- TerminalController: admin reader management + per-invoice ProcessPayment,
  PaymentStatus (poll), CancelPayment, SimulateTap (test mode only). Stores the
  PaymentIntent id on the invoice; the webhook remains the authoritative writer.
- PaymentController webhook: HandlePaymentSucceededAsync records source=terminal
  payments as CardReader (online path unchanged — no source key means no change);
  new terminal.reader.action_failed handler for declines/timeouts (notification
  only, no ledger mutation). Refund path reused unchanged.
- UI: Card Readers settings tab (register/list/deactivate + in-person surcharge
  toggle, default off with a compliance warning) and an invoice "Take Card
  Payment" modal with live status polling. External JS per project convention.
- Feature bundled with the existing online-payments entitlement (no new plan
  flag); additionally requires StripeConnectStatus == Active.
- Help: HelpKnowledgeBase + Invoices help article updated.
- Tests: TerminalController validation + surcharge-routing tests (241 pass).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 18:57:58 -04:00

210 lines
8.0 KiB
JavaScript

// terminal-payment.js
// Drives the "Take Card Payment" modal on the invoice Details page: pushes a card_present
// PaymentIntent to a Stripe Terminal reader (WisePOS E), polls the reader's action status for live
// feedback, and reloads the page once the webhook has recorded the payment. The webhook — not this
// script — is the source of truth for the ledger; here we only report progress.
(function () {
'use strict';
var cfg = window.terminalPayment;
var modalEl = document.getElementById('cardReaderModal');
if (!cfg || !modalEl) return;
var invoiceId = modalEl.dataset.invoiceId;
var testMode = modalEl.dataset.testMode === 'true';
var setupView = document.getElementById('cardReaderSetup');
var statusView = document.getElementById('cardReaderStatus');
var statusText = document.getElementById('cardReaderStatusText');
var statusSub = document.getElementById('cardReaderStatusSub');
var spinner = document.getElementById('cardReaderSpinner');
var readerSelect = document.getElementById('cardReaderSelect');
var amountInput = document.getElementById('cardReaderAmount');
var processBtn = document.getElementById('cardReaderProcessBtn');
var cancelBtn = document.getElementById('cardReaderCancelBtn');
var simulateBtn = document.getElementById('cardReaderSimulateBtn');
var POLL_MS = 2500;
var TIMEOUT_MS = 90000;
var pollTimer = null;
var timeoutTimer = null;
var currentPI = null;
var currentReaderId = null;
function csrf() {
var el = document.querySelector('input[name="__RequestVerificationToken"]');
return el ? el.value : '';
}
function post(url, data) {
return fetch(url, {
method: 'POST',
headers: {
'RequestVerificationToken': csrf(),
'Content-Type': 'application/x-www-form-urlencoded'
},
body: new URLSearchParams(data)
}).then(function (r) { return r.json(); });
}
function getJson(url) {
return fetch(url, { headers: { 'RequestVerificationToken': csrf() } })
.then(function (r) { return r.json(); });
}
function clearTimers() {
if (pollTimer) { clearTimeout(pollTimer); pollTimer = null; }
if (timeoutTimer) { clearTimeout(timeoutTimer); timeoutTimer = null; }
}
function showStatus(text, sub, busy) {
setupView.classList.add('d-none');
statusView.classList.remove('d-none');
statusText.textContent = text;
statusSub.textContent = sub || '';
spinner.classList.toggle('d-none', !busy);
processBtn.classList.add('d-none');
if (simulateBtn) simulateBtn.classList.toggle('d-none', !(testMode && busy));
}
function backToSetup() {
clearTimers();
currentPI = null;
statusView.classList.add('d-none');
setupView.classList.remove('d-none');
processBtn.classList.remove('d-none');
processBtn.disabled = false;
processBtn.innerHTML = '<i class="bi bi-send me-2"></i>Send to Reader';
if (simulateBtn) simulateBtn.classList.add('d-none');
cancelBtn.textContent = 'Cancel';
}
function fail(message) {
clearTimers();
spinner.classList.add('d-none');
statusText.textContent = 'Payment did not complete';
statusSub.textContent = message || 'Please try again.';
if (simulateBtn) simulateBtn.classList.add('d-none');
// Offer a retry by returning to the setup view via the footer button.
processBtn.classList.remove('d-none');
processBtn.disabled = false;
processBtn.innerHTML = '<i class="bi bi-arrow-repeat me-2"></i>Try Again';
}
function succeed() {
clearTimers();
spinner.classList.add('d-none');
statusText.textContent = 'Approved ✓';
statusSub.textContent = 'Updating invoice…';
// The webhook has recorded the payment; reload so the new payment row + balance show.
setTimeout(function () { window.location.reload(); }, 900);
}
function poll() {
if (!currentPI) return;
var url = cfg.statusUrl + '?readerId=' + encodeURIComponent(currentReaderId) +
'&paymentIntentId=' + encodeURIComponent(currentPI);
getJson(url).then(function (res) {
if (res.webhookRecorded) { succeed(); return; }
if (res.actionStatus === 'failed') {
fail(res.failureMessage || 'The card was declined or the payment was cancelled.');
return;
}
// still in_progress (or webhook not landed yet) — keep polling
pollTimer = setTimeout(poll, POLL_MS);
}).catch(function () {
// Transient error — keep polling until the overall timeout fires.
pollTimer = setTimeout(poll, POLL_MS);
});
}
function process() {
var amount = parseFloat(amountInput.value);
var balance = parseFloat(modalEl.dataset.balanceDue);
if (isNaN(amount) || amount <= 0 || amount > balance + 0.0001) {
amountInput.classList.add('is-invalid');
return;
}
amountInput.classList.remove('is-invalid');
currentReaderId = readerSelect.value;
processBtn.disabled = true;
processBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Sending…';
post(cfg.processUrl, {
invoiceId: invoiceId,
readerId: currentReaderId,
amount: amount.toFixed(2)
}).then(function (res) {
if (!res.success) {
backToSetup();
showInlineError(res.error || 'Could not start the payment.');
return;
}
currentPI = res.paymentIntentId;
cancelBtn.textContent = 'Cancel Payment';
showStatus('Follow the prompts on the reader', 'Ask the customer to tap, insert, or swipe their card.', true);
pollTimer = setTimeout(poll, POLL_MS);
timeoutTimer = setTimeout(function () {
fail('This took longer than expected. Check the reader, then try again.');
}, TIMEOUT_MS);
});
}
function showInlineError(message) {
var existing = document.getElementById('cardReaderInlineError');
if (!existing) {
existing = document.createElement('div');
existing.id = 'cardReaderInlineError';
existing.className = 'alert alert-danger mt-2 mb-0';
setupView.appendChild(existing);
}
existing.textContent = message;
}
function cancelOnReader() {
if (!currentReaderId) return;
post(cfg.cancelUrl, { readerId: currentReaderId });
}
// Process / Try Again button.
processBtn.addEventListener('click', function () {
if (currentPI === null && statusView.classList.contains('d-none')) {
process();
} else {
// "Try Again" after a failure — reset to setup, the next click processes.
backToSetup();
}
});
// Cancel button: if a payment is in flight, cancel it on the reader before the modal closes.
cancelBtn.addEventListener('click', function () {
if (currentPI) cancelOnReader();
});
if (simulateBtn) {
simulateBtn.addEventListener('click', function () {
simulateBtn.disabled = true;
post(cfg.simulateUrl, { readerId: currentReaderId }).then(function () {
simulateBtn.disabled = false;
});
});
}
// Reset state whenever the modal is reopened.
modalEl.addEventListener('show.bs.modal', function () {
clearTimers();
currentPI = null;
currentReaderId = null;
var err = document.getElementById('cardReaderInlineError');
if (err) err.remove();
backToSetup();
amountInput.value = parseFloat(modalEl.dataset.balanceDue).toFixed(2);
});
// If the clerk closes the modal mid-payment, stop polling (the webhook still records it).
modalEl.addEventListener('hidden.bs.modal', function () {
clearTimers();
});
})();