Payments · Lesson 5
Refunds & Chargebacks as State
Refunds you initiated. Chargebacks the bank initiated. The money flows the same direction; everything else is different.
The two shapes
- Refund. You decided to send money back. The user asked, support agreed, or a return triggered an automated flow. You call the provider's refund API, the provider sends money back from your account. Full control, predictable timing, low fees.
- Chargeback. The user told their bank "I don't recognize this charge" or "I didn't get what I paid for." The bank pulls the money back from your account, often with a fee added on top. You can dispute it with evidence. You can't prevent it from being initiated.
Modeling as state
The Payment record has more states than "succeeded" and "failed." The full lifecycle for a payment that completes:
class Payment < ApplicationRecord
enum status: {
pending: 0,
succeeded: 3,
refund_pending: 6, # refund requested, not yet completed
refunded: 7, # full refund completed
partially_refunded: 8, # refund completed but less than full
disputed: 9, # chargeback opened, evidence period
chargeback_lost: 10, # we lost the dispute
chargeback_won: 11, # we won the dispute, money returned
}
end The state transitions are driven by webhooks. Don't try to mirror every provider state — pick the ones your application's downstream behavior actually depends on.
What changes downstream
Both refunds and chargebacks may need to:
- Reverse the order state (mark order canceled, or set a new "refunded" state).
- Re-grant any inventory the order had reserved.
- Revoke any access that was granted (cancel a subscription, revoke a digital license, etc.).
- Trigger an email (refund processed, or dispute notice).
- Update the ledger (next lesson).
Chargebacks add two more things refunds don't:
- Evidence collection. The provider gives you a window (often 7-21 days) to submit evidence: shipping confirmation, IP address at checkout, customer communication, terms of service acceptance. Some apps automate parts of this.
- Fraud signal. Multiple chargebacks from the same user, IP, or card fingerprint is a sign to block. The chargeback is the event that lets you flag.
Partial refunds and the math
A user buys three items, returns one. The refund is partial. The order can't transition cleanly from "paid" to "refunded" — it's somewhere in between.
Two ways to handle the bookkeeping:
- Sum refunds against the original charge. Each Refund record references the Payment and has an amount. The order's "net paid" is
payment.amount_cents - refunds.sum(:amount_cents). Easy to compute, easy to display, never wrong. - Maintain a balance column. Faster reads, but you have to update it on every refund event and you risk drift. Use only if reads are dominant and you're disciplined about the update path. Counter-cache gotchas apply.
For most apps, the first shape is simpler. The query is fast (one SUM, indexed). You only need a balance column when "net paid" is read in hot paths thousands of times per minute.
The principle
Money moving back isn't the inverse of money moving forward; it's a separate event with its own state and its own downstream consequences. Modeling refunds and chargebacks as distinct records (not just a flag on the Payment) makes the history readable and the bookkeeping correct.