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:
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user