Start with the amount still available
Store the original paid amount and each accepted refund against one payment ID. Before a new refund, calculate the amount left from committed records. Use integer amounts in the smallest currency unit, such as kobo. Reject zero, negative, fractional-minor-unit, and wrong-currency amounts on the server. Store the currency’s unit rule with the payment so conversion cannot create extra value. Stripe’s refund API allows several partial refunds but rejects a request above the remaining charge. Your own app still needs to protect its balance, order state, and approval path.
For a ₦10,000 payment with an accepted ₦3,000 refund, the most that remains is ₦7,000. A request for ₦7,001 must fail before a provider call. If the provider later rejects an approved ₦2,000 refund, your records must show that it failed rather than counting it as money returned.
Test the cases that change the answer
| Case | Expected result | Proof to inspect |
|---|---|---|
| Refund above the remaining amount | Reject before calling the provider | Provider call count stays unchanged |
| Two refund requests arrive together | Only the funded total is accepted | Committed refund and ledger totals |
| The same request is retried | Return the first result | One provider refund ID |
| Provider rejects the refund | Keep it out of the refunded total | Failure state and open case |
| Staff member changes the amount after approval | Require fresh approval | Approved amount matches release amount |
Keep approval tied to one request
A staff member who can create a refund should not be able to approve and release a high-risk refund alone. Bind approval to the payment, recipient, amount, and reason. If any value changes, the old approval ends. OWASP’s transaction authorization guide calls for the person approving an action to see the important details and for the server to enforce the approved sequence.
Test this with two staff roles. Create a refund as the maker, then call the approval route with the maker’s session. It must fail. Next, approve a ₦2,000 refund with a separate role, change the stored request to ₦5,000, and try to release it. The release must fail because it no longer matches what was approved.
Keep the provider and ledger in step
Do not mark a refund complete when the provider only accepts a request for processing. Keep separate states for requested, approved, submitted, succeeded, and failed. A late provider message must update the matching refund request, not the whole payment. When the provider confirms success, post the refund ledger entry once. When it reports failure, leave the paid amount and customer balance unchanged and give the case to a person to resolve.
Run one test where the provider response times out after accepting the refund. Look up the existing provider refund ID first. Use a retry key only where that provider documents refund idempotency, including its scope and lifetime; do not assume a reference alone stops duplicates. The final proof is one customer refund, one provider refund, and one matching ledger effect.
Run the refund race
Case PAY-13B in the synthetic payment control test pack begins with a captured payment of 10,000 minor units. Start two separate 6,000-minor-unit refund requests at once. The amount available for refund must be reserved inside the same transaction that accepts each request. Only one 6,000-minor-unit request can reserve funds. The other must show the remaining amount or a clear conflict. After local release, the provider must receive one refund instruction for that accepted operation.
Now let the accepted request reach the provider, then cut the response. Do not restore the reserved amount because the result is unknown. Resolve the existing request through the provider’s documented refund lookup or list endpoint. If the ID was lost and no safe retry method is documented, keep the amount reserved and open a provider-support case instead of creating another refund. Test a provider rejection separately; that path releases the reservation with an audit event. Compare the sum of completed and pending refunds with the original captured amount after each step. Stripe’s refund API permits partial refunds up to the remaining unrefunded amount; your ledger must enforce that cap before sending another call.
Follow the provider status after a refund request
A synthetic ₦10,000 payment gets a ₦3,000 refund request. Reserve ₦3,000 of refundable value and send one request to the provider. A queued or pending provider response is not a completed customer refund. Keep the local status pending and check the provider refund ID until it reaches a final state. Paystack’s refund guide shows a queued response and a later status flow; it also describes a needs-attention state when customer bank details are missing. The product must show that state without creating a second refund. Paystack’s Retry Refund endpoint is for its documented needs-attention flow; it is not a general retry for a lost response. Its processed state means the processor completed the refund, while receipt in the customer’s bank can follow later. Say “processed” until you have proof of receipt; do not claim the customer already received it.
Next, request ₦8,000 more while the first ₦3,000 is pending. The available amount is ₦7,000, so the new request fails before a provider call. If the first refund fails under a final provider result, release its reservation once with an audit event. If it succeeds, the remaining refundable amount stays ₦7,000. Record the original payment ID, each refund ID, reserved amount, provider state, and ledger journal. This makes an interrupted refund traceable without treating a timeout as either success or failure.
What to keep from the test
Save the original charge ID, each refund ID, amounts in kobo, staff approvals, provider states, ledger entries, and the final customer notice. This lets a reviewer trace why the remaining refundable amount changed. For the wider workflow, read refund authorization and separation of duties. To test a live product, scope a fintech security review.