Payment Refund System: Low-Level Design
A payment refund system reverses a completed charge — partially or fully — and ensures the customer’s account is credited while the merchant’s revenue ledger is accurately adjusted. The critical design challenges are preventing double refunds, handling partial refunds correctly (multiple partial refunds on one payment), coordinating with payment processors (Stripe, Braintree), and providing a complete audit trail for disputes and chargebacks.
Core Data Model
CREATE TABLE Payment (
payment_id BIGSERIAL PRIMARY KEY,
order_id BIGINT NOT NULL,
customer_id BIGINT NOT NULL,
amount_cents INT NOT NULL,
currency CHAR(3) NOT NULL DEFAULT 'USD',
status VARCHAR(20) NOT NULL, -- succeeded, refunded, partially_refunded
processor VARCHAR(30) NOT NULL, -- stripe, braintree, paypal
processor_charge_id VARCHAR(200) NOT NULL UNIQUE, -- e.g. ch_3NxvXY2eZvKYlo2C
captured_at TIMESTAMPTZ NOT NULL,
refundable_cents INT NOT NULL, -- amount - sum(refunds); decremented on each refund
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE Refund (
refund_id BIGSERIAL PRIMARY KEY,
payment_id BIGINT NOT NULL REFERENCES Payment(payment_id),
customer_id BIGINT NOT NULL,
requested_by BIGINT NOT NULL, -- user_id of agent or customer
amount_cents INT NOT NULL,
currency CHAR(3) NOT NULL DEFAULT 'USD',
reason VARCHAR(50) NOT NULL, -- customer_request, fraud, defective, duplicate
status VARCHAR(20) NOT NULL DEFAULT 'pending',
-- pending, processing, succeeded, failed
processor_refund_id VARCHAR(200) UNIQUE, -- stripe: re_3Nxv...; set on success
idempotency_key VARCHAR(200) UNIQUE NOT NULL,
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
processed_at TIMESTAMPTZ,
failed_reason TEXT
);
CREATE TABLE RefundAuditEvent (
event_id BIGSERIAL PRIMARY KEY,
refund_id BIGINT NOT NULL REFERENCES Refund(refund_id),
event_type VARCHAR(50) NOT NULL, -- created, processor_submitted, succeeded, failed
actor_id BIGINT,
metadata JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX ON Refund(payment_id, status);
CREATE INDEX ON Payment(processor_charge_id);
Refund Processing
import uuid, stripe
stripe.api_key = STRIPE_SECRET_KEY
def request_refund(payment_id: int, amount_cents: int, reason: str,
requested_by: int, notes: str = None) -> int:
"""
Initiate a refund. Validates amount against remaining refundable balance.
Uses idempotency key to prevent duplicate refund submissions.
Returns refund_id.
"""
# Lock the payment row to prevent concurrent over-refund
payment = db.fetchone("""
SELECT * FROM Payment WHERE payment_id=%s FOR UPDATE
""", (payment_id,))
if not payment:
raise PaymentNotFoundError(f"Payment {payment_id} not found")
if payment['status'] not in ('succeeded', 'partially_refunded'):
raise RefundNotAllowedError(f"Cannot refund payment in status: {payment['status']}")
if amount_cents payment['refundable_cents']:
raise InvalidRefundAmountError(
f"Refund amount {amount_cents} exceeds refundable balance {payment['refundable_cents']}"
)
idem_key = f"refund:{payment_id}:{amount_cents}:{requested_by}:{str(uuid.uuid4())[:8]}"
refund_id = db.fetchone("""
INSERT INTO Refund
(payment_id, customer_id, requested_by, amount_cents, currency,
reason, idempotency_key, notes)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s)
RETURNING refund_id
""", (payment_id, payment['customer_id'], requested_by, amount_cents,
payment['currency'], reason, idem_key, notes))['refund_id']
# Reserve the refund amount immediately (prevent concurrent over-refund)
db.execute("""
UPDATE Payment
SET refundable_cents = refundable_cents - %s,
status = CASE WHEN refundable_cents - %s = 0 THEN 'refunded'
ELSE 'partially_refunded' END
WHERE payment_id=%s
""", (amount_cents, amount_cents, payment_id))
_audit(refund_id, 'created', requested_by, {'amount_cents': amount_cents})
# Process asynchronously via job queue for resilience
enqueue('process_refund', {'refund_id': refund_id}, queue_name='payments',
idempotency_key=f"process:{refund_id}")
return refund_id
def process_refund(refund_id: int):
"""
Job handler: submit refund to payment processor.
Idempotent: if processor_refund_id already set, the refund was already submitted.
"""
refund = db.fetchone("""
SELECT r.*, p.processor, p.processor_charge_id
FROM Refund r JOIN Payment p USING(payment_id)
WHERE r.refund_id=%s
""", (refund_id,))
if refund['status'] == 'succeeded':
return # Already processed (idempotency)
db.execute(
"UPDATE Refund SET status='processing' WHERE refund_id=%s", (refund_id,)
)
_audit(refund_id, 'processor_submitted', None, {})
try:
if refund['processor'] == 'stripe':
result = stripe.Refund.create(
charge=refund['processor_charge_id'],
amount=refund['amount_cents'],
reason=_map_reason_to_stripe(refund['reason']),
idempotency_key=refund['idempotency_key'],
)
processor_refund_id = result['id']
else:
raise NotImplementedError(f"Processor {refund['processor']} not supported")
db.execute("""
UPDATE Refund
SET status='succeeded', processor_refund_id=%s, processed_at=NOW()
WHERE refund_id=%s
""", (processor_refund_id, refund_id))
_audit(refund_id, 'succeeded', None, {'processor_refund_id': processor_refund_id})
except stripe.error.StripeError as e:
_handle_processor_failure(refund_id, refund['payment_id'], refund['amount_cents'], str(e))
raise # Re-raise so job queue retries
def _handle_processor_failure(refund_id: int, payment_id: int,
amount_cents: int, error: str):
"""On permanent failure, restore the refundable balance."""
db.execute("""
UPDATE Refund SET status='failed', failed_reason=%s WHERE refund_id=%s
""", (error[:500], refund_id))
# Restore refundable amount so the agent can retry
db.execute("""
UPDATE Payment
SET refundable_cents = refundable_cents + %s,
status = CASE WHEN status='refunded' THEN 'succeeded' ELSE status END
WHERE payment_id=%s
""", (amount_cents, payment_id))
_audit(refund_id, 'failed', None, {'error': error})
def _map_reason_to_stripe(reason: str) -> str:
return {'fraud': 'fraudulent', 'duplicate': 'duplicate', 'customer_request': 'requested_by_customer'}.get(reason, 'requested_by_customer')
def _audit(refund_id, event_type, actor_id, metadata):
import json
db.execute("""
INSERT INTO RefundAuditEvent (refund_id, event_type, actor_id, metadata)
VALUES (%s,%s,%s,%s)
""", (refund_id, event_type, actor_id, json.dumps(metadata)))
Key Design Decisions
- FOR UPDATE + immediate refundable_cents decrement prevents double refunds: the payment row is locked before the refund row is created. Two concurrent refund requests for the same payment: the first decrements refundable_cents; the second reads the updated value and fails the amount validation. No distributed locks or application-level checks needed — the database enforces it atomically.
- Reserve refundable amount before calling the processor: decrement refundable_cents at request time, not after the processor confirms. If the processor call is made first and the server crashes before recording the response, a retry would submit a second refund. By reserving first and restoring on permanent failure, there is no window for accidental double submission.
- Processor idempotency key: Stripe accepts an Idempotency-Key header — if the same key is resubmitted (due to retry), Stripe returns the same response without processing a second refund. The refund’s idempotency_key column maps directly to Stripe’s header.
- Async processing via job queue: processor calls are slow (200–800ms) and can fail. Running them in a job queue with retry logic ensures refunds are eventually processed without blocking the user-facing API and without losing the request if the server crashes.
{“@context”:”https://schema.org”,”@type”:”FAQPage”,”mainEntity”:[{“@type”:”Question”,”name”:”How do you handle a refund when the original payment method is no longer valid?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”A customer’s credit card from 6 months ago has been cancelled or replaced. The refund to the original payment method will fail. Handling: (1) most payment processors (Stripe) handle this automatically — they attempt the refund to the original card and if declined, issue an ACH credit or check instead; (2) if the processor cannot issue the refund automatically, mark the refund as requires_manual_action and alert the support team; (3) support team options: (a) issue a store credit in the same amount (instant, low cost); (b) collect updated payment details from the customer and issue a bank transfer; (c) mail a check (last resort). Prevention: process refunds promptly — most processors allow refunds to closed cards within 30 days of the original charge by applying them to the new card linked to the same account (Visa/MC account updater). Track refund method (original_card, store_credit, bank_transfer, check) in Refund.refund_method for reporting.”}},{“@type”:”Question”,”name”:”What is the difference between a refund and a chargeback, and how do they affect the system?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”A refund is merchant-initiated: the merchant agrees to return the customer’s money. The system’s process_refund() handles this. A chargeback is customer-initiated: the customer disputes the charge with their bank, bypassing the merchant. The bank reverses the charge and takes a chargeback fee ($15–$25). The merchant must respond to the dispute with evidence (delivery confirmation, refund receipts, usage logs). System implications: (1) chargebacks arrive via processor webhooks (charge.dispute.created in Stripe) — the system must track these separately in a ChargeDispute table; (2) if a refund was already issued for the disputed charge, submit it as evidence to the bank — the chargeback should be withdrawn; (3) high chargeback rates (>1% of transactions) trigger card network penalties and potential account termination; (4) prevent chargebacks proactively: issue refunds quickly when customers request them — a $10 refund is better than a $30 chargeback + fee.”}},{“@type”:”Question”,”name”:”How do you handle refunds across multiple line items in a cart checkout?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”A customer purchased 3 items ($20, $30, $50 = $100 total) in one transaction. They want to return item 2 ($30 refund). Two approaches: (1) line-item-level refund: track refundable_cents per OrderLineItem rather than per Payment. Return item 2 → refund $30, mark line item 2 as refunded. The payment’s refundable_cents decrements by $30 to $70. Subsequent refund of item 3 decrements to $20. (2) Payment-level partial refund: the Payment.refundable_cents tracks total refundable without per-item awareness. The application validates that refund_amount doesn’t exceed refundable_cents; per-item tracking is an application concern, not enforced by the payment layer. Pattern 1 provides better visibility in order management UIs and prevents refunding more than an item’s value. Add OrderLineItemRefund table: (payment_id, line_item_id, refund_id, amount_cents) to record which items were included in each refund.”}},{“@type”:”Question”,”name”:”How do you reconcile your refund records with the payment processor’s records?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”Processor records are authoritative for financial purposes — your internal records may diverge due to bugs, race conditions, or missed webhook events. Daily reconciliation: (1) fetch all refunds from the processor API for the previous day (Stripe: stripe.Refund.list(created={gte:yesterday_start, lte:yesterday_end}, limit=100)); (2) for each processor refund record, find the matching internal Refund row by processor_refund_id; (3) mismatches: (a) processor shows succeeded, internal shows pending → update internal status; (b) processor shows refund that has no internal record → create a reconciliation record and alert; (c) internal shows succeeded but processor shows failed → rollback internal status and restore refundable_cents. Run reconciliation as a nightly job, write results to a ReconciliationReport table. Unresolved mismatches after 48 hours trigger a P2 alert to the finance team.”}},{“@type”:”Question”,”name”:”How do you handle refunds for subscription products where access has already been consumed?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”A user subscribes to an annual plan for $120, uses the product for 3 months, then requests a full refund. Prorating: most SaaS products offer a pro-rated refund: ($120 / 12 months) × 9 remaining months = $90 refund. Calculate: refund_amount = (original_amount / subscription_days) × remaining_days. Implementation: in process_refund(), check if the payment is for a Subscription entity; if yes, calculate the prorated amount based on the subscription’s end_date vs. today. Immediately revoke access on refund initiation (not after processor confirmation) to prevent the user from continuing to use the product while the refund is pending. For annual plan refunds beyond 30 days: many SaaS products have a no-refund policy after the first 30 days — encode this as a RefundPolicy rule checked in request_refund() before creating the refund record. Return a clear error message when the refund is outside policy.”}}]}
Payment refund and transaction reversal system design is discussed in Stripe system design interview questions.
Payment refund and merchant order management design is covered in Shopify system design interview preparation.
Payment refund and booking cancellation system design is discussed in Airbnb system design interview guide.