using Baya.Application.Features.Payments.Commands.ConfirmPaymentAndPostLedger; using Baya.Application.Features.Payments.Commands.HandlePaymentWebhook; using Baya.Application.Features.Payments.Commands.InitiatePayment; using Baya.Application.Models.Common; using Baya.Domain.Entities.Booking; using Baya.Domain.Entities.Payments; using Mediator; using Microsoft.EntityFrameworkCore; using NSubstitute; using BookingEntity = Baya.Domain.Entities.Booking.Booking; namespace Baya.Test.Foundation.Payments; public class PaymentWebhookTests { private static readonly DateTimeOffset Now = new(2026, 7, 6, 10, 0, 0, TimeSpan.Zero); private const long Price = 23_300_000; private static ISender SenderRoutingConfirmTo(ConfirmPaymentAndPostLedgerCommandHandler confirm) { var sender = Substitute.For(); sender.Send(Arg.Any(), Arg.Any()) .Returns(ci => confirm.Handle(ci.Arg(), ci.Arg())); return sender; } private static async Task<(long RequestId, string Reference)> SeedPendingAsync(PaymentsTestHost host) { var variantId = host.AddVariant(sessionCount: 1, price: Price); var requestId = host.AddAcceptedRequest(variantId); var initiate = new InitiatePaymentCommandHandler(host.AsCustomer(), host.UnitOfWork, host.PaymentProvider, host.Clock(Now)); var init = await initiate.Handle(new InitiatePaymentCommand(requestId, null), CancellationToken.None); return (requestId, init.Result.GatewayReferenceCode); } private static string SuccessBody(string reference, string eventId) => $"{{\"external_event_id\":\"{eventId}\",\"event_type\":\"payment.succeeded\",\"gateway_reference_code\":\"{reference}\"}}"; [Fact] public async Task Webhook_success_confirms_the_booking_and_posts_one_balanced_group() { using var host = new PaymentsTestHost(); var (_, reference) = await SeedPendingAsync(host); var confirm = new ConfirmPaymentAndPostLedgerCommandHandler( host.UnitOfWork, host.Config(), host.Clock(Now), host.PaymentProvider, host.Settlement, host.Serializer, host.Notifications(), TestSenders.WithTicketHooks()); var handler = new HandlePaymentWebhookCommandHandler( SenderRoutingConfirmTo(confirm), host.UnitOfWork, host.Verifier, host.Lock, host.Clock(Now)); var result = await handler.Handle( new HandlePaymentWebhookCommand("zarinpal", new Dictionary(), SuccessBody(reference, "evt-1")), CancellationToken.None); Assert.True(result.IsSuccess); Assert.Equal(WebhookProcessingStatus.Processed, result.Result.ProcessingStatus); Assert.False(result.Result.Duplicate); Assert.Equal(BookingStatus.Confirmed, host.Db.Set().AsNoTracking().Single().Status); Assert.Equal(3, host.Db.Set().AsNoTracking().Count()); Assert.Single(host.Db.Set().AsNoTracking()); } [Fact] public async Task Replayed_webhook_event_is_a_no_op() { using var host = new PaymentsTestHost(); var (_, reference) = await SeedPendingAsync(host); var confirm = new ConfirmPaymentAndPostLedgerCommandHandler( host.UnitOfWork, host.Config(), host.Clock(Now), host.PaymentProvider, host.Settlement, host.Serializer, host.Notifications(), TestSenders.WithTicketHooks()); var sender = SenderRoutingConfirmTo(confirm); var handler = new HandlePaymentWebhookCommandHandler(sender, host.UnitOfWork, host.Verifier, host.Lock, host.Clock(Now)); var body = SuccessBody(reference, "evt-1"); await handler.Handle(new HandlePaymentWebhookCommand("zarinpal", new Dictionary(), body), CancellationToken.None); var replay = await handler.Handle(new HandlePaymentWebhookCommand("zarinpal", new Dictionary(), body), CancellationToken.None); Assert.True(replay.IsSuccess); Assert.True(replay.Result.Duplicate); // No second confirm, no second ledger group, one webhook event row. await sender.Received(1).Send(Arg.Any(), Arg.Any()); Assert.Equal(3, host.Db.Set().AsNoTracking().Count()); Assert.Single(host.Db.Set().AsNoTracking()); } [Fact] public async Task Racing_same_key_insert_is_caught_as_an_idempotent_no_op() { using var host = new PaymentsTestHost(); var (_, reference) = await SeedPendingAsync(host); // A provider that omits external_event_id skips the read-dedup, so the (provider_code, external_event_id) // UNIQUE is the SOLE backstop — exactly the state a true concurrent insert reaches when both requests read // "no existing event" before either commits. Pre-seed the colliding empty-key row so the handler's own // insert loses the unique race and must be treated as an idempotent no-op (DbUpdateException → duplicate). var existing = new PaymentWebhookEvent { ProviderCode = "zarinpal", ExternalEventId = string.Empty, EventType = "payment.succeeded", SignatureValid = true, PayloadJson = "{}", ReceivedAt = Now.UtcDateTime }; existing.MarkProcessed(null, Now.UtcDateTime); host.Db.Set().Add(existing); host.Db.SaveChanges(); var confirm = new ConfirmPaymentAndPostLedgerCommandHandler( host.UnitOfWork, host.Config(), host.Clock(Now), host.PaymentProvider, host.Settlement, host.Serializer, host.Notifications(), TestSenders.WithTicketHooks()); var sender = SenderRoutingConfirmTo(confirm); var handler = new HandlePaymentWebhookCommandHandler(sender, host.UnitOfWork, host.Verifier, host.Lock, host.Clock(Now)); // No external_event_id in the body → verification.ExternalEventId == "" (the read-dedup is skipped). var body = $"{{\"event_type\":\"payment.succeeded\",\"gateway_reference_code\":\"{reference}\"}}"; var result = await handler.Handle( new HandlePaymentWebhookCommand("zarinpal", new Dictionary(), body), CancellationToken.None); Assert.True(result.IsSuccess); Assert.True(result.Result.Duplicate); Assert.Equal(WebhookProcessingStatus.Processed, result.Result.ProcessingStatus); // The insert lost the race → no confirm, no ledger, and only the pre-existing event row survives. await sender.DidNotReceive().Send(Arg.Any(), Arg.Any()); Assert.Empty(host.Db.Set().AsNoTracking()); Assert.Single(host.Db.Set().AsNoTracking()); } [Fact] public async Task Unverified_signature_callback_mutates_nothing() { using var host = new PaymentsTestHost(); var (_, reference) = await SeedPendingAsync(host); var confirm = new ConfirmPaymentAndPostLedgerCommandHandler( host.UnitOfWork, host.Config(), host.Clock(Now), host.PaymentProvider, host.Settlement, host.Serializer, host.Notifications(), TestSenders.WithTicketHooks()); var sender = SenderRoutingConfirmTo(confirm); var handler = new HandlePaymentWebhookCommandHandler(sender, host.UnitOfWork, host.Verifier, host.Lock, host.Clock(Now)); // The default MockWebhookVerifier marks a body carrying the invalid-signature marker as invalid. var body = $"{{\"external_event_id\":\"evt-x\",\"event_type\":\"payment.succeeded\",\"gateway_reference_code\":\"{reference}\",\"note\":\"INVALID_SIGNATURE\"}}"; var result = await handler.Handle( new HandlePaymentWebhookCommand("zarinpal", new Dictionary(), body), CancellationToken.None); Assert.True(result.IsSuccess); Assert.Equal(WebhookProcessingStatus.Ignored, result.Result.ProcessingStatus); await sender.DidNotReceive().Send(Arg.Any(), Arg.Any()); Assert.Empty(host.Db.Set().AsNoTracking()); Assert.Empty(host.Db.Set().AsNoTracking()); var txn = host.Db.Set().AsNoTracking().Single(); Assert.Equal(PaymentTransactionStatus.Pending, txn.Status); } }