diff --git a/PayBridge.SDK.Test/Unit/PaymentServiceRefundTests.cs b/PayBridge.SDK.Test/Unit/PaymentServiceRefundTests.cs index 3547ebb..5f118cd 100644 --- a/PayBridge.SDK.Test/Unit/PaymentServiceRefundTests.cs +++ b/PayBridge.SDK.Test/Unit/PaymentServiceRefundTests.cs @@ -34,10 +34,8 @@ public async Task RefundPaymentAsync_persists_confirmed_full_refund_and_updates_ fixture.Refund.RefundReference.Should().Be("provider-refund"); fixture.Refund.ProcessedAt.Should().Be(providerTimestamp); fixture.Refunds.Verify(repository => - repository.UpdateAsync(fixture.Refund), Times.Once); + repository.FinalizeAsync(It.IsAny(), It.IsAny()), Times.Once); fixture.Transaction.Status.Should().Be(PaymentStatus.Refunded); - fixture.Transactions.Verify(repository => - repository.UpdateAsync(fixture.Transaction), Times.Once); } [Fact] @@ -55,10 +53,8 @@ public async Task RefundPaymentAsync_keeps_payment_successful_while_provider_ref fixture.Refund.Status.Should().Be(PaymentStatus.Pending); fixture.Refunds.Verify(repository => - repository.UpdateAsync(fixture.Refund), Times.Once); + repository.FinalizeAsync(It.IsAny(), It.IsAny()), Times.Once); fixture.Transaction.Status.Should().Be(PaymentStatus.Successful); - fixture.Transactions.Verify(repository => - repository.UpdateAsync(It.IsAny()), Times.Never); } [Fact] @@ -78,7 +74,7 @@ public async Task RefundPaymentAsync_persists_rejected_provider_attempt_as_faile fixture.Refund.ProcessedAt.Should().NotBeNull(); fixture.Refund.GatewayResponse.Should().Contain("rejected"); fixture.Refunds.Verify(repository => - repository.UpdateAsync(fixture.Refund), Times.Once); + repository.FinalizeAsync(It.IsAny(), It.IsAny()), Times.Once); } [Fact] @@ -92,7 +88,7 @@ public async Task RefundPaymentAsync_marks_attempt_failed_when_gateway_throws() fixture.Refund.Status.Should().Be(PaymentStatus.Failed); fixture.Refund.ProcessedAt.Should().NotBeNull(); fixture.Refunds.Verify(repository => - repository.UpdateAsync(fixture.Refund), Times.Once); + repository.FinalizeAsync(It.IsAny(), It.IsAny()), Times.Once); } [Fact] @@ -113,6 +109,72 @@ await action.Should().ThrowAsync() gateway.RefundPaymentAsync(It.IsAny()), Times.Never); } + [Fact] + public async Task RefundPaymentAsync_replays_stored_response_for_same_idempotency_request() + { + var fixture = CreateFixture(new RefundResponse { Success = true }); + var stored = new RefundResponse + { + Success = true, + RefundReference = "stored-refund", + TransactionReference = "PAYMENT-1", + Message = "already processed", + Amount = 40m, + Status = PaymentStatus.Refunded, + RefundDate = DateTime.UtcNow.AddMinutes(-2) + }; + + fixture.Refunds + .Setup(repository => repository.GetByIdempotencyKeyAsync("refund-key-1")) + .ReturnsAsync(new RefundTransaction + { + Id = "refund-1", + IdempotencyKey = "refund-key-1", + PaymentTransactionReference = "PAYMENT-1", + Amount = 40m, + Currency = "NGN", + Status = PaymentStatus.Refunded, + RequestFingerprint = ComputeTestFingerprint(NewRequest(40m, "refund-key-1")), + GatewayResponse = System.Text.Json.JsonSerializer.Serialize(stored), + CreatedAt = DateTime.UtcNow.AddMinutes(-3), + ProcessedAt = stored.RefundDate + }); + + var response = await fixture.Service.RefundPaymentAsync(NewRequest(40m, "refund-key-1")); + + response.RefundReference.Should().Be("stored-refund"); + response.Status.Should().Be(PaymentStatus.Refunded); + fixture.Gateway.Verify(gateway => + gateway.RefundPaymentAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task RefundPaymentAsync_rejects_reused_idempotency_key_with_different_payload() + { + var fixture = CreateFixture(new RefundResponse { Success = true }); + + fixture.Refunds + .Setup(repository => repository.GetByIdempotencyKeyAsync("refund-key-2")) + .ReturnsAsync(new RefundTransaction + { + Id = "refund-2", + IdempotencyKey = "refund-key-2", + PaymentTransactionReference = "PAYMENT-1", + Amount = 20m, + Currency = "NGN", + Status = PaymentStatus.Refunded, + RequestFingerprint = ComputeTestFingerprint(NewRequest(20m, "refund-key-2")), + CreatedAt = DateTime.UtcNow + }); + + var action = () => fixture.Service.RefundPaymentAsync(NewRequest(30m, "refund-key-2")); + + await action.Should().ThrowAsync() + .WithMessage("*different refund parameters*"); + fixture.Gateway.Verify(gateway => + gateway.RefundPaymentAsync(It.IsAny()), Times.Never); + } + private static Fixture CreateFixture( RefundResponse? response = null, Exception? exception = null) @@ -138,8 +200,29 @@ private static Fixture CreateFixture( transaction.Amount)) .Callback((refund, _) => capturedRefund = refund) .ReturnsAsync(true); + refunds.Setup(repository => repository.GetByIdempotencyKeyAsync(It.IsAny())) + .ReturnsAsync((RefundTransaction?)null); refunds.Setup(repository => repository.UpdateAsync(It.IsAny())) .ReturnsAsync((RefundTransaction refund) => refund); + refunds.Setup(repository => repository.FinalizeAsync( + It.IsAny(), + It.IsAny())) + .ReturnsAsync((RefundTransaction refund, RefundResponse response) => + { + refund.RefundReference = string.IsNullOrWhiteSpace(response.RefundReference) + ? refund.Id + : response.RefundReference; + refund.Status = response.Success ? response.Status : PaymentStatus.Failed; + refund.ProcessedAt = response.RefundDate == default ? DateTime.UtcNow : response.RefundDate; + refund.GatewayResponse = response.Message; + + if (refund.Status == PaymentStatus.Refunded) + { + transaction.Status = PaymentStatus.Refunded; + } + + return refund; + }); refunds.Setup(repository => repository.GetByPaymentReferenceAsync("PAYMENT-1")) .ReturnsAsync(() => capturedRefund is null ? [] @@ -173,13 +256,26 @@ private static Fixture CreateFixture( () => capturedRefund!); } - private static RefundRequest NewRequest(decimal amount) => new() + private static RefundRequest NewRequest(decimal amount, string? idempotencyKey = null) => new() { TransactionReference = "PAYMENT-1", Amount = amount, - Reason = "requested_by_customer" + Reason = "requested_by_customer", + IdempotencyKey = idempotencyKey }; + private static string ComputeTestFingerprint(RefundRequest request) + { + var payload = System.Text.Json.JsonSerializer.Serialize(new + { + request.TransactionReference, + request.Amount, + Reason = request.Reason ?? string.Empty + }); + return Convert.ToHexString(System.Security.Cryptography.SHA256.HashData( + System.Text.Encoding.UTF8.GetBytes(payload))); + } + private sealed record Fixture( PaymentService Service, PaymentTransaction Transaction, diff --git a/PayBridge.SDK/Dtos/Request/RefundRequest.cs b/PayBridge.SDK/Dtos/Request/RefundRequest.cs index e28265c..ce1545a 100644 --- a/PayBridge.SDK/Dtos/Request/RefundRequest.cs +++ b/PayBridge.SDK/Dtos/Request/RefundRequest.cs @@ -1,6 +1,7 @@ namespace PayBridge.SDK.Application.Dtos.Request; public class RefundRequest { + public string? IdempotencyKey { get; set; } public string TransactionReference { get; set; } = string.Empty; public decimal Amount { get; set; } public string Reason { get; set; } = string.Empty; diff --git a/PayBridge.SDK/Entities/RefundTransaction.cs b/PayBridge.SDK/Entities/RefundTransaction.cs index ee9a065..651e634 100644 --- a/PayBridge.SDK/Entities/RefundTransaction.cs +++ b/PayBridge.SDK/Entities/RefundTransaction.cs @@ -3,6 +3,11 @@ namespace PayBridge.SDK.Entities; public class RefundTransaction { + /// + /// Optional application-level idempotency key for the refund attempt + /// + public string? IdempotencyKey { get; set; } + /// /// Unique identifier for the refund /// @@ -48,6 +53,11 @@ public class RefundTransaction /// public string GatewayResponse { get; set; } = string.Empty; + /// + /// Stable fingerprint of the refund request used to validate idempotent retries + /// + public string RequestFingerprint { get; set; } = string.Empty; + /// /// When the refund was created /// diff --git a/PayBridge.SDK/Interfaces/IRefundRepository.cs b/PayBridge.SDK/Interfaces/IRefundRepository.cs index 3cec411..abb946c 100644 --- a/PayBridge.SDK/Interfaces/IRefundRepository.cs +++ b/PayBridge.SDK/Interfaces/IRefundRepository.cs @@ -1,4 +1,5 @@ using PayBridge.SDK.Entities; +using PayBridge.SDK.Dtos.Response; namespace PayBridge.SDK.Interfaces; @@ -14,6 +15,11 @@ public interface IRefundRepository /// Task TryReserveAsync(RefundTransaction refund, decimal capturedAmount); + /// + /// Gets a refund by its idempotency key. + /// + Task GetByIdempotencyKeyAsync(string idempotencyKey); + /// /// Gets a refund by its reference /// @@ -28,4 +34,9 @@ public interface IRefundRepository /// Updates an existing refund /// Task UpdateAsync(RefundTransaction refund); + + /// + /// Finalizes a refund and recalculates the parent payment status transactionally. + /// + Task FinalizeAsync(RefundTransaction refund, RefundResponse response); } diff --git a/PayBridge.SDK/Migrations/20260719140635_AddRefundIdempotency.Designer.cs b/PayBridge.SDK/Migrations/20260719140635_AddRefundIdempotency.Designer.cs new file mode 100644 index 0000000..a097bd9 --- /dev/null +++ b/PayBridge.SDK/Migrations/20260719140635_AddRefundIdempotency.Designer.cs @@ -0,0 +1,148 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using PayBridge.SDK; + +#nullable disable + +namespace PayBridge.SDK.Migrations +{ + [DbContext(typeof(PayBridgeDbContext))] + [Migration("20260719140635_AddRefundIdempotency")] + partial class AddRefundIdempotency + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.29") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("PayBridge.SDK.Entities.PaymentTransaction", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("Amount") + .HasColumnType("decimal(18,2)"); + + b.Property("CompletedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Currency") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CustomerEmail") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CustomerName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Gateway") + .HasColumnType("int"); + + b.Property("GatewayResponse") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("IdempotencyKey") + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("RequestFingerprint") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("TransactionReference") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("IdempotencyKey") + .IsUnique() + .HasFilter("[IdempotencyKey] IS NOT NULL"); + + b.ToTable("Transactions"); + }); + + modelBuilder.Entity("PayBridge.SDK.Entities.RefundTransaction", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("Amount") + .HasColumnType("decimal(18,2)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Currency") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Gateway") + .HasColumnType("int"); + + b.Property("GatewayResponse") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("IdempotencyKey") + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("PaymentTransactionReference") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("ProcessedAt") + .HasColumnType("datetime2"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("RefundReference") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("RequestFingerprint") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Status") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("IdempotencyKey") + .IsUnique() + .HasFilter("[IdempotencyKey] IS NOT NULL"); + + b.HasIndex("PaymentTransactionReference", "Status"); + + b.ToTable("Refunds"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/PayBridge.SDK/Migrations/20260719140635_AddRefundIdempotency.cs b/PayBridge.SDK/Migrations/20260719140635_AddRefundIdempotency.cs new file mode 100644 index 0000000..033ead7 --- /dev/null +++ b/PayBridge.SDK/Migrations/20260719140635_AddRefundIdempotency.cs @@ -0,0 +1,75 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace PayBridge.SDK.Migrations +{ + /// + public partial class AddRefundIdempotency : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + var (keyType, fingerprintType) = GetProviderColumnTypes(); + migrationBuilder.AddColumn( + name: "IdempotencyKey", + table: "Refunds", + type: keyType, + maxLength: 255, + nullable: true); + + migrationBuilder.AddColumn( + name: "RequestFingerprint", + table: "Refunds", + type: fingerprintType, + maxLength: 64, + nullable: false, + defaultValue: ""); + + migrationBuilder.CreateIndex( + name: "IX_Refunds_IdempotencyKey", + table: "Refunds", + column: "IdempotencyKey", + unique: true, + filter: ActiveProvider.Contains("SqlServer") + ? "[IdempotencyKey] IS NOT NULL" + : null); + } + + private (string Key, string Fingerprint) GetProviderColumnTypes() + { + if (ActiveProvider.Contains("Npgsql")) + { + return ("character varying(255)", "character varying(64)"); + } + + if (ActiveProvider.Contains("MySql")) + { + return ("varchar(255)", "varchar(64)"); + } + + if (ActiveProvider.Contains("Sqlite")) + { + return ("TEXT", "TEXT"); + } + + return ("nvarchar(255)", "nvarchar(64)"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_Refunds_IdempotencyKey", + table: "Refunds"); + + migrationBuilder.DropColumn( + name: "IdempotencyKey", + table: "Refunds"); + + migrationBuilder.DropColumn( + name: "RequestFingerprint", + table: "Refunds"); + } + } +} diff --git a/PayBridge.SDK/Migrations/PayBridgeDbContextModelSnapshot.cs b/PayBridge.SDK/Migrations/PayBridgeDbContextModelSnapshot.cs index 2d1be26..9e5e825 100644 --- a/PayBridge.SDK/Migrations/PayBridgeDbContextModelSnapshot.cs +++ b/PayBridge.SDK/Migrations/PayBridgeDbContextModelSnapshot.cs @@ -102,6 +102,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired() .HasColumnType("nvarchar(max)"); + b.Property("IdempotencyKey") + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + b.Property("PaymentTransactionReference") .IsRequired() .HasColumnType("nvarchar(450)"); @@ -117,11 +121,20 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired() .HasColumnType("nvarchar(max)"); + b.Property("RequestFingerprint") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + b.Property("Status") .HasColumnType("int"); b.HasKey("Id"); + b.HasIndex("IdempotencyKey") + .IsUnique() + .HasFilter("[IdempotencyKey] IS NOT NULL"); + b.HasIndex("PaymentTransactionReference", "Status"); b.ToTable("Refunds"); diff --git a/PayBridge.SDK/Persistence/PayBridgeDbContext.cs b/PayBridge.SDK/Persistence/PayBridgeDbContext.cs index 28fed64..b358f7a 100644 --- a/PayBridge.SDK/Persistence/PayBridgeDbContext.cs +++ b/PayBridge.SDK/Persistence/PayBridgeDbContext.cs @@ -25,5 +25,15 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) modelBuilder.Entity() .HasIndex(refund => new { refund.PaymentTransactionReference, refund.Status }); + modelBuilder.Entity() + .HasIndex(refund => refund.IdempotencyKey) + .IsUnique() + .HasFilter("[IdempotencyKey] IS NOT NULL"); + modelBuilder.Entity() + .Property(refund => refund.IdempotencyKey) + .HasMaxLength(255); + modelBuilder.Entity() + .Property(refund => refund.RequestFingerprint) + .HasMaxLength(64); } } diff --git a/PayBridge.SDK/Repositories/RefundRepository.cs b/PayBridge.SDK/Repositories/RefundRepository.cs index c0467de..da6b588 100644 --- a/PayBridge.SDK/Repositories/RefundRepository.cs +++ b/PayBridge.SDK/Repositories/RefundRepository.cs @@ -1,8 +1,10 @@ using System.Data; +using System.Text.Json; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using PayBridge.SDK.Entities; using PayBridge.SDK.Enums; +using PayBridge.SDK.Dtos.Response; using PayBridge.SDK.Interfaces; namespace PayBridge.SDK; @@ -82,6 +84,13 @@ public async Task TryReserveAsync( } } + public async Task GetByIdempotencyKeyAsync(string idempotencyKey) + { + ArgumentException.ThrowIfNullOrWhiteSpace(idempotencyKey); + return await _dbContext.Refunds + .FirstOrDefaultAsync(refund => refund.IdempotencyKey == idempotencyKey); + } + public async Task GetByReferenceAsync(string reference) { ArgumentException.ThrowIfNullOrWhiteSpace(reference); @@ -107,6 +116,70 @@ public async Task UpdateAsync(RefundTransaction refund) return refund; } + public async Task FinalizeAsync(RefundTransaction refund, RefundResponse response) + { + ArgumentNullException.ThrowIfNull(refund); + ArgumentNullException.ThrowIfNull(response); + + var strategy = _dbContext.Database.CreateExecutionStrategy(); + return await strategy.ExecuteAsync(async () => + { + await using var transaction = await _dbContext.Database.BeginTransactionAsync( + IsolationLevel.Serializable); + + var trackedRefund = await _dbContext.Refunds + .FirstOrDefaultAsync(item => item.Id == refund.Id); + if (trackedRefund is null) + { + throw new InvalidOperationException($"Refund not found: {refund.Id}"); + } + + var payment = await _dbContext.Transactions + .FirstOrDefaultAsync(item => item.TransactionReference == trackedRefund.PaymentTransactionReference); + if (payment is null) + { + throw new InvalidOperationException( + $"Payment transaction not found: {trackedRefund.PaymentTransactionReference}"); + } + + trackedRefund.RefundReference = string.IsNullOrWhiteSpace(response.RefundReference) + ? trackedRefund.Id + : response.RefundReference; + var previousStatus = trackedRefund.Status; + trackedRefund.Status = response.Success ? response.Status : PaymentStatus.Failed; + trackedRefund.ProcessedAt = trackedRefund.Status == PaymentStatus.Pending + ? null + : response.RefundDate == default ? DateTime.UtcNow : response.RefundDate; + trackedRefund.GatewayResponse = JsonSerializer.Serialize(response); + + if (trackedRefund.Status == PaymentStatus.Refunded) + { + var confirmedAmount = await _dbContext.Refunds + .Where(item => + item.PaymentTransactionReference == trackedRefund.PaymentTransactionReference && + item.Status == PaymentStatus.Refunded) + .Select(item => item.Amount) + .SumAsync(); + + if (previousStatus != PaymentStatus.Refunded) + { + confirmedAmount += trackedRefund.Amount; + } + + if (confirmedAmount >= payment.Amount) + { + payment.Status = PaymentStatus.Refunded; + _dbContext.Transactions.Update(payment); + } + } + + _dbContext.Refunds.Update(trackedRefund); + await _dbContext.SaveChangesAsync(); + await transaction.CommitAsync(); + return trackedRefund; + }); + } + private static void PrepareForInsert(RefundTransaction refund) { if (string.IsNullOrWhiteSpace(refund.Id)) @@ -119,6 +192,11 @@ private static void PrepareForInsert(RefundTransaction refund) refund.RefundReference = refund.Id; } + if (string.IsNullOrWhiteSpace(refund.RequestFingerprint)) + { + refund.RequestFingerprint = string.Empty; + } + if (refund.CreatedAt == default) { refund.CreatedAt = DateTime.UtcNow; diff --git a/PayBridge.SDK/Services/PaymentService.cs b/PayBridge.SDK/Services/PaymentService.cs index b58a03b..76346e4 100644 --- a/PayBridge.SDK/Services/PaymentService.cs +++ b/PayBridge.SDK/Services/PaymentService.cs @@ -371,10 +371,50 @@ public async Task RefundPaymentAsync(RefundRequest request) throw new PaymentGatewayException($"Gateway {selectedGateway} is not configured"); } + var fingerprint = ComputeRefundFingerprint(request); + + if (!string.IsNullOrWhiteSpace(request.IdempotencyKey)) + { + var existingRefund = await _refundRepository.GetByIdempotencyKeyAsync(request.IdempotencyKey); + if (existingRefund is not null) + { + if (!CryptographicOperations.FixedTimeEquals( + Encoding.UTF8.GetBytes(existingRefund.RequestFingerprint), + Encoding.UTF8.GetBytes(fingerprint))) + { + throw new PaymentGatewayException( + "The idempotency key was already used with different refund parameters."); + } + + if (!string.IsNullOrWhiteSpace(existingRefund.GatewayResponse)) + { + var storedResponse = JsonSerializer.Deserialize(existingRefund.GatewayResponse) + ?? throw new PaymentGatewayException("The stored refund response is invalid."); + + return storedResponse; + } + + return new RefundResponse + { + Success = existingRefund.Status == PaymentStatus.Refunded, + TransactionReference = existingRefund.PaymentTransactionReference, + RefundReference = existingRefund.RefundReference, + Amount = existingRefund.Amount, + Status = existingRefund.Status, + Message = existingRefund.Status == PaymentStatus.Pending + ? "Refund is still processing" + : "Refund already recorded", + RefundDate = existingRefund.ProcessedAt ?? existingRefund.CreatedAt + }; + } + } + var refund = new RefundTransaction { Id = Guid.NewGuid().ToString("N"), PaymentTransactionReference = request.TransactionReference, + IdempotencyKey = string.IsNullOrWhiteSpace(request.IdempotencyKey) ? null : request.IdempotencyKey, + RequestFingerprint = fingerprint, Amount = request.Amount, Currency = transaction.Currency, Reason = request.Reason, @@ -399,13 +439,16 @@ public async Task RefundPaymentAsync(RefundRequest request) } catch (Exception ex) { - refund.Status = PaymentStatus.Failed; - refund.ProcessedAt = DateTime.UtcNow; - refund.GatewayResponse = JsonSerializer.Serialize(new + await _refundRepository.FinalizeAsync(refund, new RefundResponse { - ErrorType = ex.GetType().Name + Success = false, + TransactionReference = request.TransactionReference, + RefundReference = refund.RefundReference, + Amount = request.Amount, + Status = PaymentStatus.Failed, + Message = ex.Message, + RefundDate = DateTime.UtcNow }); - await _refundRepository.UpdateAsync(refund); _logger.LogError(ex, "Refund processing failed with {Gateway}", selectedGateway); throw new PaymentGatewayException($"Refund processing failed with {selectedGateway}", ex); } @@ -413,31 +456,29 @@ public async Task RefundPaymentAsync(RefundRequest request) _logger.LogInformation("Refund processing {Status}: {Reference}, Refund Reference: {RefundReference}", response.Success ? "successful" : "failed", request.TransactionReference, response.RefundReference); - refund.RefundReference = string.IsNullOrWhiteSpace(response.RefundReference) - ? refund.Id - : response.RefundReference; - refund.Status = response.Success ? response.Status : PaymentStatus.Failed; - refund.ProcessedAt = refund.Status == PaymentStatus.Pending - ? null - : response.RefundDate == default ? DateTime.UtcNow : response.RefundDate; - refund.GatewayResponse = JsonSerializer.Serialize(response); - await _refundRepository.UpdateAsync(refund); + var finalizedRefund = await _refundRepository.FinalizeAsync(refund, response); - if (refund.Status == PaymentStatus.Refunded) + return new RefundResponse { - var refunds = await _refundRepository.GetByPaymentReferenceAsync( - request.TransactionReference); - var confirmedAmount = refunds - .Where(item => item.Status == PaymentStatus.Refunded) - .Sum(item => item.Amount); - if (confirmedAmount >= transaction.Amount) - { - transaction.Status = PaymentStatus.Refunded; - await _transactionRepository.UpdateAsync(transaction); - } - } + Success = finalizedRefund.Status == PaymentStatus.Refunded, + RefundReference = finalizedRefund.RefundReference, + TransactionReference = finalizedRefund.PaymentTransactionReference, + Message = response.Message, + Amount = finalizedRefund.Amount, + Status = finalizedRefund.Status, + RefundDate = finalizedRefund.ProcessedAt ?? finalizedRefund.CreatedAt + }; + } - return response; + private static string ComputeRefundFingerprint(RefundRequest request) + { + var payload = JsonSerializer.Serialize(new + { + request.TransactionReference, + request.Amount, + Reason = request.Reason ?? string.Empty + }); + return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(payload))); } private PaymentGatewayType SelectBestGateway(PaymentRequest request)