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
@@ -644,6 +644,12 @@
<button type="button" class="btn btn-success" data-bs-toggle="modal" data-bs-target="#recordPaymentModal">
<i class="bi bi-cash me-2"></i>Record Payment
</button>
@if ((bool)(ViewBag.TerminalPaymentsEnabled ?? false))
{
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#cardReaderModal">
<i class="bi bi-credit-card-2-front me-2"></i>Take Card Payment
</button>
}
}
<a asp-action="DownloadPdf" asp-route-id="@Model.Id" asp-route-inline="true"
class="btn btn-outline-secondary" target="_blank" rel="noopener">
@@ -1042,6 +1048,67 @@
</div>
}
@if (canPay && (bool)(ViewBag.TerminalPaymentsEnabled ?? false))
{
var terminalReaders = ViewBag.TerminalReaders as IEnumerable<SelectListItem> ?? Enumerable.Empty<SelectListItem>();
<!-- Take Card Payment (Stripe Terminal) Modal -->
<div class="modal fade" id="cardReaderModal" tabindex="-1"
data-invoice-id="@Model.Id"
data-balance-due="@Model.BalanceDue.ToString("F2")"
data-test-mode="@((bool)(ViewBag.TerminalTestMode ?? false) ? "true" : "false")">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-credit-card-2-front me-2"></i>Take Card Payment</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
@* Setup view: choose reader + amount, then send to the reader *@
<div id="cardReaderSetup">
<div class="mb-3">
<label class="form-label fw-semibold" for="cardReaderSelect">Card Reader</label>
<select id="cardReaderSelect" class="form-select">
@foreach (var r in terminalReaders)
{
<option value="@r.Value">@r.Text</option>
}
</select>
</div>
<div class="mb-3">
<label class="form-label fw-semibold" for="cardReaderAmount">Amount <span class="text-danger">*</span></label>
<div class="input-group">
<span class="input-group-text">$</span>
<input type="number" id="cardReaderAmount" class="form-control" step="0.01" min="0.01"
max="@Model.BalanceDue" value="@Model.BalanceDue.ToString("F2")" />
</div>
<div class="form-text">Balance due: @Model.BalanceDue.ToString("C")</div>
</div>
</div>
@* Status view: live progress while the customer presents their card *@
<div id="cardReaderStatus" class="text-center py-4 d-none">
<div id="cardReaderSpinner" class="spinner-border text-primary mb-3" role="status"></div>
<div id="cardReaderStatusText" class="fw-semibold"></div>
<div id="cardReaderStatusSub" class="text-muted small mt-1"></div>
</div>
</div>
<div class="modal-footer">
<button type="button" id="cardReaderCancelBtn" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
@if ((bool)(ViewBag.TerminalTestMode ?? false))
{
<button type="button" id="cardReaderSimulateBtn" class="btn btn-outline-info d-none" title="Test mode only">
<i class="bi bi-magic me-1"></i>Simulate Tap
</button>
}
<button type="button" id="cardReaderProcessBtn" class="btn btn-primary">
<i class="bi bi-send me-2"></i>Send to Reader
</button>
</div>
</div>
</div>
</div>
}
<!-- Edit Payment Modal -->
@if (!isVoided)
{
@@ -1531,6 +1598,18 @@
}
};
</script>
@if (canPay && (bool)(ViewBag.TerminalPaymentsEnabled ?? false))
{
<script>
window.terminalPayment = {
processUrl: '@Url.Action("ProcessPayment", "Terminal")',
statusUrl: '@Url.Action("PaymentStatus", "Terminal")',
cancelUrl: '@Url.Action("CancelPayment", "Terminal")',
simulateUrl: '@Url.Action("SimulateTap", "Terminal")'
};
</script>
<script src="~/js/terminal-payment.js" asp-append-version="true"></script>
}
<script>
function submitSendInvoice(sendEmail, sendSms) {
document.getElementById('sendInvoiceSendEmail').value = sendEmail ? 'true' : 'false';