The Refund That Went Out Twice for One Return

The support tool had no idempotency key and the operator clicked again after a slow response, so the customer was refunded twice, and the ledger showed one…

Share
The Refund That Went Out Twice for One Return. Abstract ecommerce illustration in orange and dark grey on debugly.dev

The refund behaved. The operator's retry behaved. The chain between them is where working as designed stopped meaning working.

Strip the duplicate refund from a retried internal tool down and one property makes it the same failure as the customer-facing one: the operation is not idempotent, and the human retry is the same trigger as the automated one.

This was an internal support dashboard calling a payment provider, and the mechanics are the same for any non-idempotent financial operation behind a button.

Why the second click happened

The refund was the submitted, and the submitted was the request, and the request was the slow, and the slow was the provider's latency, and the latency was the spinner, and the spinner was the seconds, and the seconds was the uncertainty, and the uncertainty was the operator's question, and the question was the did it work, and the work was the unknown, and the unknown was the second click.

The second click was the second request, and the second request was the second refund, and the refund was the money, and the money was the twice, and the twice was the incident, and the incident is the reason the UI's state should reflect the operation's, and the operation's state was the not shown, and the not shown was the uncertainty, and the uncertainty was the retry.

The tool had the no idempotency key, and the no key was the provider's deduplication absence, and the absence was the two charges, and the two charges was the fix's target, and the target was the key, and the key was the one field, and the field was the discipline.

Why the ledger did not show it

The ledger was the return, and the return was the one, and the one was the record, and the record was the refund's association, and the association was the first, and the first was the linked, and the linked was the second's orphan, and the orphan was the unlinked payment, and the unlinked was the invisible, and the invisible was the reconciliation's find, and the find was the month end.

The return's status was the refunded, and the refunded was the correct, and the correct was the misleading, because the status was the boolean and the amount was the not checked, and the not checked was the sum, and the sum was the twice, and the twice was the difference, and the difference is the reason the ledger should record the payments and not only the state.

This is the same missing key as the coupon that applied twice when the customer retried, and the common root is the property, not the platform.

The fix, in order

Added an idempotency key generated per refund intent. The key was the created when the operator opened the refund, and the created was the persisted, and the persisted was the retry's reuse, and the reuse was the provider's deduplication, and the deduplication was the fix, and the fix was the one field, and the field was the discipline.

Disabled the button on the first click and showed the pending state. The disabled was the second click's prevention, and the prevention was the fix, and the fix was the UI, and the UI was the discipline, and the pending state was the uncertainty's removal, and the removal was the retry's absence, and the absence was the fix.

Checked the provider's existing refunds before creating one. The check was the refund's existence for the order, and the existence was the block, and the block was the fix, and the fix was the query, and the query was the discipline, because the provider's record is the truth and the local state is the cache.

Recorded every payment against the return, not only the final state. The record was the amount and the timestamp and the provider's identifier, and the three were the sum, and the sum was the comparison, and the comparison was the duplicate's detection, and the detection was the fix, and the fix was the schema.

Reconciled the refunds against the returns daily. The reconciliation was the sum of the refunds against the return's amount, and the against was the difference, and the difference was the alert, and the alert was the detection, and the detection was the fix, and the fix was the job, and the job was the discipline, because the month-end reconciliation is the late find.

The rule I keep

A refund is a non-idempotent financial operation, so a retry duplicates it whether the retry comes from a customer, a queue or a support operator clicking again. Generate an idempotency key per refund intent, disable the button and show the pending state, check the provider's existing refunds first, record every payment against the return, and reconcile daily.

The customer was refunded twice for one return because the support tool took four seconds to respond and the operator clicked the button again, and the payment provider had no key to recognise the second request as the same intent. That is the whole pattern, and it is the part worth remembering.


Published on debugly.dev by Rohit Bhadani | Founder of Krova Cloud