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:
2026-06-15 18:57:58 -04:00
parent 9bbe1e4e27
commit f671f7e62e
24 changed files with 13281 additions and 8 deletions
@@ -0,0 +1,129 @@
// 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 { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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">&mdash;</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 || '&mdash;') + '</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; }
});
}
})();