diff --git a/AgentPayWatch.slnx b/AgentPayWatch.slnx
index be2a722..d5d6501 100644
--- a/AgentPayWatch.slnx
+++ b/AgentPayWatch.slnx
@@ -12,7 +12,14 @@
-
-
+
+
+
+
+
+
+
+
+
diff --git a/src/AgentPayWatch.Agents.Payment/PaymentProcessor.cs b/src/AgentPayWatch.Agents.Payment/PaymentProcessor.cs
new file mode 100644
index 0000000..8ef16c8
--- /dev/null
+++ b/src/AgentPayWatch.Agents.Payment/PaymentProcessor.cs
@@ -0,0 +1,182 @@
+using AgentPayWatch.Domain.Entities;
+using AgentPayWatch.Domain.Enums;
+using AgentPayWatch.Domain.Events;
+using AgentPayWatch.Domain.Interfaces;
+using AgentPayWatch.Infrastructure.Messaging;
+using Microsoft.Extensions.Logging;
+
+namespace AgentPayWatch.Agents.Payment;
+
+///
+/// Encapsulates the business logic for processing an approved payment.
+/// Extracted from PaymentWorker to allow direct unit testing without Service Bus infrastructure.
+///
+public sealed class PaymentProcessor
+{
+ private readonly IWatchRequestRepository _watchRepo;
+ private readonly IProductMatchRepository _matchRepo;
+ private readonly IPaymentTransactionRepository _transactionRepo;
+ private readonly IPaymentProvider _paymentProvider;
+ private readonly IEventPublisher _eventPublisher;
+ private readonly ILogger _logger;
+
+ public PaymentProcessor(
+ IWatchRequestRepository watchRepo,
+ IProductMatchRepository matchRepo,
+ IPaymentTransactionRepository transactionRepo,
+ IPaymentProvider paymentProvider,
+ IEventPublisher eventPublisher,
+ ILogger logger)
+ {
+ _watchRepo = watchRepo;
+ _matchRepo = matchRepo;
+ _transactionRepo = transactionRepo;
+ _paymentProvider = paymentProvider;
+ _eventPublisher = eventPublisher;
+ _logger = logger;
+ }
+
+ public async Task ProcessAsync(ApprovalDecided approvalEvent, CancellationToken ct)
+ {
+ var watchRequestId = approvalEvent.CorrelationId;
+ var matchId = approvalEvent.MatchId;
+ var approvalId = approvalEvent.ApprovalId;
+
+ _logger.LogInformation(
+ "Processing approved payment for watch {WatchId}, match {MatchId}, approval {ApprovalId}",
+ watchRequestId, matchId, approvalId);
+
+ var approvedWatches = await _watchRepo.GetByStatusAsync(WatchStatus.Approved);
+ var watch = approvedWatches.FirstOrDefault(w => w.Id == watchRequestId);
+
+ if (watch is null)
+ {
+ _logger.LogWarning(
+ "Watch request {WatchId} not found in Approved status. It may have already been processed.",
+ watchRequestId);
+ return;
+ }
+
+ var match = await _matchRepo.GetByIdAsync(matchId, watchRequestId);
+ if (match is null)
+ {
+ _logger.LogWarning(
+ "Product match {MatchId} not found for watch {WatchId}", matchId, watchRequestId);
+ return;
+ }
+
+ watch.UpdateStatus(WatchStatus.Purchasing, "Payment initiated");
+ await _watchRepo.UpdateAsync(watch);
+
+ _logger.LogInformation(
+ "Watch {WatchId} transitioned to Purchasing. Executing payment: {Amount} {Currency} to {Seller}",
+ watch.Id, match.Price, match.Currency, match.Seller);
+
+ var idempotencyKey = $"{matchId}:{approvalId}";
+ var paymentMethodToken = string.IsNullOrEmpty(watch.PaymentMethodToken)
+ ? "tok_demo"
+ : watch.PaymentMethodToken;
+
+ var paymentResult = await _paymentProvider.ExecutePaymentAsync(
+ idempotencyKey, match.Price, match.Currency, match.Seller, paymentMethodToken, ct);
+
+ if (paymentResult.Success)
+ await HandlePaymentSuccessAsync(watch, match, approvalId, idempotencyKey, paymentResult, ct);
+ else
+ await HandlePaymentFailureAsync(watch, match, approvalId, idempotencyKey, paymentResult, ct);
+ }
+
+ private async Task HandlePaymentSuccessAsync(
+ WatchRequest watch,
+ ProductMatch match,
+ Guid approvalId,
+ string idempotencyKey,
+ PaymentResult paymentResult,
+ CancellationToken ct)
+ {
+ var transaction = new PaymentTransaction
+ {
+ Id = Guid.NewGuid(),
+ MatchId = match.Id,
+ ApprovalId = approvalId,
+ WatchRequestId = watch.Id,
+ UserId = watch.UserId,
+ IdempotencyKey = idempotencyKey,
+ Amount = match.Price,
+ Currency = match.Currency,
+ Merchant = match.Seller,
+ Status = PaymentStatus.Succeeded,
+ PaymentProviderRef = paymentResult.ProviderReference ?? string.Empty,
+ InitiatedAt = DateTimeOffset.UtcNow,
+ CompletedAt = DateTimeOffset.UtcNow,
+ FailureReason = null
+ };
+
+ await _transactionRepo.CreateAsync(transaction);
+
+ watch.UpdateStatus(WatchStatus.Completed, $"Payment succeeded, ref: {paymentResult.ProviderReference}");
+ await _watchRepo.UpdateAsync(watch);
+
+ var paymentCompletedEvent = new PaymentCompleted(
+ MessageId: Guid.NewGuid(),
+ CorrelationId: watch.Id,
+ Timestamp: DateTimeOffset.UtcNow,
+ Source: "payment-agent",
+ TransactionId: transaction.Id,
+ Amount: match.Price,
+ Currency: match.Currency,
+ Merchant: match.Seller);
+
+ await _eventPublisher.PublishAsync(paymentCompletedEvent, TopicNames.PaymentCompleted, ct);
+
+ _logger.LogInformation(
+ "Payment succeeded for watch {WatchId}: {Amount} {Currency} to {Merchant}, ref: {ProviderRef}",
+ watch.Id, match.Price, match.Currency, match.Seller, paymentResult.ProviderReference);
+ }
+
+ private async Task HandlePaymentFailureAsync(
+ WatchRequest watch,
+ ProductMatch match,
+ Guid approvalId,
+ string idempotencyKey,
+ PaymentResult paymentResult,
+ CancellationToken ct)
+ {
+ var transaction = new PaymentTransaction
+ {
+ Id = Guid.NewGuid(),
+ MatchId = match.Id,
+ ApprovalId = approvalId,
+ WatchRequestId = watch.Id,
+ UserId = watch.UserId,
+ IdempotencyKey = idempotencyKey,
+ Amount = match.Price,
+ Currency = match.Currency,
+ Merchant = match.Seller,
+ Status = PaymentStatus.Failed,
+ PaymentProviderRef = string.Empty,
+ InitiatedAt = DateTimeOffset.UtcNow,
+ CompletedAt = null,
+ FailureReason = paymentResult.FailureReason
+ };
+
+ await _transactionRepo.CreateAsync(transaction);
+
+ watch.UpdateStatus(WatchStatus.Active, $"Payment failed: {paymentResult.FailureReason}. Returning to active scanning.");
+ await _watchRepo.UpdateAsync(watch);
+
+ var paymentFailedEvent = new PaymentFailed(
+ MessageId: Guid.NewGuid(),
+ CorrelationId: watch.Id,
+ Timestamp: DateTimeOffset.UtcNow,
+ Source: "payment-agent",
+ TransactionId: transaction.Id,
+ Reason: paymentResult.FailureReason ?? "Unknown failure");
+
+ await _eventPublisher.PublishAsync(paymentFailedEvent, TopicNames.PaymentFailed, ct);
+
+ _logger.LogInformation(
+ "Payment failed for watch {WatchId}: {Reason}. Watch returned to Active for re-scanning.",
+ watch.Id, paymentResult.FailureReason);
+ }
+}
diff --git a/src/AgentPayWatch.Agents.Payment/PaymentWorker.cs b/src/AgentPayWatch.Agents.Payment/PaymentWorker.cs
new file mode 100644
index 0000000..e5c39c3
--- /dev/null
+++ b/src/AgentPayWatch.Agents.Payment/PaymentWorker.cs
@@ -0,0 +1,129 @@
+using System.Text.Json;
+using AgentPayWatch.Domain.Enums;
+using AgentPayWatch.Domain.Events;
+using AgentPayWatch.Infrastructure.Messaging;
+using Azure.Messaging.ServiceBus;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+
+namespace AgentPayWatch.Agents.Payment;
+
+public sealed class PaymentWorker : BackgroundService
+{
+ private readonly ServiceBusClient _serviceBusClient;
+ private readonly PaymentProcessor _processor;
+ private readonly ILogger _logger;
+
+ private ServiceBusProcessor? _sbProcessor;
+
+ public PaymentWorker(
+ ServiceBusClient serviceBusClient,
+ PaymentProcessor processor,
+ ILogger logger)
+ {
+ _serviceBusClient = serviceBusClient;
+ _processor = processor;
+ _logger = logger;
+ }
+
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ _sbProcessor = _serviceBusClient.CreateProcessor(
+ TopicNames.ApprovalDecided,
+ "payment-agent",
+ new ServiceBusProcessorOptions
+ {
+ AutoCompleteMessages = false,
+ MaxConcurrentCalls = 1,
+ PrefetchCount = 0
+ });
+
+ _sbProcessor.ProcessMessageAsync += ProcessMessageAsync;
+ _sbProcessor.ProcessErrorAsync += ProcessErrorAsync;
+
+ _logger.LogInformation("PaymentWorker starting. Listening on topic '{Topic}', subscription 'payment-agent'",
+ TopicNames.ApprovalDecided);
+
+ await _sbProcessor.StartProcessingAsync(stoppingToken);
+
+ try
+ {
+ await Task.Delay(Timeout.Infinite, stoppingToken);
+ }
+ catch (OperationCanceledException)
+ {
+ // Expected on shutdown
+ }
+ }
+
+ private async Task ProcessMessageAsync(ProcessMessageEventArgs args)
+ {
+ var body = args.Message.Body.ToString();
+ _logger.LogInformation("PaymentWorker received message: {MessageId}", args.Message.MessageId);
+
+ ApprovalDecided? approvalEvent;
+ try
+ {
+ approvalEvent = JsonSerializer.Deserialize(body, new JsonSerializerOptions
+ {
+ PropertyNameCaseInsensitive = true
+ });
+ }
+ catch (JsonException ex)
+ {
+ _logger.LogError(ex, "Failed to deserialize ApprovalDecided event from message {MessageId}", args.Message.MessageId);
+ await args.CompleteMessageAsync(args.Message);
+ return;
+ }
+
+ if (approvalEvent is null)
+ {
+ _logger.LogWarning("Deserialized ApprovalDecided event was null. Message {MessageId}", args.Message.MessageId);
+ await args.CompleteMessageAsync(args.Message);
+ return;
+ }
+
+ if (approvalEvent.Decision != ApprovalDecision.Approved)
+ {
+ _logger.LogInformation(
+ "Skipping non-approved decision '{Decision}' for watch {WatchId}. Message {MessageId}",
+ approvalEvent.Decision, approvalEvent.CorrelationId, args.Message.MessageId);
+ await args.CompleteMessageAsync(args.Message);
+ return;
+ }
+
+ try
+ {
+ await _processor.ProcessAsync(approvalEvent, args.CancellationToken);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex,
+ "Error processing payment for watch {WatchId}, match {MatchId}",
+ approvalEvent.CorrelationId, approvalEvent.MatchId);
+ }
+
+ await args.CompleteMessageAsync(args.Message);
+ }
+
+ private Task ProcessErrorAsync(ProcessErrorEventArgs args)
+ {
+ _logger.LogError(args.Exception,
+ "PaymentWorker Service Bus error. Source: {ErrorSource}, Entity: {EntityPath}",
+ args.ErrorSource, args.FullyQualifiedNamespace);
+ return Task.CompletedTask;
+ }
+
+ public override async Task StopAsync(CancellationToken cancellationToken)
+ {
+ _logger.LogInformation("PaymentWorker stopping");
+
+ if (_sbProcessor is not null)
+ {
+ await _sbProcessor.StopProcessingAsync(cancellationToken);
+ await _sbProcessor.DisposeAsync();
+ }
+
+ await base.StopAsync(cancellationToken);
+ }
+}
diff --git a/src/AgentPayWatch.Agents.Payment/Program.cs b/src/AgentPayWatch.Agents.Payment/Program.cs
index 379616e..673ded2 100644
--- a/src/AgentPayWatch.Agents.Payment/Program.cs
+++ b/src/AgentPayWatch.Agents.Payment/Program.cs
@@ -1,6 +1,13 @@
+using AgentPayWatch.Agents.Payment;
+using AgentPayWatch.Infrastructure;
+
var builder = Host.CreateApplicationBuilder(args);
builder.AddServiceDefaults();
+builder.AddInfrastructureServices();
+
+builder.Services.AddSingleton();
+builder.Services.AddHostedService();
var host = builder.Build();
diff --git a/src/AgentPayWatch.Agents.Payment/appsettings.json b/src/AgentPayWatch.Agents.Payment/appsettings.json
new file mode 100644
index 0000000..549399a
--- /dev/null
+++ b/src/AgentPayWatch.Agents.Payment/appsettings.json
@@ -0,0 +1,13 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.Hosting.Lifetime": "Information",
+ "Azure.Messaging.ServiceBus": "Warning",
+ "Azure.Core": "Warning"
+ }
+ },
+ "Payment": {
+ "MockSuccessRatePercent": 90
+ }
+}
diff --git a/src/AgentPayWatch.Api/Contracts/TransactionResponse.cs b/src/AgentPayWatch.Api/Contracts/TransactionResponse.cs
new file mode 100644
index 0000000..2946633
--- /dev/null
+++ b/src/AgentPayWatch.Api/Contracts/TransactionResponse.cs
@@ -0,0 +1,36 @@
+using AgentPayWatch.Domain.Entities;
+
+namespace AgentPayWatch.Api.Contracts;
+
+public sealed record TransactionResponse(
+ Guid Id,
+ Guid MatchId,
+ Guid ApprovalId,
+ Guid WatchRequestId,
+ string UserId,
+ string IdempotencyKey,
+ decimal Amount,
+ string Currency,
+ string Merchant,
+ string Status,
+ string PaymentProviderRef,
+ DateTimeOffset InitiatedAt,
+ DateTimeOffset? CompletedAt,
+ string? FailureReason)
+{
+ public static TransactionResponse FromEntity(PaymentTransaction entity) => new(
+ Id: entity.Id,
+ MatchId: entity.MatchId,
+ ApprovalId: entity.ApprovalId,
+ WatchRequestId: entity.WatchRequestId,
+ UserId: entity.UserId,
+ IdempotencyKey: entity.IdempotencyKey,
+ Amount: entity.Amount,
+ Currency: entity.Currency,
+ Merchant: entity.Merchant,
+ Status: entity.Status.ToString(),
+ PaymentProviderRef: entity.PaymentProviderRef,
+ InitiatedAt: entity.InitiatedAt,
+ CompletedAt: entity.CompletedAt,
+ FailureReason: entity.FailureReason);
+}
diff --git a/src/AgentPayWatch.Api/Endpoints/TransactionEndpoints.cs b/src/AgentPayWatch.Api/Endpoints/TransactionEndpoints.cs
new file mode 100644
index 0000000..eb9e3c3
--- /dev/null
+++ b/src/AgentPayWatch.Api/Endpoints/TransactionEndpoints.cs
@@ -0,0 +1,60 @@
+using AgentPayWatch.Api.Contracts;
+using AgentPayWatch.Domain.Interfaces;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Routing;
+
+namespace AgentPayWatch.Api.Endpoints;
+
+public static class TransactionEndpoints
+{
+ public static IEndpointRouteBuilder MapTransactionEndpoints(this IEndpointRouteBuilder routes)
+ {
+ var group = routes.MapGroup("/api/transactions");
+
+ group.MapGet("/", async (
+ string userId,
+ IPaymentTransactionRepository transactionRepo) =>
+ {
+ if (string.IsNullOrWhiteSpace(userId))
+ {
+ return Results.BadRequest(new { error = "userId query parameter is required." });
+ }
+
+ var transactions = await transactionRepo.GetByUserIdAsync(userId);
+
+ var response = transactions
+ .OrderByDescending(t => t.InitiatedAt)
+ .Select(TransactionResponse.FromEntity)
+ .ToList();
+
+ return Results.Ok(response);
+ })
+ .WithName("GetTransactions")
+ .WithOpenApi();
+
+ group.MapGet("/{id:guid}", async (
+ Guid id,
+ string userId,
+ IPaymentTransactionRepository transactionRepo) =>
+ {
+ if (string.IsNullOrWhiteSpace(userId))
+ {
+ return Results.BadRequest(new { error = "userId query parameter is required." });
+ }
+
+ var transaction = await transactionRepo.GetByIdAsync(id, userId);
+
+ if (transaction is null)
+ {
+ return Results.NotFound(new { error = $"Transaction {id} not found." });
+ }
+
+ return Results.Ok(TransactionResponse.FromEntity(transaction));
+ })
+ .WithName("GetTransaction")
+ .WithOpenApi();
+
+ return routes;
+ }
+}
diff --git a/src/AgentPayWatch.Api/Program.cs b/src/AgentPayWatch.Api/Program.cs
index b31a4f2..2ba693d 100644
--- a/src/AgentPayWatch.Api/Program.cs
+++ b/src/AgentPayWatch.Api/Program.cs
@@ -50,6 +50,7 @@
app.MapWatchEndpoints();
app.MapMatchEndpoints();
app.MapCallbackEndpoints();
+app.MapTransactionEndpoints();
app.Run();
diff --git a/src/AgentPayWatch.Domain/Interfaces/IPaymentProvider.cs b/src/AgentPayWatch.Domain/Interfaces/IPaymentProvider.cs
new file mode 100644
index 0000000..4d4232f
--- /dev/null
+++ b/src/AgentPayWatch.Domain/Interfaces/IPaymentProvider.cs
@@ -0,0 +1,17 @@
+namespace AgentPayWatch.Domain.Interfaces;
+
+public record PaymentResult(
+ bool Success,
+ string? ProviderReference,
+ string? FailureReason);
+
+public interface IPaymentProvider
+{
+ Task ExecutePaymentAsync(
+ string idempotencyKey,
+ decimal amount,
+ string currency,
+ string merchant,
+ string paymentMethodToken,
+ CancellationToken ct);
+}
diff --git a/src/AgentPayWatch.Infrastructure/DependencyInjection.cs b/src/AgentPayWatch.Infrastructure/DependencyInjection.cs
index ee2f16f..6925520 100644
--- a/src/AgentPayWatch.Infrastructure/DependencyInjection.cs
+++ b/src/AgentPayWatch.Infrastructure/DependencyInjection.cs
@@ -30,6 +30,7 @@ public static IHostApplicationBuilder AddInfrastructureServices(
// Mocks
builder.Services.AddSingleton();
builder.Services.AddSingleton();
+ builder.Services.AddSingleton();
return builder;
}
diff --git a/src/AgentPayWatch.Infrastructure/Mocks/MockPaymentProvider.cs b/src/AgentPayWatch.Infrastructure/Mocks/MockPaymentProvider.cs
new file mode 100644
index 0000000..02b90e3
--- /dev/null
+++ b/src/AgentPayWatch.Infrastructure/Mocks/MockPaymentProvider.cs
@@ -0,0 +1,97 @@
+using System.Collections.Concurrent;
+using AgentPayWatch.Domain.Interfaces;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.Logging;
+
+namespace AgentPayWatch.Infrastructure.Mocks;
+
+public sealed class MockPaymentProvider : IPaymentProvider
+{
+ private readonly ILogger _logger;
+ private readonly int _successRatePercent;
+ private readonly ConcurrentDictionary _processedPayments = new();
+ private readonly Random _random = new();
+
+ private static readonly string[] FailureReasons =
+ [
+ "Insufficient funds",
+ "Card declined",
+ "Provider timeout"
+ ];
+
+ public MockPaymentProvider(
+ ILogger logger,
+ IConfiguration configuration)
+ {
+ _logger = logger;
+ _successRatePercent = configuration.GetValue("Payment:MockSuccessRatePercent", 90);
+ }
+
+ public async Task ExecutePaymentAsync(
+ string idempotencyKey,
+ decimal amount,
+ string currency,
+ string merchant,
+ string paymentMethodToken,
+ CancellationToken ct)
+ {
+ if (_processedPayments.TryGetValue(idempotencyKey, out var cachedResult))
+ {
+ _logger.LogInformation(
+ "Idempotent payment request detected for key {IdempotencyKey}. Returning cached result: Success={Success}",
+ idempotencyKey,
+ cachedResult.Success);
+ return cachedResult;
+ }
+
+ _logger.LogInformation(
+ "Processing payment: {Amount} {Currency} to {Merchant} with token {Token}, idempotency key {IdempotencyKey}",
+ amount,
+ currency,
+ merchant,
+ paymentMethodToken,
+ idempotencyKey);
+
+ var delayMs = _random.Next(1000, 2001);
+ await Task.Delay(delayMs, ct);
+
+ var roll = _random.Next(100);
+ PaymentResult result;
+
+ if (roll < _successRatePercent)
+ {
+ var providerRef = $"PAY-{Guid.NewGuid():N}";
+ result = new PaymentResult(
+ Success: true,
+ ProviderReference: providerRef,
+ FailureReason: null);
+
+ _logger.LogInformation(
+ "Payment succeeded for key {IdempotencyKey}: {Amount} {Currency} to {Merchant}, ref: {ProviderRef}",
+ idempotencyKey,
+ amount,
+ currency,
+ merchant,
+ providerRef);
+ }
+ else
+ {
+ var failureReason = FailureReasons[_random.Next(FailureReasons.Length)];
+ result = new PaymentResult(
+ Success: false,
+ ProviderReference: null,
+ FailureReason: failureReason);
+
+ _logger.LogWarning(
+ "Payment failed for key {IdempotencyKey}: {Amount} {Currency} to {Merchant}, reason: {Reason}",
+ idempotencyKey,
+ amount,
+ currency,
+ merchant,
+ failureReason);
+ }
+
+ _processedPayments.TryAdd(idempotencyKey, result);
+ return result;
+ }
+}
diff --git a/tests/AgentPayWatch.Agents.Payment.Tests/AgentPayWatch.Agents.Payment.Tests.csproj b/tests/AgentPayWatch.Agents.Payment.Tests/AgentPayWatch.Agents.Payment.Tests.csproj
new file mode 100644
index 0000000..1803270
--- /dev/null
+++ b/tests/AgentPayWatch.Agents.Payment.Tests/AgentPayWatch.Agents.Payment.Tests.csproj
@@ -0,0 +1,31 @@
+
+
+
+ net10.0
+ enable
+ enable
+ false
+ AgentPayWatch.Agents.Payment.Tests
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/AgentPayWatch.Agents.Payment.Tests/Fakes.cs b/tests/AgentPayWatch.Agents.Payment.Tests/Fakes.cs
new file mode 100644
index 0000000..912f451
--- /dev/null
+++ b/tests/AgentPayWatch.Agents.Payment.Tests/Fakes.cs
@@ -0,0 +1,145 @@
+using AgentPayWatch.Domain.Entities;
+using AgentPayWatch.Domain.Enums;
+using AgentPayWatch.Domain.Interfaces;
+
+namespace AgentPayWatch.Agents.Payment.Tests;
+
+// ── FakeWatchRequestRepository ────────────────────────────────────────────────
+
+internal sealed class FakeWatchRequestRepository : IWatchRequestRepository
+{
+ private readonly Dictionary _store;
+
+ public List UpdatedWatches { get; } = [];
+
+ public FakeWatchRequestRepository(params WatchRequest[] watches)
+ {
+ _store = watches.ToDictionary(w => w.Id);
+ }
+
+ public Task CreateAsync(WatchRequest watchRequest, CancellationToken ct = default)
+ {
+ _store[watchRequest.Id] = watchRequest;
+ return Task.FromResult(watchRequest);
+ }
+
+ public Task GetByIdAsync(Guid id, string? userId = null, CancellationToken ct = default) =>
+ Task.FromResult(_store.GetValueOrDefault(id));
+
+ public Task> GetByUserIdAsync(string userId, CancellationToken ct = default) =>
+ Task.FromResult>(
+ _store.Values.Where(w => w.UserId == userId).ToList());
+
+ public Task> GetByStatusAsync(WatchStatus status, CancellationToken ct = default) =>
+ Task.FromResult>(
+ _store.Values.Where(w => w.Status == status).ToList());
+
+ public Task UpdateAsync(WatchRequest watchRequest, CancellationToken ct = default)
+ {
+ _store[watchRequest.Id] = watchRequest;
+ UpdatedWatches.Add(watchRequest);
+ return Task.CompletedTask;
+ }
+
+ public WatchRequest? GetById(Guid id) => _store.GetValueOrDefault(id);
+}
+
+// ── FakeProductMatchRepository ────────────────────────────────────────────────
+
+internal sealed class FakeProductMatchRepository : IProductMatchRepository
+{
+ private readonly Dictionary _store;
+
+ public FakeProductMatchRepository(params ProductMatch[] matches)
+ {
+ _store = matches.ToDictionary(m => m.Id);
+ }
+
+ public Task CreateAsync(ProductMatch match, CancellationToken ct = default)
+ {
+ _store[match.Id] = match;
+ return Task.FromResult(match);
+ }
+
+ public Task GetByIdAsync(Guid id, Guid watchRequestId, CancellationToken ct = default) =>
+ Task.FromResult(_store.GetValueOrDefault(id));
+
+ public Task> GetByWatchRequestIdAsync(Guid watchRequestId, CancellationToken ct = default) =>
+ Task.FromResult>(
+ _store.Values.Where(m => m.WatchRequestId == watchRequestId).ToList());
+}
+
+// ── FakePaymentTransactionRepository ─────────────────────────────────────────
+
+internal sealed class FakePaymentTransactionRepository : IPaymentTransactionRepository
+{
+ private readonly Dictionary _store = [];
+
+ public List CreatedTransactions { get; } = [];
+
+ public Task CreateAsync(PaymentTransaction transaction, CancellationToken ct = default)
+ {
+ _store[transaction.Id] = transaction;
+ CreatedTransactions.Add(transaction);
+ return Task.FromResult(transaction);
+ }
+
+ public Task GetByIdAsync(Guid id, string userId, CancellationToken ct = default) =>
+ Task.FromResult(_store.GetValueOrDefault(id));
+
+ public Task> GetByUserIdAsync(string userId, CancellationToken ct = default) =>
+ Task.FromResult>(
+ _store.Values.Where(t => t.UserId == userId).ToList());
+
+ public Task UpdateAsync(PaymentTransaction transaction, CancellationToken ct = default)
+ {
+ _store[transaction.Id] = transaction;
+ return Task.CompletedTask;
+ }
+}
+
+// ── FakeEventPublisher ────────────────────────────────────────────────────────
+
+internal sealed class FakeEventPublisher : IEventPublisher
+{
+ public List<(object Event, string Topic)> PublishedEvents { get; } = [];
+
+ public Task PublishAsync(T message, string topicName, CancellationToken ct)
+ {
+ PublishedEvents.Add((message!, topicName));
+ return Task.CompletedTask;
+ }
+}
+
+// ── FakePaymentProvider ───────────────────────────────────────────────────────
+
+internal sealed class FakePaymentProvider : IPaymentProvider
+{
+ private readonly bool _alwaysSucceed;
+ private readonly string _failureReason;
+
+ public List<(string Key, decimal Amount, string Currency, string Merchant, string Token)> Calls { get; } = [];
+
+ public FakePaymentProvider(bool alwaysSucceed = true, string failureReason = "Card declined")
+ {
+ _alwaysSucceed = alwaysSucceed;
+ _failureReason = failureReason;
+ }
+
+ public Task ExecutePaymentAsync(
+ string idempotencyKey,
+ decimal amount,
+ string currency,
+ string merchant,
+ string paymentMethodToken,
+ CancellationToken ct)
+ {
+ Calls.Add((idempotencyKey, amount, currency, merchant, paymentMethodToken));
+
+ var result = _alwaysSucceed
+ ? new PaymentResult(true, $"PAY-{Guid.NewGuid():N}", null)
+ : new PaymentResult(false, null, _failureReason);
+
+ return Task.FromResult(result);
+ }
+}
diff --git a/tests/AgentPayWatch.Agents.Payment.Tests/MockPaymentProviderTests.cs b/tests/AgentPayWatch.Agents.Payment.Tests/MockPaymentProviderTests.cs
new file mode 100644
index 0000000..af4da4f
--- /dev/null
+++ b/tests/AgentPayWatch.Agents.Payment.Tests/MockPaymentProviderTests.cs
@@ -0,0 +1,142 @@
+using AgentPayWatch.Infrastructure.Mocks;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.Logging.Abstractions;
+using Xunit;
+
+namespace AgentPayWatch.Agents.Payment.Tests;
+
+public sealed class MockPaymentProviderTests
+{
+ // NOTE: MockPaymentProvider includes a 1-2s simulated delay per call.
+ // Tests are kept to the absolute minimum number of calls to stay fast.
+
+ // ── Success result shape ──────────────────────────────────────────────────
+
+ [Fact]
+ public async Task ExecutePaymentAsync_SuccessResult_HasProviderRefAndNoFailureReason()
+ {
+ var provider = BuildProvider(successRate: 100);
+
+ var result = await provider.ExecutePaymentAsync(
+ "key-1", 249.99m, "USD", "TechMart", "tok_demo", CancellationToken.None);
+
+ Assert.True(result.Success);
+ Assert.NotNull(result.ProviderReference);
+ Assert.StartsWith("PAY-", result.ProviderReference);
+ Assert.Null(result.FailureReason);
+ }
+
+ // ── Failure result shape ──────────────────────────────────────────────────
+
+ [Fact]
+ public async Task ExecutePaymentAsync_FailureResult_HasFailureReasonAndNoProviderRef()
+ {
+ var provider = BuildProvider(successRate: 0);
+
+ var result = await provider.ExecutePaymentAsync(
+ "key-1", 249.99m, "USD", "TechMart", "tok_demo", CancellationToken.None);
+
+ Assert.False(result.Success);
+ Assert.Null(result.ProviderReference);
+ Assert.NotNull(result.FailureReason);
+ Assert.NotEmpty(result.FailureReason);
+ }
+
+ [Fact]
+ public async Task ExecutePaymentAsync_FailureReason_IsOneOfKnownValues()
+ {
+ var provider = BuildProvider(successRate: 0);
+ string[] knownReasons = ["Insufficient funds", "Card declined", "Provider timeout"];
+
+ // 3 calls — enough to verify the reason is always drawn from the known set
+ for (var i = 0; i < 3; i++)
+ {
+ var result = await provider.ExecutePaymentAsync(
+ Guid.NewGuid().ToString(), 100m, "USD", "Shop", "tok", CancellationToken.None);
+
+ Assert.Contains(result.FailureReason!, knownReasons);
+ }
+ }
+
+ // ── Idempotency ───────────────────────────────────────────────────────────
+
+ [Fact]
+ public async Task ExecutePaymentAsync_ReturnsSameResult_ForDuplicateKey()
+ {
+ var provider = BuildProvider(successRate: 100);
+ const string key = "idempotent-key-abc";
+
+ var first = await provider.ExecutePaymentAsync(key, 100m, "USD", "Shop", "tok", CancellationToken.None);
+ var second = await provider.ExecutePaymentAsync(key, 100m, "USD", "Shop", "tok", CancellationToken.None);
+
+ Assert.Equal(first.Success, second.Success);
+ Assert.Equal(first.ProviderReference, second.ProviderReference);
+ Assert.Equal(first.FailureReason, second.FailureReason);
+ }
+
+ [Fact]
+ public async Task ExecutePaymentAsync_IdempotencyKey_CanPreserveFailure()
+ {
+ var provider = BuildProvider(successRate: 0);
+ const string key = "idempotent-failure-key";
+
+ var first = await provider.ExecutePaymentAsync(key, 100m, "USD", "Shop", "tok", CancellationToken.None);
+ var second = await provider.ExecutePaymentAsync(key, 100m, "USD", "Shop", "tok", CancellationToken.None);
+
+ Assert.False(first.Success);
+ Assert.Equal(first.FailureReason, second.FailureReason);
+ }
+
+ [Fact]
+ public async Task ExecutePaymentAsync_DifferentKeys_ProduceDifferentProviderRefs()
+ {
+ var provider = BuildProvider(successRate: 100);
+
+ var result1 = await provider.ExecutePaymentAsync("key-a", 100m, "USD", "Shop", "tok", CancellationToken.None);
+ var result2 = await provider.ExecutePaymentAsync("key-b", 100m, "USD", "Shop", "tok", CancellationToken.None);
+
+ Assert.True(result1.Success);
+ Assert.True(result2.Success);
+ Assert.NotEqual(result1.ProviderReference, result2.ProviderReference);
+ }
+
+ // ── Configuration ─────────────────────────────────────────────────────────
+
+ [Fact]
+ public async Task ExecutePaymentAsync_ReturnsValidResult_WhenNoConfigProvided()
+ {
+ // Default is 90% success — just verify the result structure is valid
+ var provider = new MockPaymentProvider(
+ NullLogger.Instance,
+ new ConfigurationBuilder().Build());
+
+ var result = await provider.ExecutePaymentAsync(
+ "key-default", 100m, "USD", "Shop", "tok", CancellationToken.None);
+
+ // Result must be either a clean success or a clean failure — never ambiguous
+ if (result.Success)
+ {
+ Assert.NotNull(result.ProviderReference);
+ Assert.Null(result.FailureReason);
+ }
+ else
+ {
+ Assert.Null(result.ProviderReference);
+ Assert.NotNull(result.FailureReason);
+ }
+ }
+
+ // ── Helpers ───────────────────────────────────────────────────────────────
+
+ private static MockPaymentProvider BuildProvider(int successRate)
+ {
+ var config = new ConfigurationBuilder()
+ .AddInMemoryCollection(new Dictionary
+ {
+ ["Payment:MockSuccessRatePercent"] = successRate.ToString()
+ })
+ .Build();
+
+ return new MockPaymentProvider(NullLogger.Instance, config);
+ }
+}
diff --git a/tests/AgentPayWatch.Agents.Payment.Tests/PaymentProcessorTests.cs b/tests/AgentPayWatch.Agents.Payment.Tests/PaymentProcessorTests.cs
new file mode 100644
index 0000000..003b3fe
--- /dev/null
+++ b/tests/AgentPayWatch.Agents.Payment.Tests/PaymentProcessorTests.cs
@@ -0,0 +1,353 @@
+using AgentPayWatch.Domain.Entities;
+using AgentPayWatch.Domain.Enums;
+using AgentPayWatch.Domain.Events;
+using AgentPayWatch.Infrastructure.Messaging;
+using Microsoft.Extensions.Logging.Abstractions;
+using Xunit;
+
+namespace AgentPayWatch.Agents.Payment.Tests;
+
+public sealed class PaymentProcessorTests
+{
+ // ── Watch not found ───────────────────────────────────────────────────────
+
+ [Fact]
+ public async Task ProcessAsync_DoesNothing_WhenWatchNotInApprovedStatus()
+ {
+ // Watch exists but in wrong status (Active, not Approved)
+ var watch = MakeWatch(WatchStatus.Active);
+ var (transactionRepo, publisher) = await RunAsync(
+ approvalEvent: MakeApprovalEvent(watchId: watch.Id),
+ watches: [watch],
+ matches: []);
+
+ Assert.Empty(transactionRepo.CreatedTransactions);
+ Assert.Empty(publisher.PublishedEvents);
+ }
+
+ [Fact]
+ public async Task ProcessAsync_DoesNothing_WhenWatchIdNotFound()
+ {
+ var (transactionRepo, publisher) = await RunAsync(
+ approvalEvent: MakeApprovalEvent(watchId: Guid.NewGuid()),
+ watches: [],
+ matches: []);
+
+ Assert.Empty(transactionRepo.CreatedTransactions);
+ Assert.Empty(publisher.PublishedEvents);
+ }
+
+ // ── Match not found ───────────────────────────────────────────────────────
+
+ [Fact]
+ public async Task ProcessAsync_DoesNothing_WhenMatchNotFound()
+ {
+ var watch = MakeWatch(WatchStatus.Approved);
+ var approvalEvent = MakeApprovalEvent(watchId: watch.Id, matchId: Guid.NewGuid());
+
+ var (transactionRepo, publisher) = await RunAsync(approvalEvent, [watch], []);
+
+ Assert.Empty(transactionRepo.CreatedTransactions);
+ Assert.Empty(publisher.PublishedEvents);
+ }
+
+ // ── Successful payment ────────────────────────────────────────────────────
+
+ [Fact]
+ public async Task ProcessAsync_CreatesTransaction_OnPaymentSuccess()
+ {
+ var (watch, match, approvalEvent) = MakeApprovedScenario();
+ var (transactionRepo, _) = await RunAsync(approvalEvent, [watch], [match], succeed: true);
+
+ Assert.Single(transactionRepo.CreatedTransactions);
+ var tx = transactionRepo.CreatedTransactions[0];
+ Assert.Equal(PaymentStatus.Succeeded, tx.Status);
+ Assert.Equal(match.Id, tx.MatchId);
+ Assert.Equal(approvalEvent.ApprovalId, tx.ApprovalId);
+ Assert.Equal(watch.Id, tx.WatchRequestId);
+ Assert.Equal(watch.UserId, tx.UserId);
+ Assert.Equal(match.Price, tx.Amount);
+ Assert.Equal(match.Currency, tx.Currency);
+ Assert.Equal(match.Seller, tx.Merchant);
+ Assert.NotNull(tx.CompletedAt);
+ Assert.Null(tx.FailureReason);
+ }
+
+ [Fact]
+ public async Task ProcessAsync_TransitionsWatchToCompleted_OnPaymentSuccess()
+ {
+ var (watch, match, approvalEvent) = MakeApprovedScenario();
+ var watchRepo = new FakeWatchRequestRepository(watch);
+
+ await RunWithRepoAsync(approvalEvent, watchRepo, [match], succeed: true);
+
+ Assert.Equal(WatchStatus.Completed, watchRepo.GetById(watch.Id)!.Status);
+ }
+
+ [Fact]
+ public async Task ProcessAsync_PublishesPaymentCompletedEvent_OnPaymentSuccess()
+ {
+ var (watch, match, approvalEvent) = MakeApprovedScenario();
+ var (_, publisher) = await RunAsync(approvalEvent, [watch], [match], succeed: true);
+
+ Assert.Single(publisher.PublishedEvents);
+ var (evt, topic) = publisher.PublishedEvents[0];
+ Assert.IsType(evt);
+ Assert.Equal(TopicNames.PaymentCompleted, topic);
+
+ var completed = (PaymentCompleted)evt;
+ Assert.Equal(watch.Id, completed.CorrelationId);
+ Assert.Equal(match.Price, completed.Amount);
+ Assert.Equal(match.Currency, completed.Currency);
+ Assert.Equal(match.Seller, completed.Merchant);
+ Assert.Equal("payment-agent", completed.Source);
+ }
+
+ [Fact]
+ public async Task ProcessAsync_TransitionsToPurchasingBeforeCallingProvider()
+ {
+ // Verifies the watch passes through Purchasing on the way to Completed
+ var (watch, match, approvalEvent) = MakeApprovedScenario();
+ var watchRepo = new FakeWatchRequestRepository(watch);
+
+ await RunWithRepoAsync(approvalEvent, watchRepo, [match], succeed: true);
+
+ var finalWatch = watchRepo.GetById(watch.Id)!;
+ Assert.Equal(WatchStatus.Completed, finalWatch.Status);
+ // Status history confirms the Purchasing intermediate state was recorded
+ Assert.Contains(finalWatch.StatusHistory, h => h.To == WatchStatus.Purchasing);
+ Assert.Contains(finalWatch.StatusHistory, h => h.To == WatchStatus.Completed);
+ }
+
+ // ── Failed payment ────────────────────────────────────────────────────────
+
+ [Fact]
+ public async Task ProcessAsync_CreatesFailedTransaction_OnPaymentFailure()
+ {
+ var (watch, match, approvalEvent) = MakeApprovedScenario();
+ var (transactionRepo, _) = await RunAsync(
+ approvalEvent, [watch], [match], succeed: false, failureReason: "Card declined");
+
+ Assert.Single(transactionRepo.CreatedTransactions);
+ var tx = transactionRepo.CreatedTransactions[0];
+ Assert.Equal(PaymentStatus.Failed, tx.Status);
+ Assert.Equal("Card declined", tx.FailureReason);
+ Assert.Empty(tx.PaymentProviderRef);
+ Assert.Null(tx.CompletedAt);
+ }
+
+ [Fact]
+ public async Task ProcessAsync_ReturnsWatchToActive_OnPaymentFailure()
+ {
+ var (watch, match, approvalEvent) = MakeApprovedScenario();
+ var watchRepo = new FakeWatchRequestRepository(watch);
+
+ await RunWithRepoAsync(approvalEvent, watchRepo, [match], succeed: false);
+
+ Assert.Equal(WatchStatus.Active, watchRepo.GetById(watch.Id)!.Status);
+ }
+
+ [Fact]
+ public async Task ProcessAsync_PublishesPaymentFailedEvent_OnPaymentFailure()
+ {
+ var (watch, match, approvalEvent) = MakeApprovedScenario();
+ var (_, publisher) = await RunAsync(
+ approvalEvent, [watch], [match], succeed: false, failureReason: "Insufficient funds");
+
+ Assert.Single(publisher.PublishedEvents);
+ var (evt, topic) = publisher.PublishedEvents[0];
+ Assert.IsType(evt);
+ Assert.Equal(TopicNames.PaymentFailed, topic);
+
+ var failed = (PaymentFailed)evt;
+ Assert.Equal(watch.Id, failed.CorrelationId);
+ Assert.Equal("Insufficient funds", failed.Reason);
+ Assert.Equal("payment-agent", failed.Source);
+ }
+
+ [Fact]
+ public async Task ProcessAsync_FailedPayment_TransitionsToPurchasingThenBackToActive()
+ {
+ // Verifies the watch passes through Purchasing before returning to Active
+ var (watch, match, approvalEvent) = MakeApprovedScenario();
+ var watchRepo = new FakeWatchRequestRepository(watch);
+
+ await RunWithRepoAsync(approvalEvent, watchRepo, [match], succeed: false);
+
+ var finalWatch = watchRepo.GetById(watch.Id)!;
+ Assert.Equal(WatchStatus.Active, finalWatch.Status);
+ // Status history confirms Purchasing was an intermediate state
+ Assert.Contains(finalWatch.StatusHistory, h => h.To == WatchStatus.Purchasing);
+ Assert.Contains(finalWatch.StatusHistory,
+ h => h.From == WatchStatus.Purchasing && h.To == WatchStatus.Active);
+ }
+
+ // ── Idempotency key format ────────────────────────────────────────────────
+
+ [Fact]
+ public async Task ProcessAsync_IdempotencyKey_IsMatchIdColonApprovalId()
+ {
+ var (watch, match, approvalEvent) = MakeApprovedScenario();
+ var paymentProvider = new FakePaymentProvider(alwaysSucceed: true);
+
+ await RunWithProviderAsync(
+ approvalEvent, new FakeWatchRequestRepository(watch), [match], paymentProvider);
+
+ Assert.Single(paymentProvider.Calls);
+ var expectedKey = $"{match.Id}:{approvalEvent.ApprovalId}";
+ Assert.Equal(expectedKey, paymentProvider.Calls[0].Key);
+ }
+
+ [Fact]
+ public async Task ProcessAsync_Transaction_IdempotencyKeyMatchesProviderCall()
+ {
+ var (watch, match, approvalEvent) = MakeApprovedScenario();
+ var (transactionRepo, _) = await RunAsync(approvalEvent, [watch], [match], succeed: true);
+
+ var tx = transactionRepo.CreatedTransactions[0];
+ Assert.Equal($"{match.Id}:{approvalEvent.ApprovalId}", tx.IdempotencyKey);
+ }
+
+ // ── Payment method token ──────────────────────────────────────────────────
+
+ [Fact]
+ public async Task ProcessAsync_UsesFallbackToken_WhenWatchHasNoPaymentMethodToken()
+ {
+ var (watch, match, approvalEvent) = MakeApprovedScenario();
+ watch.PaymentMethodToken = string.Empty;
+ var paymentProvider = new FakePaymentProvider(alwaysSucceed: true);
+
+ await RunWithProviderAsync(
+ approvalEvent, new FakeWatchRequestRepository(watch), [match], paymentProvider);
+
+ Assert.Equal("tok_demo", paymentProvider.Calls[0].Token);
+ }
+
+ [Fact]
+ public async Task ProcessAsync_UsesWatchToken_WhenWatchHasPaymentMethodToken()
+ {
+ var (watch, match, approvalEvent) = MakeApprovedScenario();
+ watch.PaymentMethodToken = "tok_real_card_xyz";
+ var paymentProvider = new FakePaymentProvider(alwaysSucceed: true);
+
+ await RunWithProviderAsync(
+ approvalEvent, new FakeWatchRequestRepository(watch), [match], paymentProvider);
+
+ Assert.Equal("tok_real_card_xyz", paymentProvider.Calls[0].Token);
+ }
+
+ // ── Success transaction details ───────────────────────────────────────────
+
+ [Fact]
+ public async Task ProcessAsync_SuccessTransaction_HasProviderRefSet()
+ {
+ var (watch, match, approvalEvent) = MakeApprovedScenario();
+ var (transactionRepo, _) = await RunAsync(approvalEvent, [watch], [match], succeed: true);
+
+ var tx = transactionRepo.CreatedTransactions[0];
+ Assert.NotEmpty(tx.PaymentProviderRef);
+ Assert.StartsWith("PAY-", tx.PaymentProviderRef);
+ }
+
+ // ── Helpers ───────────────────────────────────────────────────────────────
+
+ private static async Task<(FakePaymentTransactionRepository, FakeEventPublisher)> RunAsync(
+ ApprovalDecided approvalEvent,
+ WatchRequest[] watches,
+ ProductMatch[] matches,
+ bool succeed = true,
+ string failureReason = "Card declined")
+ {
+ var watchRepo = new FakeWatchRequestRepository(watches);
+ var provider = new FakePaymentProvider(succeed, failureReason);
+ return await RunWithProviderAsync(approvalEvent, watchRepo, matches, provider);
+ }
+
+ private static async Task RunWithRepoAsync(
+ ApprovalDecided approvalEvent,
+ FakeWatchRequestRepository watchRepo,
+ ProductMatch[] matches,
+ bool succeed = true,
+ string failureReason = "Card declined")
+ {
+ var provider = new FakePaymentProvider(succeed, failureReason);
+ await RunWithProviderAsync(approvalEvent, watchRepo, matches, provider);
+ }
+
+ private static async Task<(FakePaymentTransactionRepository, FakeEventPublisher)> RunWithProviderAsync(
+ ApprovalDecided approvalEvent,
+ FakeWatchRequestRepository watchRepo,
+ ProductMatch[] matches,
+ FakePaymentProvider paymentProvider)
+ {
+ var matchRepo = new FakeProductMatchRepository(matches);
+ var transactionRepo = new FakePaymentTransactionRepository();
+ var publisher = new FakeEventPublisher();
+
+ var processor = new PaymentProcessor(
+ watchRepo,
+ matchRepo,
+ transactionRepo,
+ paymentProvider,
+ publisher,
+ NullLogger.Instance);
+
+ await processor.ProcessAsync(approvalEvent, CancellationToken.None);
+
+ return (transactionRepo, publisher);
+ }
+
+ private static ApprovalDecided MakeApprovalEvent(
+ Guid? watchId = null,
+ Guid? matchId = null,
+ Guid? approvalId = null) =>
+ new(
+ MessageId: Guid.NewGuid(),
+ CorrelationId: watchId ?? Guid.NewGuid(),
+ Timestamp: DateTimeOffset.UtcNow,
+ Source: "approval-agent",
+ ApprovalId: approvalId ?? Guid.NewGuid(),
+ MatchId: matchId ?? Guid.NewGuid(),
+ Decision: ApprovalDecision.Approved);
+
+ private static WatchRequest MakeWatch(WatchStatus status, Guid? id = null) =>
+ new()
+ {
+ Id = id ?? Guid.NewGuid(),
+ UserId = "user-1",
+ ProductName = "Test Product",
+ MaxPrice = 999m,
+ Status = status,
+ StatusHistory = status switch
+ {
+ WatchStatus.Approved =>
+ [
+ new(WatchStatus.Active, WatchStatus.Matched, DateTimeOffset.UtcNow.AddMinutes(-30), null),
+ new(WatchStatus.Matched, WatchStatus.AwaitingApproval, DateTimeOffset.UtcNow.AddMinutes(-20), null),
+ new(WatchStatus.AwaitingApproval, WatchStatus.Approved, DateTimeOffset.UtcNow.AddMinutes(-5), null)
+ ],
+ _ => []
+ }
+ };
+
+ private static ProductMatch MakeMatch(Guid watchRequestId) =>
+ new()
+ {
+ Id = Guid.NewGuid(),
+ WatchRequestId = watchRequestId,
+ UserId = "user-1",
+ ProductName = "Test Product",
+ Price = 849.99m,
+ Currency = "USD",
+ Seller = "TechDeals Direct"
+ };
+
+ private static (WatchRequest watch, ProductMatch match, ApprovalDecided approvalEvent)
+ MakeApprovedScenario()
+ {
+ var watch = MakeWatch(WatchStatus.Approved);
+ var match = MakeMatch(watch.Id);
+ var approvalEvent = MakeApprovalEvent(watchId: watch.Id, matchId: match.Id);
+ return (watch, match, approvalEvent);
+ }
+}
diff --git a/tests/AgentPayWatch.Api.Tests/ApiTestFactory.cs b/tests/AgentPayWatch.Api.Tests/ApiTestFactory.cs
index 2cc17d1..5089674 100644
--- a/tests/AgentPayWatch.Api.Tests/ApiTestFactory.cs
+++ b/tests/AgentPayWatch.Api.Tests/ApiTestFactory.cs
@@ -21,6 +21,9 @@ public sealed class ApiTestFactory : WebApplicationFactory
public IProductMatchRepository MatchRepository { get; } =
Substitute.For();
+ public IPaymentTransactionRepository TransactionRepository { get; } =
+ Substitute.For();
+
public IEventPublisher EventPublisher { get; } =
Substitute.For();
@@ -60,6 +63,9 @@ protected override void ConfigureWebHost(IWebHostBuilder builder)
services.RemoveAll();
services.AddScoped(_ => MatchRepository);
+ services.RemoveAll();
+ services.AddScoped(_ => TransactionRepository);
+
services.RemoveAll();
services.AddSingleton(_ => EventPublisher);
});
diff --git a/tests/AgentPayWatch.Api.Tests/TransactionEndpointsTests.cs b/tests/AgentPayWatch.Api.Tests/TransactionEndpointsTests.cs
new file mode 100644
index 0000000..8b220ce
--- /dev/null
+++ b/tests/AgentPayWatch.Api.Tests/TransactionEndpointsTests.cs
@@ -0,0 +1,211 @@
+using System.Net;
+using System.Net.Http.Json;
+using AgentPayWatch.Domain.Entities;
+using AgentPayWatch.Domain.Enums;
+using NSubstitute;
+using Xunit;
+
+namespace AgentPayWatch.Api.Tests;
+
+public sealed class TransactionEndpointsTests : IClassFixture
+{
+ private readonly HttpClient _client;
+ private readonly ApiTestFactory _factory;
+
+ public TransactionEndpointsTests(ApiTestFactory factory)
+ {
+ _factory = factory;
+ _client = factory.CreateClient();
+ }
+
+ // ── GET /api/transactions ─────────────────────────────────────────────────
+
+ [Fact]
+ public async Task GetTransactions_Returns200WithList_WhenTransactionsExist()
+ {
+ var tx = MakeTransaction("user-1");
+ _factory.TransactionRepository
+ .GetByUserIdAsync("user-1", Arg.Any())
+ .Returns(new List { tx });
+
+ var response = await _client.GetAsync("/api/transactions?userId=user-1");
+
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ var body = await response.Content.ReadFromJsonAsync>();
+ Assert.NotNull(body);
+ Assert.Single(body);
+ Assert.Equal(tx.Id, body[0].Id);
+ Assert.Equal("Succeeded", body[0].Status);
+ }
+
+ [Fact]
+ public async Task GetTransactions_Returns200WithEmptyList_WhenNoTransactions()
+ {
+ _factory.TransactionRepository
+ .GetByUserIdAsync("empty-user", Arg.Any())
+ .Returns(new List());
+
+ var response = await _client.GetAsync("/api/transactions?userId=empty-user");
+
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ var body = await response.Content.ReadFromJsonAsync>();
+ Assert.NotNull(body);
+ Assert.Empty(body);
+ }
+
+ [Fact]
+ public async Task GetTransactions_Returns400_WhenUserIdMissing()
+ {
+ var response = await _client.GetAsync("/api/transactions?userId=");
+
+ Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task GetTransactions_ReturnsMostRecentFirst()
+ {
+ var older = MakeTransaction("user-1", initiatedAt: DateTimeOffset.UtcNow.AddHours(-2));
+ var newer = MakeTransaction("user-1", initiatedAt: DateTimeOffset.UtcNow.AddHours(-1));
+ var newest = MakeTransaction("user-1", initiatedAt: DateTimeOffset.UtcNow);
+
+ _factory.TransactionRepository
+ .GetByUserIdAsync("user-1", Arg.Any())
+ .Returns(new List { older, newest, newer }); // unsorted
+
+ var response = await _client.GetAsync("/api/transactions?userId=user-1");
+
+ var body = await response.Content.ReadFromJsonAsync>();
+ Assert.NotNull(body);
+ Assert.Equal(3, body.Count);
+ // Should be sorted newest→oldest
+ Assert.Equal(newest.Id, body[0].Id);
+ Assert.Equal(newer.Id, body[1].Id);
+ Assert.Equal(older.Id, body[2].Id);
+ }
+
+ [Fact]
+ public async Task GetTransactions_ResponseContainsAllFields()
+ {
+ var tx = MakeTransaction("user-1");
+ _factory.TransactionRepository
+ .GetByUserIdAsync("user-1", Arg.Any())
+ .Returns(new List { tx });
+
+ var response = await _client.GetAsync("/api/transactions?userId=user-1");
+ var body = await response.Content.ReadFromJsonAsync>();
+ Assert.NotNull(body);
+
+ var dto = body[0];
+ Assert.Equal(tx.Id, dto.Id);
+ Assert.Equal(tx.MatchId, dto.MatchId);
+ Assert.Equal(tx.ApprovalId, dto.ApprovalId);
+ Assert.Equal(tx.WatchRequestId, dto.WatchRequestId);
+ Assert.Equal(tx.UserId, dto.UserId);
+ Assert.Equal(tx.IdempotencyKey, dto.IdempotencyKey);
+ Assert.Equal(tx.Amount, dto.Amount);
+ Assert.Equal(tx.Currency, dto.Currency);
+ Assert.Equal(tx.Merchant, dto.Merchant);
+ Assert.Equal("Succeeded", dto.Status);
+ Assert.Equal(tx.PaymentProviderRef, dto.PaymentProviderRef);
+ Assert.NotNull(dto.CompletedAt);
+ Assert.Null(dto.FailureReason);
+ }
+
+ // ── GET /api/transactions/{id} ────────────────────────────────────────────
+
+ [Fact]
+ public async Task GetTransactionById_Returns200_WhenFound()
+ {
+ var tx = MakeTransaction("user-1");
+ _factory.TransactionRepository
+ .GetByIdAsync(tx.Id, "user-1", Arg.Any())
+ .Returns(tx);
+
+ var response = await _client.GetAsync($"/api/transactions/{tx.Id}?userId=user-1");
+
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ var body = await response.Content.ReadFromJsonAsync();
+ Assert.NotNull(body);
+ Assert.Equal(tx.Id, body.Id);
+ }
+
+ [Fact]
+ public async Task GetTransactionById_Returns404_WhenNotFound()
+ {
+ var id = Guid.NewGuid();
+ _factory.TransactionRepository
+ .GetByIdAsync(id, "user-1", Arg.Any())
+ .Returns((PaymentTransaction?)null);
+
+ var response = await _client.GetAsync($"/api/transactions/{id}?userId=user-1");
+
+ Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task GetTransactionById_Returns400_WhenUserIdMissing()
+ {
+ var response = await _client.GetAsync($"/api/transactions/{Guid.NewGuid()}?userId=");
+
+ Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task GetTransactionById_FailedTransaction_HasFailureReasonSet()
+ {
+ var tx = MakeTransaction("user-1", status: PaymentStatus.Failed, failureReason: "Insufficient funds");
+ _factory.TransactionRepository
+ .GetByIdAsync(tx.Id, "user-1", Arg.Any())
+ .Returns(tx);
+
+ var response = await _client.GetAsync($"/api/transactions/{tx.Id}?userId=user-1");
+ var body = await response.Content.ReadFromJsonAsync();
+
+ Assert.NotNull(body);
+ Assert.Equal("Failed", body.Status);
+ Assert.Equal("Insufficient funds", body.FailureReason);
+ }
+
+ // ── Helpers ───────────────────────────────────────────────────────────────
+
+ private static PaymentTransaction MakeTransaction(
+ string userId,
+ PaymentStatus status = PaymentStatus.Succeeded,
+ string? failureReason = null,
+ DateTimeOffset? initiatedAt = null) =>
+ new()
+ {
+ Id = Guid.NewGuid(),
+ MatchId = Guid.NewGuid(),
+ ApprovalId = Guid.NewGuid(),
+ WatchRequestId = Guid.NewGuid(),
+ UserId = userId,
+ IdempotencyKey = $"{Guid.NewGuid()}:{Guid.NewGuid()}",
+ Amount = 849.99m,
+ Currency = "USD",
+ Merchant = "TechDeals Direct",
+ Status = status,
+ PaymentProviderRef = status == PaymentStatus.Succeeded ? $"PAY-{Guid.NewGuid():N}" : string.Empty,
+ InitiatedAt = initiatedAt ?? DateTimeOffset.UtcNow,
+ CompletedAt = status == PaymentStatus.Succeeded ? DateTimeOffset.UtcNow : null,
+ FailureReason = failureReason
+ };
+}
+
+// ── Response DTO for deserialization ─────────────────────────────────────────
+
+file sealed record TransactionDto(
+ Guid Id,
+ Guid MatchId,
+ Guid ApprovalId,
+ Guid WatchRequestId,
+ string UserId,
+ string IdempotencyKey,
+ decimal Amount,
+ string Currency,
+ string Merchant,
+ string Status,
+ string PaymentProviderRef,
+ DateTimeOffset InitiatedAt,
+ DateTimeOffset? CompletedAt,
+ string? FailureReason);