Practice · Payments · Card 5
A user returns one of three items. Where does the data go?
The order was $150. The refund is $50. The order isn't "refunded" but it's not "paid in full" either. How should the schema represent that?
The setup
Existing schema:
class Order < ApplicationRecord
has_one :payment
enum status: { pending: 0, paid: 1, refunded: 2 }
end
class Payment < ApplicationRecord
belongs_to :order
enum status: { pending: 0, succeeded: 3, refunded: 7 }
end
# A user paid $150. They return one $50 item. You need to refund $50. The question
Where should the partial refund live, and how should "how much was refunded so far?" be answered?
Take a moment. Pick the best answer. Wrong picks reveal why they're wrong, which is half the point.
✅ Answer breakdown
✗ A. Add refunded_amount_cents to Payment, incremented on each refund.
A balance column. Works for the simple case but loses history (which refund was for which item? when did it happen? was there a partial-of-a-partial?). Also subject to concurrent-update races if two refunds arrive at the same moment.
✗ B. Reuse the refunded status on Payment, even though the refund is partial.
Lies to the rest of the application. Code that reads "payment.refunded?" will treat a partially-refunded payment as fully refunded. Downstream effects (canceling access, blocking deliveries) misfire.
✓ C. Create a separate Refund model. payment.refunds has many. "How much was refunded?" is payment.refunds.sum(:amount_cents).
Each refund is its own event with its own amount, timestamp, reason, and provider ID. The history is queryable. The Payment can carry a partially_refunded state that reflects "some but not all." Multiple partial refunds compose naturally. Reconciliation against the provider is straightforward — each Refund maps to one provider refund object.
✗ D. Store the refund as a row in the Order's metadata JSON column.
JSON-as-text blocks queries (the Spot the Tax card "Indexable Storage" covers this). You can't easily ask "all refunds for this order" or "total refunded across the system this month."
💡 The principle
Money moving back is its own event, not a flag on the money that moved forward. Modeling Refund as a record (belongs_to :payment) gives you:
- History (you can answer "what happened on this order?" by listing rows).
- Provider mapping (each Refund stores its provider refund ID for reconciliation).
- Composability (multiple partial refunds are just multiple rows).
- Auditability (every refund has timestamp, amount, reason, who initiated it).
The Payment's status becomes a summary of the Refund history. If refunds.sum(:amount_cents) equals the payment amount, fully refunded. Less than, partially refunded. Zero, succeeded.
📚 Theory
For the full walkthrough, read Payments · Refunds & Chargebacks as State.