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:
@@ -0,0 +1,232 @@
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Enums;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
using PowderCoating.Infrastructure.Repositories;
|
||||
using PowderCoating.Web.Controllers;
|
||||
|
||||
namespace PowderCoating.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Validation and surcharge-routing tests for the in-person Stripe Terminal payment flow.
|
||||
/// The Stripe API itself is mocked via <see cref="IStripeConnectService"/>; these tests cover the
|
||||
/// controller's guard rails and the TerminalSurchargeEnabled toggle, not Stripe behavior.
|
||||
/// </summary>
|
||||
public class TerminalControllerTests
|
||||
{
|
||||
private const int CompanyId = 1;
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessPayment_WhenAmountExceedsBalance_ReturnsError()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
SeedCompany(context, connected: true);
|
||||
SeedInvoice(context, total: 100m, amountPaid: 0m);
|
||||
SeedReader(context);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var stripe = new Mock<IStripeConnectService>();
|
||||
var controller = CreateController(context, stripe);
|
||||
|
||||
var result = await controller.ProcessPayment(invoiceId: 1, readerId: 1, amount: 250m);
|
||||
|
||||
using var doc = ParseJson(result);
|
||||
Assert.False(doc.RootElement.GetProperty("success").GetBoolean());
|
||||
stripe.Verify(s => s.ProcessInvoicePaymentOnReaderAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<decimal>(), It.IsAny<decimal>(),
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<int>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessPayment_WhenInvoiceVoided_ReturnsError()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
SeedCompany(context, connected: true);
|
||||
SeedInvoice(context, total: 100m, amountPaid: 0m, status: InvoiceStatus.Voided);
|
||||
SeedReader(context);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var stripe = new Mock<IStripeConnectService>();
|
||||
var controller = CreateController(context, stripe);
|
||||
|
||||
var result = await controller.ProcessPayment(invoiceId: 1, readerId: 1, amount: 50m);
|
||||
|
||||
using var doc = ParseJson(result);
|
||||
Assert.False(doc.RootElement.GetProperty("success").GetBoolean());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessPayment_WhenStripeNotConnected_ReturnsError()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
SeedCompany(context, connected: false);
|
||||
SeedInvoice(context, total: 100m, amountPaid: 0m);
|
||||
SeedReader(context);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var stripe = new Mock<IStripeConnectService>();
|
||||
var controller = CreateController(context, stripe);
|
||||
|
||||
var result = await controller.ProcessPayment(invoiceId: 1, readerId: 1, amount: 50m);
|
||||
|
||||
using var doc = ParseJson(result);
|
||||
Assert.False(doc.RootElement.GetProperty("success").GetBoolean());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessPayment_WhenSurchargeDisabled_PassesZeroSurchargeAndStoresPaymentIntent()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
SeedCompany(context, connected: true, surchargeEnabled: false, surchargePercent: 3m);
|
||||
SeedInvoice(context, total: 100m, amountPaid: 0m);
|
||||
SeedReader(context);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var stripe = new Mock<IStripeConnectService>();
|
||||
stripe.Setup(s => s.ProcessInvoicePaymentOnReaderAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<decimal>(), It.IsAny<decimal>(),
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<int>()))
|
||||
.ReturnsAsync((true, "pi_123", (string?)null));
|
||||
var controller = CreateController(context, stripe);
|
||||
|
||||
var result = await controller.ProcessPayment(invoiceId: 1, readerId: 1, amount: 100m);
|
||||
|
||||
using var doc = ParseJson(result);
|
||||
Assert.True(doc.RootElement.GetProperty("success").GetBoolean());
|
||||
Assert.Equal("pi_123", doc.RootElement.GetProperty("paymentIntentId").GetString());
|
||||
|
||||
// Surcharge must be 0 when the toggle is off, even though the company has a 3% online fee.
|
||||
stripe.Verify(s => s.ProcessInvoicePaymentOnReaderAsync(
|
||||
"acct_test", "tmr_test", 100m, 0m, "usd", "INV-1", 1), Times.Once);
|
||||
|
||||
// The PaymentIntent id must be persisted on the invoice for the webhook idempotency guard.
|
||||
var invoice = await context.Invoices.IgnoreQueryFilters().SingleAsync();
|
||||
Assert.Equal("pi_123", invoice.StripePaymentIntentId);
|
||||
Assert.Equal(OnlinePaymentStatus.Pending, invoice.OnlinePaymentStatus);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessPayment_WhenSurchargeEnabled_PassesComputedSurcharge()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
SeedCompany(context, connected: true, surchargeEnabled: true, surchargePercent: 3m);
|
||||
SeedInvoice(context, total: 200m, amountPaid: 0m);
|
||||
SeedReader(context);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var stripe = new Mock<IStripeConnectService>();
|
||||
stripe.Setup(s => s.ProcessInvoicePaymentOnReaderAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<decimal>(), It.IsAny<decimal>(),
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<int>()))
|
||||
.ReturnsAsync((true, "pi_456", (string?)null));
|
||||
var controller = CreateController(context, stripe);
|
||||
|
||||
await controller.ProcessPayment(invoiceId: 1, readerId: 1, amount: 200m);
|
||||
|
||||
// 3% of 200 = 6.00
|
||||
stripe.Verify(s => s.ProcessInvoicePaymentOnReaderAsync(
|
||||
"acct_test", "tmr_test", 200m, 6m, "usd", "INV-1", 1), Times.Once);
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
private static TerminalController CreateController(ApplicationDbContext context, Mock<IStripeConnectService> stripe)
|
||||
{
|
||||
var uow = new UnitOfWork(context);
|
||||
var tenant = new Mock<ITenantContext>();
|
||||
tenant.Setup(t => t.GetCurrentCompanyId()).Returns(CompanyId);
|
||||
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?> { ["Stripe:Connect:SecretKey"] = "sk_test_abc" })
|
||||
.Build();
|
||||
|
||||
var controller = new TerminalController(
|
||||
uow, stripe.Object, tenant.Object, config, Mock.Of<ILogger<TerminalController>>())
|
||||
{
|
||||
ControllerContext = new() { HttpContext = new DefaultHttpContext() }
|
||||
};
|
||||
return controller;
|
||||
}
|
||||
|
||||
private static void SeedCompany(ApplicationDbContext context, bool connected,
|
||||
bool surchargeEnabled = false, decimal surchargePercent = 0m)
|
||||
{
|
||||
context.Companies.Add(new Company
|
||||
{
|
||||
Id = CompanyId,
|
||||
CompanyName = "Test Shop",
|
||||
StripeAccountId = connected ? "acct_test" : null,
|
||||
StripeConnectStatus = connected ? StripeConnectStatus.Active : StripeConnectStatus.NotConnected,
|
||||
StripeTerminalLocationId = "tml_test",
|
||||
TerminalSurchargeEnabled = surchargeEnabled,
|
||||
OnlinePaymentSurchargeType = surchargePercent > 0 ? OnlinePaymentSurchargeType.Percent : OnlinePaymentSurchargeType.None,
|
||||
OnlinePaymentSurchargeValue = surchargePercent
|
||||
});
|
||||
}
|
||||
|
||||
private static void SeedInvoice(ApplicationDbContext context, decimal total, decimal amountPaid,
|
||||
InvoiceStatus status = InvoiceStatus.Sent)
|
||||
{
|
||||
context.Invoices.Add(new Invoice
|
||||
{
|
||||
Id = 1,
|
||||
CompanyId = CompanyId,
|
||||
InvoiceNumber = "INV-1",
|
||||
Total = total,
|
||||
AmountPaid = amountPaid,
|
||||
Status = status
|
||||
});
|
||||
}
|
||||
|
||||
private static void SeedReader(ApplicationDbContext context)
|
||||
{
|
||||
context.TerminalReaders.Add(new TerminalReader
|
||||
{
|
||||
Id = 1,
|
||||
CompanyId = CompanyId,
|
||||
StripeReaderId = "tmr_test",
|
||||
StripeLocationId = "tml_test",
|
||||
Label = "Front Counter",
|
||||
DeviceType = "simulated_wisepos_e",
|
||||
Status = TerminalReaderStatus.Active
|
||||
});
|
||||
}
|
||||
|
||||
private static JsonDocument ParseJson(Microsoft.AspNetCore.Mvc.IActionResult result)
|
||||
{
|
||||
var json = Assert.IsType<Microsoft.AspNetCore.Mvc.JsonResult>(result);
|
||||
return JsonDocument.Parse(JsonSerializer.Serialize(json.Value));
|
||||
}
|
||||
|
||||
private static ApplicationDbContext CreateContext()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
|
||||
// SuperAdmin principal so IsPlatformAdmin = true and the tenant query filter is bypassed in-test.
|
||||
var identity = new ClaimsIdentity(new[] { new Claim(ClaimTypes.Role, "SuperAdmin") }, "Test");
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
|
||||
byte[]? noBytes = null;
|
||||
var sessionMock = new Mock<ISession>();
|
||||
sessionMock.Setup(s => s.TryGetValue(It.IsAny<string>(), out noBytes)).Returns(false);
|
||||
|
||||
var httpContextMock = new Mock<HttpContext>();
|
||||
httpContextMock.SetupGet(c => c.User).Returns(principal);
|
||||
httpContextMock.SetupGet(c => c.Session).Returns(sessionMock.Object);
|
||||
|
||||
var accessor = new Mock<IHttpContextAccessor>();
|
||||
accessor.SetupGet(a => a.HttpContext).Returns(httpContextMock.Object);
|
||||
|
||||
return new ApplicationDbContext(options, accessor.Object, null!);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user