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>
This commit is contained in:
@@ -0,0 +1,209 @@
|
||||
// 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();
|
||||
});
|
||||
})();
|
||||
Reference in New Issue
Block a user