Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 106 additions & 10 deletions PayBridge.SDK.Test/Unit/PaymentServiceRefundTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<RefundTransaction>(), It.IsAny<RefundResponse>()), Times.Once);
fixture.Transaction.Status.Should().Be(PaymentStatus.Refunded);
fixture.Transactions.Verify(repository =>
repository.UpdateAsync(fixture.Transaction), Times.Once);
}

[Fact]
Expand All @@ -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<RefundTransaction>(), It.IsAny<RefundResponse>()), Times.Once);
fixture.Transaction.Status.Should().Be(PaymentStatus.Successful);
fixture.Transactions.Verify(repository =>
repository.UpdateAsync(It.IsAny<PaymentTransaction>()), Times.Never);
}

[Fact]
Expand All @@ -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<RefundTransaction>(), It.IsAny<RefundResponse>()), Times.Once);
}

[Fact]
Expand All @@ -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<RefundTransaction>(), It.IsAny<RefundResponse>()), Times.Once);
}

[Fact]
Expand All @@ -113,6 +109,72 @@ await action.Should().ThrowAsync<Exception>()
gateway.RefundPaymentAsync(It.IsAny<RefundRequest>()), Times.Never);
}

[Fact]
public async Task RefundPaymentAsync_replays_stored_response_for_same_idempotency_request()
{
Comment on lines +112 to +114
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<RefundRequest>()), 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<Exception>()
.WithMessage("*different refund parameters*");
fixture.Gateway.Verify(gateway =>
gateway.RefundPaymentAsync(It.IsAny<RefundRequest>()), Times.Never);
}

private static Fixture CreateFixture(
RefundResponse? response = null,
Exception? exception = null)
Expand All @@ -138,8 +200,29 @@ private static Fixture CreateFixture(
transaction.Amount))
.Callback<RefundTransaction, decimal>((refund, _) => capturedRefund = refund)
.ReturnsAsync(true);
refunds.Setup(repository => repository.GetByIdempotencyKeyAsync(It.IsAny<string>()))
.ReturnsAsync((RefundTransaction?)null);
refunds.Setup(repository => repository.UpdateAsync(It.IsAny<RefundTransaction>()))
.ReturnsAsync((RefundTransaction refund) => refund);
refunds.Setup(repository => repository.FinalizeAsync(
It.IsAny<RefundTransaction>(),
It.IsAny<RefundResponse>()))
.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
? []
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions PayBridge.SDK/Dtos/Request/RefundRequest.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
10 changes: 10 additions & 0 deletions PayBridge.SDK/Entities/RefundTransaction.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@
namespace PayBridge.SDK.Entities;
public class RefundTransaction
{
/// <summary>
/// Optional application-level idempotency key for the refund attempt
/// </summary>
public string? IdempotencyKey { get; set; }

/// <summary>
/// Unique identifier for the refund
/// </summary>
Expand Down Expand Up @@ -48,6 +53,11 @@ public class RefundTransaction
/// </summary>
public string GatewayResponse { get; set; } = string.Empty;

/// <summary>
/// Stable fingerprint of the refund request used to validate idempotent retries
/// </summary>
public string RequestFingerprint { get; set; } = string.Empty;

/// <summary>
/// When the refund was created
/// </summary>
Expand Down
11 changes: 11 additions & 0 deletions PayBridge.SDK/Interfaces/IRefundRepository.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using PayBridge.SDK.Entities;
using PayBridge.SDK.Dtos.Response;

namespace PayBridge.SDK.Interfaces;

Expand All @@ -14,6 +15,11 @@ public interface IRefundRepository
/// </summary>
Task<bool> TryReserveAsync(RefundTransaction refund, decimal capturedAmount);

/// <summary>
/// Gets a refund by its idempotency key.
/// </summary>
Task<RefundTransaction?> GetByIdempotencyKeyAsync(string idempotencyKey);

/// <summary>
/// Gets a refund by its reference
/// </summary>
Expand All @@ -28,4 +34,9 @@ public interface IRefundRepository
/// Updates an existing refund
/// </summary>
Task<RefundTransaction> UpdateAsync(RefundTransaction refund);

/// <summary>
/// Finalizes a refund and recalculates the parent payment status transactionally.
/// </summary>
Task<RefundTransaction> FinalizeAsync(RefundTransaction refund, RefundResponse response);
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading