Files
PowderCoatingLogix/tests/PowderCoating.UnitTests/TerminalControllerTests.cs
T
spouliot f671f7e62e 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>
2026-06-15 18:57:58 -04:00

233 lines
9.6 KiB
C#

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!);
}
}