f671f7e62e
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>
210 lines
8.0 KiB
JavaScript
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();
|
|
});
|
|
})();
|