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