Skip to content

Implement refund idempotency and transactional reconciliation (#115 #116 #117)#128

Merged
teesofttech merged 4 commits into
masterfrom
core/issue-115-116-117-refund-reconciliation
Jul 19, 2026
Merged

Implement refund idempotency and transactional reconciliation (#115 #116 #117)#128
teesofttech merged 4 commits into
masterfrom
core/issue-115-116-117-refund-reconciliation

Conversation

@teesofttech

@teesofttech teesofttech commented Jul 19, 2026

Copy link
Copy Markdown
Owner

Summary

  • add refund idempotency support via request and persistence contracts
  • add transactional refund finalization in repository to reconcile refund + payment state atomically
  • refactor refund flow in payment service to replay idempotent retries and reject mismatched payload reuse
  • add EF Core migration for refund idempotency key and request fingerprint
  • expand refund unit tests for idempotent replay and mismatched-key rejection

Validation

  • dotnet test PayBridge.SDK.Test/PayBridge.SDK.Test.csproj --filter "FullyQualifiedNamePaymentServiceRefundTests|FullyQualifiedNameRefundRepositoryTests"
  • dotnet test PayBridge.SDK.Test/PayBridge.SDK.Test.csproj --filter "FullyQualifiedName~Refund"
  • dotnet build PayBridge.SDK.sln -c Release

Closes #115
Closes #116
Closes #117

Summary by CodeRabbit

  • New Features
    • Added refund idempotency support via an optional idempotency key to prevent duplicate refund attempts on retries.
    • Identical retry requests now replay the previously stored refund result.
    • Reusing a key with different refund details is rejected.
    • Refund outcomes are now finalized consistently and the related payment is updated when the refund is confirmed.
  • Database
    • Updated the schema to store refund idempotency keys and request fingerprints with uniqueness enforcement.
  • Tests
    • Added unit tests covering idempotent replay, conflicting-request rejection, and the refund finalization flow.

Copilot AI review requested due to automatic review settings July 19, 2026 15:22
@coderabbitai

coderabbitai Bot commented Jul 19, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 4f99931d-1804-4093-b447-7e68b0aa0530

📥 Commits

Reviewing files that changed from the base of the PR and between cbfa7b1 and c231f44.

📒 Files selected for processing (2)
  • PayBridge.SDK/Migrations/20260719140635_AddRefundIdempotency.cs
  • PayBridge.SDK/Repositories/RefundRepository.cs
🚧 Files skipped from review as they are similar to previous changes (2)
  • PayBridge.SDK/Migrations/20260719140635_AddRefundIdempotency.cs
  • PayBridge.SDK/Repositories/RefundRepository.cs

📝 Walkthrough

Walkthrough

Refund processing now supports idempotency keys and request fingerprints, replays matching stored outcomes, rejects mismatched retries, and finalizes refund and payment state transactionally through the repository.

Changes

Refund idempotency and finalization

Layer / File(s) Summary
Refund contracts and persistence schema
PayBridge.SDK/Dtos/Request/RefundRequest.cs, PayBridge.SDK/Entities/RefundTransaction.cs, PayBridge.SDK/Interfaces/IRefundRepository.cs, PayBridge.SDK/Persistence/..., PayBridge.SDK/Migrations/...
Adds idempotency and fingerprint fields, repository lookup/finalization contracts, EF constraints, and the corresponding migration.
Refund idempotency and transactional finalization
PayBridge.SDK/Services/PaymentService.cs, PayBridge.SDK/Repositories/RefundRepository.cs
Computes and validates fingerprints, replays stored responses, rejects mismatched keys, and finalizes refund outcomes and parent payment status transactionally.
Refund flow and idempotency tests
PayBridge.SDK.Test/Unit/PaymentServiceRefundTests.cs
Updates finalization assertions and adds coverage for response replay, mismatched payload rejection, and gateway suppression.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant PaymentService
  participant RefundRepository
  participant PaymentGateway
  Client->>PaymentService: Submit refund with IdempotencyKey
  PaymentService->>RefundRepository: Look up stored refund
  RefundRepository-->>PaymentService: Stored refund or no match
  PaymentService->>PaymentGateway: Execute refund for new request
  PaymentGateway-->>PaymentService: RefundResponse
  PaymentService->>RefundRepository: Finalize refund and payment state
  RefundRepository-->>PaymentService: Finalized refund
  PaymentService-->>Client: RefundResponse
Loading

Possibly related PRs

Suggested labels: area: persistence, area: core, priority: P1

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Linked Issues check ⚠️ Warning [#115 #116 #117] The PR adds idempotency and finalize logic, but it lacks gateway-specific reconciliation, webhook/poller integration, and provider-backed contention tests. Add gateway reconciliation support, invoke finalization from webhook and polling paths, and include provider-backed concurrency/retry tests across supported databases.
Docstring Coverage ⚠️ Warning Docstring coverage is 21.43% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: refund idempotency plus transactional reconciliation.
Out of Scope Changes check ✅ Passed The changes stay focused on refund idempotency, finalization, schema updates, and related tests.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch core/issue-115-116-117-refund-reconciliation

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds application-level refund idempotency and introduces a transactional “finalize refund” operation in the persistence layer so refund completion and parent payment status reconciliation can be persisted atomically.

Changes:

  • Add refund idempotency via an IdempotencyKey + stable request fingerprint and replay logic in PaymentService.
  • Add FinalizeAsync in RefundRepository to persist refund status + reconcile payment status within a serializable transaction.
  • Add EF Core schema changes (columns + index) and expand refund unit tests for idempotent replay and mismatched-payload rejection.

Reviewed changes

Copilot reviewed 9 out of 10 changed files in this pull request and generated 8 comments.

Show a summary per file
File Description
PayBridge.SDK/Services/PaymentService.cs Adds refund idempotency replay/mismatch checks and routes refund persistence through transactional finalization.
PayBridge.SDK/Repositories/RefundRepository.cs Adds GetByIdempotencyKeyAsync and FinalizeAsync to reconcile refund + payment state transactionally.
PayBridge.SDK/Persistence/PayBridgeDbContext.cs Adds refund idempotency index and column constraints in the EF model.
PayBridge.SDK/Migrations/PayBridgeDbContextModelSnapshot.cs Updates model snapshot to include refund idempotency key + request fingerprint.
PayBridge.SDK/Migrations/20260719140635_AddRefundIdempotency.Designer.cs New migration designer snapshot for the refund idempotency schema changes.
PayBridge.SDK/Migrations/20260719140635_AddRefundIdempotency.cs New migration adding refund idempotency columns and unique index.
PayBridge.SDK/Interfaces/IRefundRepository.cs Extends repository contract with idempotency lookup and transactional finalization API.
PayBridge.SDK/Entities/RefundTransaction.cs Adds IdempotencyKey and RequestFingerprint fields to persisted refund entity.
PayBridge.SDK/Dtos/Request/RefundRequest.cs Adds optional IdempotencyKey to refund request DTO.
PayBridge.SDK.Test/Unit/PaymentServiceRefundTests.cs Adds tests for idempotent replay and mismatched-payload idempotency rejection; updates expectations for finalization.
Files not reviewed (1)
  • PayBridge.SDK/Migrations/20260719140635_AddRefundIdempotency.Designer.cs: Generated file

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread PayBridge.SDK/Services/PaymentService.cs
Comment on lines +376 to +378
if (!string.IsNullOrWhiteSpace(request.IdempotencyKey))
{
var existingRefund = await _refundRepository.GetByIdempotencyKeyAsync(request.IdempotencyKey);
Comment on lines 418 to 420
Amount = request.Amount,
Currency = transaction.Currency,
Reason = request.Reason,
Comment thread PayBridge.SDK/Repositories/RefundRepository.cs
Comment on lines +189 to +192
if (string.IsNullOrWhiteSpace(refund.RequestFingerprint))
{
refund.RequestFingerprint = string.Empty;
}
Comment on lines +28 to +31
modelBuilder.Entity<RefundTransaction>()
.HasIndex(refund => refund.IdempotencyKey)
.IsUnique()
.HasFilter("[IdempotencyKey] IS NOT NULL");
Comment thread PayBridge.SDK/Migrations/20260719140635_AddRefundIdempotency.cs
Comment on lines +112 to +114
[Fact]
public async Task RefundPaymentAsync_replays_stored_response_for_same_idempotency_request()
{

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
PayBridge.SDK/Services/PaymentService.cs (2)

412-424: 🗄️ Data Integrity & Integration | 🔴 Critical | ⚡ Quick win

IdempotencyKey defaults to empty string instead of null, breaking the filtered unique index for every non-idempotent refund.

The filtered unique index on RefundTransaction.IdempotencyKey is WHERE [IdempotencyKey] IS NOT NULL (see PayBridgeDbContext.cs), meaning it should only constrain refunds that actually supply a key. Coalescing to string.Empty here makes every key-less refund store "", which is not null, so the second refund without an idempotency key anywhere in the system will violate the unique index and throw an unhandled DbUpdateException from TryReserveAsync. Compare with the payment path, where non-idempotent PaymentTransactions simply leave IdempotencyKey unset (null).

🐛 Proposed fix
-            IdempotencyKey = request.IdempotencyKey ?? string.Empty,
+            IdempotencyKey = request.IdempotencyKey,
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@PayBridge.SDK/Services/PaymentService.cs` around lines 412 - 424, Update the
RefundTransaction initializer in the refund creation flow to assign
request.IdempotencyKey directly, preserving null for requests without an
idempotency key. Keep the existing value unchanged when a key is supplied and
align this behavior with the PaymentTransaction creation path.

374-433: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Refund idempotency path lacks the concurrency safeguards used in the analogous payment path.

CreateIdempotentPaymentAsync protects against racing duplicate requests with an in-process semaphore lock (AcquireIdempotencyLock) plus a catch (DbUpdateException) fallback that re-fetches and replays the winning attempt. The refund path here has neither: two concurrent requests with the same IdempotencyKey can both pass the existingRefund is null check and both call TryReserveAsync, so the loser's insert will throw an unhandled DbUpdateException from the new unique index instead of gracefully replaying the stored outcome. Separately, there's no length check on request.IdempotencyKey before it's persisted into the nvarchar(255) column (the payment path validates Length > 255 up front).

Recommend mirroring the payment path: acquire a per-key lock around the lookup/create/reserve sequence, catch DbUpdateException around TryReserveAsync to re-fetch and replay via the existing lookup logic, and validate key length early.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@PayBridge.SDK/Services/PaymentService.cs` around lines 374 - 433, The refund
idempotency flow in the enclosing refund method lacks the safeguards used by
CreateIdempotentPaymentAsync. Validate request.IdempotencyKey length before
persistence, acquire the existing per-key lock via AcquireIdempotencyLock around
the lookup/create/TryReserveAsync sequence, and handle DbUpdateException from
TryReserveAsync by re-fetching the existing refund and replaying it through the
current stored-response/result logic.
🧹 Nitpick comments (1)
PayBridge.SDK.Test/Unit/PaymentServiceRefundTests.cs (1)

203-225: 🗄️ Data Integrity & Integration | 🔵 Trivial | 🏗️ Heavy lift

Mocked FinalizeAsync/TryReserveAsync can't catch real EF-layer bugs (e.g., unique-index or query-ordering defects in RefundRepository).

This fixture hand-rolls the finalize/reserve behavior instead of exercising a real DbContext, so it can't surface issues like a filtered unique index being violated by an unintended non-null value, or a SUM query racing ahead of a pending SaveChangesAsync(). Given the PR objective calls for "provider-backed contention tests using independent contexts," consider adding an EF Core-backed (Sqlite/InMemory or real provider) integration test suite specifically for RefundRepository to close this gap.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@PayBridge.SDK.Test/Unit/PaymentServiceRefundTests.cs` around lines 203 - 225,
Replace the hand-rolled FinalizeAsync/TryReserveAsync mock coverage in the
refund tests with an EF Core-backed integration suite for RefundRepository using
independent DbContext instances. Exercise provider-backed contention and
persistence flows so unique-index violations, query ordering, and pending
SaveChangesAsync behavior are validated against the real repository
implementation rather than mocked callbacks.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@PayBridge.SDK/Repositories/RefundRepository.cs`:
- Around line 145-174: Persist the updated trackedRefund before calculating
confirmedAmount in the refund finalization flow, ensuring the database aggregate
includes the current refund’s new status and amount. Reorder the
_dbContext.Refunds.Update(trackedRefund) and SaveChangesAsync calls ahead of the
PaymentStatus.Refunded check, while preserving the existing payment update,
transaction commit, and return behavior.

In `@PayBridge.SDK/Services/PaymentService.cs`:
- Around line 440-454: Update the exception handling around the refund gateway
call in the refund-processing method so transient or otherwise uncertain
provider failures are not finalized as PaymentStatus.Failed. Persist an explicit
uncertain/pending outcome, then use the provider’s refund-status query and
existing idempotency flow to reconcile whether the refund was accepted before
returning or throwing; ensure retries do not replay a definitive failure while
reconciliation remains unresolved.

---

Outside diff comments:
In `@PayBridge.SDK/Services/PaymentService.cs`:
- Around line 412-424: Update the RefundTransaction initializer in the refund
creation flow to assign request.IdempotencyKey directly, preserving null for
requests without an idempotency key. Keep the existing value unchanged when a
key is supplied and align this behavior with the PaymentTransaction creation
path.
- Around line 374-433: The refund idempotency flow in the enclosing refund
method lacks the safeguards used by CreateIdempotentPaymentAsync. Validate
request.IdempotencyKey length before persistence, acquire the existing per-key
lock via AcquireIdempotencyLock around the lookup/create/TryReserveAsync
sequence, and handle DbUpdateException from TryReserveAsync by re-fetching the
existing refund and replaying it through the current stored-response/result
logic.

---

Nitpick comments:
In `@PayBridge.SDK.Test/Unit/PaymentServiceRefundTests.cs`:
- Around line 203-225: Replace the hand-rolled FinalizeAsync/TryReserveAsync
mock coverage in the refund tests with an EF Core-backed integration suite for
RefundRepository using independent DbContext instances. Exercise provider-backed
contention and persistence flows so unique-index violations, query ordering, and
pending SaveChangesAsync behavior are validated against the real repository
implementation rather than mocked callbacks.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: d482c5f4-2ee4-4254-8668-f98a847db69d

📥 Commits

Reviewing files that changed from the base of the PR and between ef472ac and 21160bb.

📒 Files selected for processing (10)
  • PayBridge.SDK.Test/Unit/PaymentServiceRefundTests.cs
  • PayBridge.SDK/Dtos/Request/RefundRequest.cs
  • PayBridge.SDK/Entities/RefundTransaction.cs
  • PayBridge.SDK/Interfaces/IRefundRepository.cs
  • PayBridge.SDK/Migrations/20260719140635_AddRefundIdempotency.Designer.cs
  • PayBridge.SDK/Migrations/20260719140635_AddRefundIdempotency.cs
  • PayBridge.SDK/Migrations/PayBridgeDbContextModelSnapshot.cs
  • PayBridge.SDK/Persistence/PayBridgeDbContext.cs
  • PayBridge.SDK/Repositories/RefundRepository.cs
  • PayBridge.SDK/Services/PaymentService.cs

Comment on lines +145 to +174
trackedRefund.RefundReference = string.IsNullOrWhiteSpace(response.RefundReference)
? trackedRefund.Id
: response.RefundReference;
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 (confirmedAmount >= payment.Amount)
{
payment.Status = PaymentStatus.Refunded;
_dbContext.Transactions.Update(payment);
}
}

_dbContext.Refunds.Update(trackedRefund);
await _dbContext.SaveChangesAsync();
await transaction.CommitAsync();
return trackedRefund;
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🔴 Critical | ⚡ Quick win

Payment reconciliation undercounts the refund currently being finalized.

confirmedAmount is computed via a SQL aggregate query (Line 156-161) before trackedRefund's new Status is persisted via SaveChangesAsync() (Line 171). Since the sum is translated to SQL and executed against the database, it reflects the pre-update row for trackedRefund, so the refund being finalized right now is never included in its own confirmed-amount check. A single full refund will therefore never flip payment.Status to Refunded here, and partial refunds are permanently under-counted by the current refund's amount. The provided mocked unit tests can't catch this because they don't execute real SQL.

🐛 Proposed fix: persist the refund transition before computing the confirmed total
-            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 (confirmedAmount >= payment.Amount)
-                {
-                    payment.Status = PaymentStatus.Refunded;
-                    _dbContext.Transactions.Update(payment);
-                }
-            }
-
-            _dbContext.Refunds.Update(trackedRefund);
-            await _dbContext.SaveChangesAsync();
+            trackedRefund.GatewayResponse = JsonSerializer.Serialize(response);
+
+            _dbContext.Refunds.Update(trackedRefund);
+            await _dbContext.SaveChangesAsync();
+
+            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 (confirmedAmount >= payment.Amount)
+                {
+                    payment.Status = PaymentStatus.Refunded;
+                    _dbContext.Transactions.Update(payment);
+                    await _dbContext.SaveChangesAsync();
+                }
+            }
             await transaction.CommitAsync();
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
trackedRefund.RefundReference = string.IsNullOrWhiteSpace(response.RefundReference)
? trackedRefund.Id
: response.RefundReference;
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 (confirmedAmount >= payment.Amount)
{
payment.Status = PaymentStatus.Refunded;
_dbContext.Transactions.Update(payment);
}
}
_dbContext.Refunds.Update(trackedRefund);
await _dbContext.SaveChangesAsync();
await transaction.CommitAsync();
return trackedRefund;
});
trackedRefund.RefundReference = string.IsNullOrWhiteSpace(response.RefundReference)
? trackedRefund.Id
: response.RefundReference;
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);
_dbContext.Refunds.Update(trackedRefund);
await _dbContext.SaveChangesAsync();
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 (confirmedAmount >= payment.Amount)
{
payment.Status = PaymentStatus.Refunded;
_dbContext.Transactions.Update(payment);
await _dbContext.SaveChangesAsync();
}
}
await transaction.CommitAsync();
return trackedRefund;
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@PayBridge.SDK/Repositories/RefundRepository.cs` around lines 145 - 174,
Persist the updated trackedRefund before calculating confirmedAmount in the
refund finalization flow, ensuring the database aggregate includes the current
refund’s new status and amount. Reorder the
_dbContext.Refunds.Update(trackedRefund) and SaveChangesAsync calls ahead of the
PaymentStatus.Refunded check, while preserving the existing payment update,
transaction commit, and return behavior.

Comment on lines 440 to 454
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);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Gateway exceptions are durably finalized as Failed with no reconciliation path for uncertain provider outcomes.

Any exception from the gateway call — including transient/network errors where the refund may have actually been accepted upstream — is unconditionally finalized as PaymentStatus.Failed, and that outcome is durably serialized into GatewayResponse. A subsequent retry with the same idempotency key will simply replay this stored "Failed" response (lines 389-395) rather than re-querying the provider, so there is no way to detect or reconcile a refund that actually succeeded despite the client-side exception.

Based on learnings, this repeats the exact gap flagged previously on this file: refund safety should come from idempotency and the ability to query refund status from the provider, with durable persistence of uncertain outcomes and reconciliation logic — not a partial implementation.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@PayBridge.SDK/Services/PaymentService.cs` around lines 440 - 454, Update the
exception handling around the refund gateway call in the refund-processing
method so transient or otherwise uncertain provider failures are not finalized
as PaymentStatus.Failed. Persist an explicit uncertain/pending outcome, then use
the provider’s refund-status query and existing idempotency flow to reconcile
whether the refund was accepted before returning or throwing; ensure retries do
not replay a definitive failure while reconciliation remains unresolved.

Source: Learnings

teesofttech and others added 3 commits July 19, 2026 16:31
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
@teesofttech
teesofttech merged commit ebfb27d into master Jul 19, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants