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