A refund is not undo

• Futurify Team

A refund is not an undo button. It’s a second transaction that happens to point at the first one.

A refund is not undo — false model vs first-class refund event

Almost every payment bug I’ve had to clean up at 7pm on a Friday started with the same assumption: that refunding is just the charge running backwards. Set status to refunded, write -amount, send the email, move on. It works in the demo. It works for the first few hundred. Then someone refunds half an order, the processor keeps a fee, a return shows up five weeks late, and your ledger and your processor’s ledger disagree by $1.73 — and nobody can say which one is lying.

The false model is “refund = undo the charge.” The real model is “a refund is its own event, with its own money movement, its own timing, and its own failure modes.” Once you model it that way, most of the pain goes away.

The undo myth

The undo myth — orders status column vs events ledger

Look at what teams actually code the first time around. There’s an orders table with a status column and an amount column. The refund handler does three things: flips status to refunded, stores refunded_at, and maybe writes a row somewhere with the amount negated.

That design encodes two assumptions that are both wrong. First, that a charge has exactly one refund. Second, that the refund’s amount is the charge’s amount.

Neither survives contact with a real customer. The customer who ordered three items and sent one back. The customer you refunded $20 as a goodwill gesture, then refunded the rest two weeks later when the replacement also failed. The customer whose card was closed, so the refund bounced and you owe them a cheque.

With status = 'refunded' you can’t represent any of that. So people start patching: a refunded_amount column, then a partially_refunded status, then a nullable refund_reference. Each patch is a small lie that makes the next question harder to answer.

Partial refunds break the mirror

Partial refunds — ledger rows for order o_91

The moment refunds can be partial, the charge and the refund stop being symmetrical, and a bunch of questions you never asked become real.

How much is still refundable on this order? If you only store refunded_amount, you’re trusting a counter. Counters drift. They drift when two requests land at once, when a refund fails after you incremented, when someone fixes a mistake by hand in the database.

What was refunded, not just how much? Ops will ask. Tax will ask. The customer will ask. “$40 of $120” doesn’t tell you whether the shipping came back, and whether shipping came back changes the tax.

What about the discount? If the customer used a 20% code and returns one of two items, you don’t refund the item’s list price. You refund their share of what was actually paid. Skip that modelling and teams over-refund for years without noticing, because nobody reconciles at the line level.

The fix isn’t clever math. It’s storing refunds as rows, each with an amount, a reason, and a link to what it covers. Then “how much is refundable” is a query against reality, not a counter you hope is right.

Fees don’t reverse cleanly

Fees don't reverse cleanly — charge vs refund gross fee net

This is the one that surprises finance teams, usually during their first month-end close.

You charge $100. The processor takes, say, $3. You net $97. The customer asks for a full refund. You send $100 back. Depending on your processor and region, you might get that $3 back, you might get part of it back, or you might eat it entirely. Some processors also charge a fee for the refund.

So a “full refund” of a $100 charge can leave you down $3, or $3.30, or $6. Your gross is flat and your margin isn’t. If your code treats refunds as the negation of the charge, your P&L is quietly wrong, and the gap grows with your refund rate.

Model the fee as its own movement. The charge has a gross, a fee, and a net. The refund has a gross, a fee treatment, and a net. Don’t derive one from the other — read both from what the processor actually reports in settlement, which is a different thing than what it told you in the moment. (Which is the webhooks-aren’t-settlement point again, from the other side: the API response tells you an intent was accepted; the settlement file tells you what money moved and what it cost.)

Late returns and chargebacks aren’t your refund button

Three event types — refund return chargeback

Three different things get jammed into one word in most codebases.

A refund is you deciding to send money back. You control the timing and the amount.

A return is goods coming back. It might arrive before the refund, after it, or never. The return window has rules — restocking fees, condition checks, who pays shipping. A return that arrives outside the window is a business decision, not a status transition.

A chargeback is the cardholder going around you to their bank. You don’t control it. You may not even know about it for weeks. It has its own money movement, its own fee, its own evidence deadlines, and it can land on an order you already refunded — which means you’ve now paid twice and have to claw one back.

If all three write to the same status column, you lose the ability to answer the only question that matters during a dispute: what happened, in what order, and who decided it. Three event types, three sets of rules, one timeline.

”Cancelled” is a customer-facing word

Cancelled vs ledger truth — customer label vs ledger rows

Customers see “Cancelled.” They should. It’s the honest summary of their experience.

But “Cancelled” is a label computed from events — not a fact stored in place of them. The ledger truth underneath might be: authorisation captured, $60 refunded, $40 pending because the second refund failed, $2.90 in fees retained, goods not yet received.

Teams get this backwards. They store the customer-facing word and try to reconstruct the money from it. Then support tells the customer they’ve been fully refunded while $40 is still sitting with the processor, and you find out when the customer’s bank calls.

Store events. Compute labels. The display string is a view, and views are allowed to be simpler than the data.

Give refunds their own id

Here’s the discipline, and it’s the same discipline as every other place where your system meets someone else’s money.

Generate your own id for the refund before you call anyone. Not the processor’s id — yours. Yours exists the instant the operator clicks the button, which means if the call times out, you have a handle on the thing whose outcome you don’t know yet. Send that id to the processor as the idempotency key so a retry can’t double-send. Store the processor’s id alongside yours when it comes back.

Then the rest follows:

  • Ingest is idempotent. The same refund notification arriving three times produces one refund.
  • Every refund has a lifecycle, not a boolean: requested, submitted, settled, failed. A refund you submitted and never saw settle is not a refund. It’s an open question.
  • You reconcile refunds against settlement, same as charges. Refunds in your ledger with no settlement line. Settlement lines with no refund in your ledger. Amounts that don’t match.
  • Mismatches go to a queue a human owns. This is the Friday pile again — it exists whether or not you built it. The only choice is whether it lives in a system with a name and an owner, or in someone’s inbox and memory.

What good looks like

What good looks like — refunds table and one query test

A refunds table, not a column. Your id, their id, amount, currency, reason, state, state history, link to the charge, link to the lines it covers.

A fee field that comes from settlement, not from arithmetic.

Returns and chargebacks as separate event types with their own tables and their own rules.

Customer-facing status as a function of events.

A reconciliation job that runs daily and a queue of exceptions with someone’s name on it.

And a question you can answer in one query at any moment: for this order, how much did we collect, how much did we send back, what did it cost us, and what’s still open?

That last query is the test. If you can’t write it, your refunds are stored as an undo.

Boundary

Futurify builds software. We’re not a processor and we don’t hold funds — your money moves through your providers and your bank, under your agreements. What we build is the system of record around it: the events, the reconciliation, the exception queue, the operator screens. Under Run, we operate it with you after go-live, so the Friday pile has an owner who isn’t the last person to touch the code.

Next step

Two minutes, no tooling required. Open your refund code and answer:

  1. Can one charge have two refunds in your schema? If not, you have a bug waiting.
  2. Where does the processor’s refund fee live in your data? If the answer is “nowhere,” your margin reporting is off by your refund volume times your fee.
  3. Pick any refunded order from last month. Can you tell, from your database, whether the money actually settled?

If any of those take more than a minute to answer, that’s the thing worth fixing before the next thing. Happy to compare notes — hello@futurify.io.

Ready to modernize your legacy system?

Let's talk about how we can help you identify and fix what's slowing you down.

Book a Call →