This article was drafted with AI assistance.
A refund timeout can cost money even when your provider’s limits work exactly as documented. Consider a hypothetical $100 captured payment and one approved $20 refund. The provider accepts the refund, but your service loses the response. A retry with a fresh idempotency key can create another $20 refund. If both complete, the customer receives $40 and the merchant refunds $20 more than intended.
Stripe permits multiple partial refunds until the charge is fully refunded. Both requests can fit under that cap. A check that total refunds never exceed the captured amount would pass, while the business outcome would still be wrong. 1
That makes partial refunds a useful test of a payments system. Can it preserve one approved refund through a lost response, a process restart, and a second worker?
Start with the approved refund
The business invariant is that one approved $20 refund intent must not result in more than $20 being returned for that intent. While its provider outcome is unresolved, recovery must not create an additional refund. A replacement after a confirmed failure needs an explicit recovery decision.
Give that intent a durable identity before calling the provider. Persist the payment reference, amount in minor units, currency, provider account, idempotency key, and exact request parameters. Include an opaque local intent ID in provider metadata where supported so later evidence can be matched. Avoid customer details in identifiers.
The distinction between intent and amount matters. Two separately approved $20 refunds on one payment can be legitimate. Hashing the payment and amount cannot tell a second approval from a retry. AWS’s guidance on safe retries makes this same point: callers need a way to express which logical operation they mean. 2
Enforce uniqueness when accepting the business request, so repeated submissions resolve to the existing intent. Then make every execution path reload it. A queue redelivery, support retry, or restarted worker must reuse the saved key and parameters rather than generate a new key merely because a new function call began.
Keep uncertainty visible
A network timeout leaves the request outcome unknown. Stripe recommends retrying these requests with the same idempotency key and parameters. Its guidance also treats a 500 response as indeterminate because side effects may have occurred. A same-key retry can replay the cached 500, leaving reconciliation to resolve the outcome. Switching keys to escape an error can create another operation. 3
Keep transport uncertainty separate from the provider’s refund status. Your application may have no response while the provider already has a refund object. That object can be pending, require action, succeed, fail, or be canceled. Receiving a refund ID establishes identity; it does not establish that the customer has received the money. Track later status changes and reconcile the resulting balance effects. [4, 5]
Your database transaction cannot atomically commit both a local record and an independent provider’s HTTP operation. There is always a boundary to recover across. An outbox can commit the intent and work item together locally, but its worker can still crash after the provider accepts the refund and before recording the response. Redelivery must preserve the same operation identity.
Concurrent workers expose the same gap. Use transactional claims or conditional state updates to coordinate them, and preserve the key even if a lease expires while a call is in flight. Stripe may reject a same-key request that conflicts with one still executing without saving an idempotent result. Handle that through the documented retry path; a fresh key would defeat the protection. 6
Test the moment after acceptance
A test that throws a timeout before sending anything misses the expensive case. Build a deterministic failure-injection test with a provider stub that durably records a refund before dropping the response.
- Start with a captured $100 payment and one approved $20 intent. Let the first request create a provider-side refund, then suppress its response.
- Terminate the worker before it stores a provider refund ID. Restart it using only durable state and redeliver the work item.
- Run a competing worker as well. Verify that every retry keeps the same key, account, and parameters, including across the restart.
- Assert that the provider has one refund for the intent, the local record converges to that refund ID, and the successful amount for this scenario is $20. Repeated responses and events must not create duplicate local financial postings.
Add a negative control that deliberately generates a fresh key. The stub should allow a second $20 refund and the business assertion should fail, even though the $100 cap still holds. Otherwise the test may be proving that the fake provider silently prevents the bug.
Retain the evidence someone reviewing the change would need:
- The approved intent, immutable request parameters, and key used by each attempt
- The precise injection point, restart trace, and concurrent execution trace
- Provider refund IDs and statuses, plus matched local financial entries
- The passing recovery assertions and failing fresh-key negative control
Label the environment. A simulation demonstrates behavior under its model. A provider test-mode run checks the integration exercised by that run. Neither proves every production failure is covered.
Know when recovery needs reconciliation
Provider deduplication has a retention boundary. Stripe says keys may be pruned once they are at least 24 hours old, and reuse after pruning creates a new request. That is not a precise expiry timestamp your application can safely guess. Persist the first-attempt time and define a conservative retry policy within the documented contract. 6
When that protection can no longer be relied on, stop automatic reissuance. Reconcile provider records and events against the local intent, using the refund ID when known and other correlation evidence when it is not. Amount alone cannot identify the right refund. An empty local row or a missing webhook is not proof that nothing happened.
If the evidence remains inconclusive, keep the intent unresolved and route it to a named owner. Record the evidence behind any decision to issue a replacement. The financial risk starts when uncertainty is silently converted into permission to move money again.
This becomes especially important when AI agents write or modify payment code. A change that looks reasonable in a diff can still alter retry identity, refund limits, or recovery after a crash. Give the agent an explicit rule: one approved refund must not create an extra financial effect. Then require evidence from lost-response, restart, and concurrent-worker tests before accepting the change. Those tests should check both provider-side refunds and local ledger entries, with unresolved outcomes kept visible for review.
I’m building Ocean to help engineering teams review financial software changes with controls, tests, and evidence tied to the money at risk. For a refund change, the useful question is specific: what shows that this approved $20 remains $20 when the response disappears?
