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
@@ -381,6 +381,11 @@ public class PaymentController : Controller
var dispute = stripeEvent.Data.Object as Dispute;
if (dispute != null) await HandleDisputeClosedAsync(dispute);
}
else if (stripeEvent.Type == "terminal.reader.action_failed")
{
var reader = stripeEvent.Data.Object as Stripe.Terminal.Reader;
if (reader != null) await HandleReaderActionFailedAsync(reader);
}
return Ok();
}
@@ -459,15 +464,24 @@ public class PaymentController : Controller
// Create a Payment record so the payment appears in AR and bank reports, and make the
// matching GL entries. Manual payments go through RecordPayment which does the same thing;
// this makes Stripe payments consistent with that path.
// In-person Terminal payments carry source=terminal so we can record them as a card-reader
// payment (vs an online card-not-present payment) for clearer reporting. Everything else —
// GL posting, status machine, notifications — is identical.
var isTerminal = intent.Metadata.GetValueOrDefault("source") == "terminal";
var (arAcctId, checkingAcctId) = await GetGlAccountIdsAsync(invoice.CompanyId);
var stripePayment = new Core.Entities.Payment
{
InvoiceId = invoice.Id,
Amount = netPayment,
PaymentDate = DateTime.UtcNow,
PaymentMethod = PowderCoating.Core.Enums.PaymentMethod.CreditDebitCard,
PaymentMethod = isTerminal
? PowderCoating.Core.Enums.PaymentMethod.CardReader
: PowderCoating.Core.Enums.PaymentMethod.CreditDebitCard,
Reference = intent.Id,
Notes = $"Online payment via Stripe. Surcharge: {surcharge:C}",
Notes = isTerminal
? $"In-person card payment via Stripe Terminal. Surcharge: {surcharge:C}"
: $"Online payment via Stripe. Surcharge: {surcharge:C}",
DepositAccountId = checkingAcctId,
CompanyId = invoice.CompanyId,
CreatedAt = DateTime.UtcNow
@@ -502,6 +516,45 @@ public class PaymentController : Controller
}
}
/// <summary>
/// Handles a <c>terminal.reader.action_failed</c> event (declined card, customer cancellation,
/// reader timeout). This is observability only — no payment occurred, so nothing is written to the
/// ledger. The clerk's live status poll is the primary feedback channel; this fires an in-app
/// notification as a backstop in case they navigated away. Resolves the company via the invoice the
/// failed PaymentIntent was created for. Uses <c>IgnoreQueryFilters</c> (no tenant context here).
/// </summary>
private async Task HandleReaderActionFailedAsync(Stripe.Terminal.Reader reader)
{
var failureMessage = reader.Action?.FailureMessage ?? "The card reader payment did not complete.";
var paymentIntentId = reader.Action?.ProcessPaymentIntent?.PaymentIntentId;
_logger.LogWarning("Terminal reader {ReaderId} action failed: {Code} {Message} (PI={PI})",
reader.Id, reader.Action?.FailureCode, failureMessage, paymentIntentId);
if (string.IsNullOrEmpty(paymentIntentId)) return;
var invoice = await _context.Invoices
.IgnoreQueryFilters()
.FirstOrDefaultAsync(i => i.StripePaymentIntentId == paymentIntentId && !i.IsDeleted);
if (invoice == null) return;
try
{
await _inApp.CreateAsync(
companyId: invoice.CompanyId,
title: "Card Reader Payment Failed",
message: $"The card reader payment for invoice {invoice.InvoiceNumber} failed: {failureMessage}",
notificationType: "PaymentFailed",
link: $"/Invoices/Details/{invoice.Id}",
invoiceId: invoice.Id,
customerId: invoice.CustomerId);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "In-app notification failed for terminal failure on invoice {InvoiceId}", invoice.Id);
}
}
/// <summary>
/// Processes a successful <c>payment_intent.succeeded</c> event for a quote deposit. Creates a
/// <c>Deposit</c> ledger record so the deposit appears in the customer's deposit history and can