diff --git a/LiteCharms.Features.MidrandBooks/Payments/Events/Handlers/PayfastPaymentConfirmationReceivedEventHandler.cs b/LiteCharms.Features.MidrandBooks/Payments/Events/Handlers/PayfastPaymentConfirmationReceivedEventHandler.cs new file mode 100644 index 0000000..371c07b --- /dev/null +++ b/LiteCharms.Features.MidrandBooks/Payments/Events/Handlers/PayfastPaymentConfirmationReceivedEventHandler.cs @@ -0,0 +1,81 @@ +using LiteCharms.Features.Hasher; +using LiteCharms.Features.MidrandBooks.Orders; + +namespace LiteCharms.Features.MidrandBooks.Payments.Events.Handlers; + +public sealed class PayfastPaymentConfirmationReceivedEventHandler(PaymentService paymentService, + HashService hashService, OrderService orderService, ILogger logger) : + INotificationHandler +{ + public async ValueTask Handle(PayfastPaymentConfirmationReceivedEvent notification, CancellationToken cancellationToken) + { + var hashResult = hashService.DecodeLongIdHash(notification.Payload?.MPaymentId!); + if (hashResult.IsFailed) + { + logger.LogError("Failed to decode payment ID hash: {Hash}. Errors: {Errors}", notification.Payload?.MPaymentId, string.Join(", ", hashResult.Errors.Select(e => e.Message))); + throw new Exception($"Failed to decode payment ID hash: {notification.Payload?.MPaymentId}."); + } + + var orderResult = await orderService.GetOrderAsync(hashResult.Value, cancellationToken); + if (orderResult.IsFailed) + { + logger.LogError("Failed to retrieve order for payment ID: {PaymentId}. Errors: {Errors}", notification.Payload?.MPaymentId, string.Join(", ", orderResult.Errors.Select(e => e.Message))); + throw new Exception($"Failed to retrieve order for payment ID: {notification.Payload?.MPaymentId}."); + } + + var paymentResult = await paymentService.GetOrderPaymentAsync(orderResult.Value.CustomerId, cancellationToken); + if (paymentResult.IsFailed) + { + logger.LogError("Failed to retrieve payment for order ID: {OrderId}. Errors: {Errors}", orderResult.Value.Id, string.Join(", ", paymentResult.Errors.Select(e => e.Message))); + throw new Exception($"Failed to retrieve payment for order ID: {orderResult.Value.Id}."); + } + + var isAlreadyProcessed = await paymentService.HasLedgerEntryAsync(orderResult.Value.Id, paymentResult.Value.Id, 1, cancellationToken); + + if (isAlreadyProcessed.IsFailed) + { + logger.LogError("Failed to check existing ledger entry for order ID: {OrderId} and payment ID: {PaymentId}. Errors: {Errors}", orderResult.Value.Id, paymentResult.Value.Id, string.Join(", ", isAlreadyProcessed.Errors.Select(e => e.Message))); + throw new Exception($"Failed to check existing ledger entry for order ID: {orderResult.Value.Id} and payment ID: {paymentResult.Value.Id}."); + } + + if (isAlreadyProcessed.Value) + { + logger.LogInformation("Payment confirmation for payment ID: {PaymentId} has already been processed. Skipping.", notification.Payload?.MPaymentId); + return; + } + + var ledgerResult = await paymentService.WriteLedgerEntryAsync(new Models.CreateLedgerEntry + { + CustomerId = orderResult.Value.CustomerId, + OrderId = orderResult.Value.Id, + PaymentId = paymentResult.Value.Id, + Status = LedgerStatuses.Received, + PaymentGatewayId = 1, + PaymentGatewayReference = notification.CorrelationId, + }, cancellationToken); + + if (ledgerResult.IsFailed) + { + logger.LogError("Failed to write ledger entry for payment ID: {PaymentId}. Errors: {Errors}", notification.Payload?.MPaymentId, string.Join(", ", ledgerResult.Errors.Select(e => e.Message))); + throw new Exception($"Failed to write ledger entry for payment ID: {notification.Payload?.MPaymentId}."); + } + + var paymentCompletedResult = await paymentService.CompletePaymentAsync(paymentResult.Value.Id, PaymentStatuses.Paid, cancellationToken); + if (paymentCompletedResult.IsFailed) + { + logger.LogError("Failed to complete payment for order ID: {OrderId}. Errors: {Errors}", orderResult.Value.Id, string.Join(", ", paymentCompletedResult.Errors.Select(e => e.Message))); + throw new Exception($"Failed to complete payment for order ID: {orderResult.Value.Id}."); + } + + var orderCompletedResult = await orderService.UpdateOrderStatusAsync(orderResult.Value.Id, OrderStatus.Completed, cancellationToken); + if (orderCompletedResult.IsFailed) + { + logger.LogError("Failed to update order status to Completed for order ID: {OrderId}. Errors: {Errors}", orderResult.Value.Id, string.Join(", ", orderCompletedResult.Errors.Select(e => e.Message))); + throw new Exception($"Failed to update order status to Completed for order ID: {orderResult.Value.Id}."); + } + + logger.LogInformation("Received Payfast payment confirmation for payment ID: {PaymentId}", notification.Payload?.MPaymentId); + + // TODO: Publish MediatR notifications or queue downstream Quartz jobs (Discord, Shipping, Customer Email, Royalties) + } +} diff --git a/LiteCharms.Features.MidrandBooks/Payments/Events/PayfastPaymentConfirmationReceivedEvent.cs b/LiteCharms.Features.MidrandBooks/Payments/Events/PayfastPaymentConfirmationReceivedEvent.cs new file mode 100644 index 0000000..b322753 --- /dev/null +++ b/LiteCharms.Features.MidrandBooks/Payments/Events/PayfastPaymentConfirmationReceivedEvent.cs @@ -0,0 +1,22 @@ +using LiteCharms.Features.Abstractions; +using LiteCharms.Features.Models; + +namespace LiteCharms.Features.MidrandBooks.Payments.Events; + +public sealed class PayfastPaymentConfirmationReceivedEvent : EventBase, IEvent +{ + public string Name { get; set; } = nameof(PayfastPaymentConfirmationReceivedEvent); + + public PayfastWebhookPayload? Payload { get; set; } + + public PayfastPaymentConfirmationReceivedEvent() { } + + private PayfastPaymentConfirmationReceivedEvent(PayfastWebhookPayload? payload, string paymentId) + { + Payload = payload; + CorrelationId = paymentId; + } + + public static PayfastPaymentConfirmationReceivedEvent Create(PayfastWebhookPayload? payload, string paymentId) => + new(payload, paymentId); +} diff --git a/LiteCharms.Features.MidrandBooks/Payments/PaymentService.cs b/LiteCharms.Features.MidrandBooks/Payments/PaymentService.cs index 58e418b..7904491 100644 --- a/LiteCharms.Features.MidrandBooks/Payments/PaymentService.cs +++ b/LiteCharms.Features.MidrandBooks/Payments/PaymentService.cs @@ -7,6 +7,27 @@ namespace LiteCharms.Features.MidrandBooks.Payments; public sealed class PaymentService(IDbContextFactory contextFactory) : IService { + public async ValueTask> GetOrderPaymentAsync(long orderId, CancellationToken cancellationToken = default) + { + try + { + await using var context = await contextFactory.CreateDbContextAsync(cancellationToken); + + var payment = await context.Payments.AsNoTracking() + .Where(p => p.OrderId == orderId) + .OrderByDescending(p => p.Id) + .FirstOrDefaultAsync(cancellationToken); + + return payment is not null + ? Result.Ok(payment.ToModel()) + : Result.Fail("Could not find payment for the order"); + } + catch (Exception ex) + { + return Result.Fail(new Error(ex.Message).CausedBy(ex)); + } + } + public async ValueTask> GetRefundAsync(long refundId, CancellationToken cancellationToken = default) { try @@ -95,6 +116,25 @@ public sealed class PaymentService(IDbContextFactory cont } } + public async ValueTask> HasLedgerEntryAsync(long orderId, long paymentId, long gatewayId, CancellationToken cancellationToken = default) + { + try + { + await using var context = await contextFactory.CreateDbContextAsync(cancellationToken); + + var exists = await context.Ledger.AnyAsync(l => + l.OrderId == orderId && + l.PaymentId == paymentId && + l.PaymentGatewayId == gatewayId, cancellationToken); + + return Result.Ok(exists); + } + catch (Exception ex) + { + return Result.Fail(new Error(ex.Message).CausedBy(ex)); + } + } + public async ValueTask WriteLedgerEntryAsync(CreateLedgerEntry request, CancellationToken cancellationToken = default) { try diff --git a/LiteCharms.Features/Quartz/JobOrchestrator.cs b/LiteCharms.Features/Quartz/JobOrchestrator.cs index fae63c3..7c79b8a 100644 --- a/LiteCharms.Features/Quartz/JobOrchestrator.cs +++ b/LiteCharms.Features/Quartz/JobOrchestrator.cs @@ -23,7 +23,7 @@ public sealed class JobOrchestrator(ISchedulerFactory schedulerFactory) : IJobOr var trigger = global::Quartz.TriggerBuilder.Create() .WithIdentity(triggerKey) - .StartNow() + .StartNow() .Build(); await scheduler.ScheduleJob(job, new List { trigger }.AsReadOnly(), replace: true, cancellationToken);