Payment Refund System Low-Level Design: Double-Refund Prevention, Processor Integration, and Async Processing

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.

Scroll to Top