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>
130 lines
5.6 KiB
JavaScript
130 lines
5.6 KiB
JavaScript
// terminal-readers.js
|
|
// Powers the Company Settings "Card Readers" tab: registering, listing, and deactivating Stripe
|
|
// Terminal readers, plus saving the in-person surcharge toggle. Loaded only when the company has an
|
|
// active Stripe Connect account.
|
|
(function () {
|
|
'use strict';
|
|
|
|
function token() {
|
|
var el = document.querySelector('input[name="__RequestVerificationToken"]');
|
|
return el ? el.value : '';
|
|
}
|
|
|
|
function notifyOk(msg) {
|
|
if (typeof showSuccess === 'function') showSuccess(msg); else console.log(msg);
|
|
}
|
|
function notifyErr(msg) {
|
|
if (typeof showError === 'function') showError(msg); else console.error(msg);
|
|
}
|
|
|
|
function post(url, data) {
|
|
return fetch(url, {
|
|
method: 'POST',
|
|
headers: {
|
|
'RequestVerificationToken': token(),
|
|
'Content-Type': 'application/x-www-form-urlencoded'
|
|
},
|
|
body: new URLSearchParams(data)
|
|
}).then(function (r) { return r.json(); });
|
|
}
|
|
|
|
var tableBody = document.getElementById('readersTableBody');
|
|
var registerBtn = document.getElementById('registerReaderBtn');
|
|
var saveSettingsBtn = document.getElementById('saveTerminalSettingsBtn');
|
|
var loaded = false;
|
|
|
|
function escapeHtml(s) {
|
|
return (s || '').replace(/[&<>"']/g, function (c) {
|
|
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c];
|
|
});
|
|
}
|
|
|
|
function renderReaders(readers) {
|
|
if (!readers || readers.length === 0) {
|
|
tableBody.innerHTML = '<tr><td colspan="5" class="text-muted small">No readers registered yet.</td></tr>';
|
|
return;
|
|
}
|
|
tableBody.innerHTML = readers.map(function (r) {
|
|
var net = r.networkStatus
|
|
? '<span class="badge bg-' + (r.networkStatus === 'online' ? 'success' : 'secondary') + '">' + escapeHtml(r.networkStatus) + '</span>'
|
|
: '<span class="text-muted small">—</span>';
|
|
return '<tr>' +
|
|
'<td>' + escapeHtml(r.label) + '</td>' +
|
|
'<td class="small text-muted">' + escapeHtml(r.deviceType) + '</td>' +
|
|
'<td class="small text-muted">' + escapeHtml(r.serialNumber || '—') + '</td>' +
|
|
'<td>' + net + '</td>' +
|
|
'<td class="text-end"><button type="button" class="btn btn-outline-danger btn-sm" data-reader-id="' + r.id + '">' +
|
|
'<i class="bi bi-trash"></i></button></td>' +
|
|
'</tr>';
|
|
}).join('');
|
|
}
|
|
|
|
function loadReaders() {
|
|
fetch('/Terminal/ListReaders', { headers: { 'RequestVerificationToken': token() } })
|
|
.then(function (r) { return r.json(); })
|
|
.then(function (res) {
|
|
if (res.success) renderReaders(res.readers);
|
|
else tableBody.innerHTML = '<tr><td colspan="5" class="text-danger small">Could not load readers.</td></tr>';
|
|
})
|
|
.catch(function () {
|
|
tableBody.innerHTML = '<tr><td colspan="5" class="text-danger small">Could not load readers.</td></tr>';
|
|
});
|
|
}
|
|
|
|
if (registerBtn) {
|
|
registerBtn.addEventListener('click', function () {
|
|
var code = document.getElementById('readerRegCode').value.trim();
|
|
var label = document.getElementById('readerLabel').value.trim();
|
|
if (!code || !label) { notifyErr('Enter both a registration code and a label.'); return; }
|
|
|
|
registerBtn.disabled = true;
|
|
post('/Terminal/RegisterReader', { registrationCode: code, label: label }).then(function (res) {
|
|
registerBtn.disabled = false;
|
|
if (res.success) {
|
|
notifyOk('Reader registered.');
|
|
document.getElementById('readerRegCode').value = '';
|
|
document.getElementById('readerLabel').value = '';
|
|
loadReaders();
|
|
} else {
|
|
notifyErr(res.error || 'Could not register the reader.');
|
|
}
|
|
}).catch(function () {
|
|
registerBtn.disabled = false;
|
|
notifyErr('Could not register the reader.');
|
|
});
|
|
});
|
|
}
|
|
|
|
// Deactivate (event delegation on the table body).
|
|
if (tableBody) {
|
|
tableBody.addEventListener('click', function (e) {
|
|
var btn = e.target.closest('button[data-reader-id]');
|
|
if (!btn) return;
|
|
if (!confirm('Remove this reader? You can register it again later.')) return;
|
|
btn.disabled = true;
|
|
post('/Terminal/DeactivateReader', { id: btn.dataset.readerId }).then(function (res) {
|
|
if (res.success) { notifyOk('Reader removed.'); loadReaders(); }
|
|
else { btn.disabled = false; notifyErr(res.error || 'Could not remove the reader.'); }
|
|
});
|
|
});
|
|
}
|
|
|
|
if (saveSettingsBtn) {
|
|
saveSettingsBtn.addEventListener('click', function () {
|
|
var enabled = document.getElementById('terminalSurchargeEnabled').checked;
|
|
post('/Terminal/UpdateTerminalSettings', { surchargeEnabled: enabled }).then(function (res) {
|
|
if (res.success) notifyOk('Reader settings saved.');
|
|
else notifyErr(res.error || 'Could not save settings.');
|
|
});
|
|
});
|
|
}
|
|
|
|
// Lazy-load the readers list the first time the tab is shown.
|
|
var tabBtn = document.getElementById('card-readers-tab');
|
|
if (tabBtn) {
|
|
tabBtn.addEventListener('shown.bs.tab', function () {
|
|
if (!loaded) { loadReaders(); loaded = true; }
|
|
});
|
|
}
|
|
})();
|