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; /// /// Validation and surcharge-routing tests for the in-person Stripe Terminal payment flow. /// The Stripe API itself is mocked via ; these tests cover the /// controller's guard rails and the TerminalSurchargeEnabled toggle, not Stripe behavior. /// 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(); 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(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), 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(); 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(); 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(); stripe.Setup(s => s.ProcessInvoicePaymentOnReaderAsync( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .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(); stripe.Setup(s => s.ProcessInvoicePaymentOnReaderAsync( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .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 stripe) { var uow = new UnitOfWork(context); var tenant = new Mock(); tenant.Setup(t => t.GetCurrentCompanyId()).Returns(CompanyId); var config = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { ["Stripe:Connect:SecretKey"] = "sk_test_abc" }) .Build(); var controller = new TerminalController( uow, stripe.Object, tenant.Object, config, Mock.Of>()) { 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(result); return JsonDocument.Parse(JsonSerializer.Serialize(json.Value)); } private static ApplicationDbContext CreateContext() { var options = new DbContextOptionsBuilder() .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(); sessionMock.Setup(s => s.TryGetValue(It.IsAny(), out noBytes)).Returns(false); var httpContextMock = new Mock(); httpContextMock.SetupGet(c => c.User).Returns(principal); httpContextMock.SetupGet(c => c.Session).Returns(sessionMock.Object); var accessor = new Mock(); accessor.SetupGet(a => a.HttpContext).Returns(httpContextMock.Object); return new ApplicationDbContext(options, accessor.Object, null!); } }