// 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 = '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 = '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 = '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(); }); })();