f671f7e62e
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>
233 lines
9.6 KiB
C#
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!);
|
|
}
|
|
}
|