Question: Refund allocation across prioritized payment methods
Companies: Airbnb
Difficulty: Medium
Problem statement
A customer's order was paid with one or more payments, and some of them may already have been partly refunded. Implement a function that splits a new refund across those payments.
def allocate_refund(transactions: list[list[str]], refund: int) -> list[tuple[str, int]]: ...
Each transaction is a list of strings in one of two shapes:
- Payment:
[payment_id, "PAYMENT", method, timestamp, amount], wheremethodisCREDIT,CREDIT_CARD, orPAYPAL. - Refund:
[refund_id, "REFUND", payment_id, timestamp, amount], a refund already made against that payment.
All amounts are whole cents. Timestamps are integers.
Rules
- A payment can give back only what is left on it: its amount minus all earlier refunds against it.
- Refund by method first: all
CREDITpayments, thenCREDIT_CARD, thenPAYPAL. - Within one method, refund the most recent payment first. Break exact ties by payment id.
- Take as much as possible from each payment before moving to the next.
- If the payments cannot cover the full refund, raise an error and allocate nothing.
Return a list of (payment_id, amount) pairs in the order the money is refunded. Skip payments that receive nothing.
Example
transactions = [
["a", "PAYMENT", "PAYPAL", "10", "4000"],
["b", "PAYMENT", "CREDIT_CARD", "20", "2500"],
["c", "PAYMENT", "CREDIT", "30", "800"],
["d", "PAYMENT", "CREDIT_CARD", "40", "1500"],
["x", "REFUND", "d", "50", "300"],
]
allocate_refund(transactions, 4000)
# -> [("c", 800), ("d", 1200), ("b", 2000)]
Credit c goes first. Card d is newer than b, but only 1200 is left on it after the earlier refund. The last 2000 comes from b, and PayPal is not touched. A request of 8501 or more raises an error, because only 8500 is refundable.
Constraints: up to 10^5 transactions; a refund always names a payment in the list, but it may appear before that payment.
Approach
Clarify first. Pin these down before you write code:
-
Money is integer cents. Never use floats for money. Parse each amount string with
int. - Priority order. Refund store credit first, then credit cards, then PayPal. Within one method, refund the newest payment first. If two payments have the same method and timestamp, break the tie by payment id so the output is the same every run.
- Past refunds count. A payment that was already partly refunded can give back only what is left on it. Ask if a refund row can come before its payment in the list. Assume it can, so apply refunds after you load every payment.
- Not enough money. Ask what happens when the request is bigger than everything still refundable. The usual answer is to reject the whole request and change nothing. A partial refund that leaves the customer short is a worse bug than an error.
-
Output. A list of
(payment_id, amount)pairs, in the order the money goes back.
Brute force. Loop until the refund is used up. On each pass, scan every payment to find the best one that still has money left, take what you can, and repeat. This is O(n) per pass and O(n^2) in total. It is correct but a little clumsy.
Optimal: one pass to load, one sort, one greedy walk.
- Load every
PAYMENTrow into a record withamountandrefunded = 0. - For every
REFUNDrow, add its amount to that payment'srefunded. Reject a refund for an unknown payment, or one that takes a payment below zero. - Check that the total still refundable covers the request. If not, raise an error before you allocate anything.
- Sort the payments that have money left by
(method rank, -timestamp, payment_id). - Walk the sorted list. Take
min(left, payment.remaining)from each payment untilleftis zero.
Why greedy is correct. The rules are a strict ordering of payments, not an optimization. The only correct answer is to drain payment 1 before you touch payment 2, and so on. So taking as much as possible from each payment in sort order gives exactly the required split.
Invariants.
- For every payment,
0 <= refunded <= amount. - The sum of the allocations equals the request, or nothing is allocated.
- At most one payment in the output is partly refunded, and it is the last one.
Code
from __future__ import annotations
from dataclasses import dataclass
# Lower rank is refunded first.
METHOD_PRIORITY: dict[str, int] = {"CREDIT": 0, "CREDIT_CARD": 1, "PAYPAL": 2}
@dataclass
class Payment:
payment_id: str
method: str
timestamp: int
amount: int # cents
refunded: int = 0 # cents already refunded
@property
def remaining(self) -> int:
return self.amount - self.refunded
def load_payments(transactions: list[list[str]]) -> dict[str, Payment]:
"""Build payments, then subtract every earlier refund from its payment."""
payments: dict[str, Payment] = {}
refunds: list[list[str]] = []
for row in transactions:
if row[1] == "PAYMENT":
pid, _, method, ts, amount = row
if method not in METHOD_PRIORITY:
raise ValueError(f"unknown method {method!r}")
payments[pid] = Payment(pid, method, int(ts), int(amount))
elif row[1] == "REFUND":
refunds.append(row)
else:
raise ValueError(f"unknown transaction type {row[1]!r}")
# Refunds may appear before their payment in the list, so apply them last.
for _, _, pid, _, amount in refunds:
payment = payments.get(pid)
if payment is None:
raise ValueError(f"refund for unknown payment {pid!r}")
payment.refunded += int(amount)
if payment.remaining < 0:
raise ValueError(f"payment {pid!r} refunded more than it paid")
return payments
def allocate_refund(transactions: list[list[str]], refund: int) -> list[tuple[str, int]]:
"""Split `refund` cents across payments.
Order: method priority (CREDIT, CREDIT_CARD, PAYPAL), then newest
payment first, then payment id for a stable tie-break.
Returns (payment_id, amount) pairs in the order they are refunded.
Raises ValueError if the payments cannot cover the refund.
"""
if refund < 0:
raise ValueError("refund must be non-negative")
payments = load_payments(transactions)
total_left = sum(p.remaining for p in payments.values())
if refund > total_left:
raise ValueError(f"refund {refund} exceeds refundable {total_left}")
order = sorted(
(p for p in payments.values() if p.remaining > 0),
key=lambda p: (METHOD_PRIORITY[p.method], -p.timestamp, p.payment_id),
)
allocations: list[tuple[str, int]] = []
left = refund
for payment in order:
if left == 0:
break
take = min(left, payment.remaining)
allocations.append((payment.payment_id, take))
left -= take
return allocations
Complexity
Let n be the number of transactions and p the number of payments.
-
Loading payments and past refunds:
O(n)time,O(p)space. -
Sorting:
O(p log p)time. -
Greedy walk:
O(p)time. It stops as soon as the refund is covered. -
Total per call:
O(n + p log p)time andO(p)space. -
Brute force:
O(p^2)time. Fine for a handful of payments, but the sort is just as easy to write.
If refunds are requested many times on the same order, keep the payment records and a sorted list between calls (see Follow-ups). Then each request costs O(k), where k is the number of payments it touches.
Test cases
Payments below are [id, "PAYMENT", method, timestamp, cents]. Refunds are [id, "REFUND", payment_id, timestamp, cents].
-
Mixed methods with a past refund.
p1PAYPAL t=100 5000,p2CREDIT_CARD t=200 3000,p3CREDIT t=300 1000,p4CREDIT_CARD t=400 2000, and a past refund of 500 onp4. A refund of 6000 gives[("p3", 1000), ("p4", 1500), ("p2", 3000), ("p1", 500)]. Credit goes first.p4is the newer card, but only 1500 is left on it. -
Zero refund. Same data, refund 0, gives
[]. -
Exact total. Same data, refund 10500 (everything left), drains every payment. The last pair is
("p1", 5000). -
Too large. Same data, refund 10501, raises
ValueErrorand allocates nothing. -
Refund listed before its payment.
[["r", "REFUND", "a", "5", "100"], ["a", "PAYMENT", "CREDIT", "1", "100"], ["b", "PAYMENT", "CREDIT", "1", "50"]], refund 50, gives[("b", 50)]. Paymentais already fully refunded. -
Tie on method and time. Two PAYPAL payments
aandb, both t=1 and 100 cents, refund 150, gives[("a", 100), ("b", 50)]because of the id tie-break. -
Bad input. An unknown method, an unknown transaction type, a refund for an unknown payment, or past refunds larger than the payment all raise
ValueError.
Follow-ups
-
Record the refund. "Now the system must remember this refund." Turn the function into a class
RefundLedgerwithadd_payment,add_refund, andrefund(amount, timestamp).refundallocates as above, then writes oneREFUNDrow per allocation so the next call sees them. Make the check and the writes one step, so a failure leaves no half-applied refund. -
Several orders or customers. Transactions carry an
order_idorcustomer_id, and a refund may only use payments from that order. Group payments by that key in a dict, and run the same allocation on one group. -
Configurable rules. The business adds a method such as
GIFT_CARD, or wants oldest-first inside some methods. Pass the priority map and a per-method ordering in, and build the sort key from them. Keep the rules as data, not asifbranches. -
Refund windows and limits. Cards can only be refunded within 180 days, or PayPal has a per-transaction cap. Filter or cap each payment's
remainingbefore the walk. The greedy walk does not change, because the rules still give a strict order. -
Many refunds on a large order. Keep the payments in a list sorted once, plus a pointer to the first payment that still has money. Each refund then starts from the pointer, so the cost is
O(k)for thekpayments it touches. If new payments can arrive between refunds, keep one heap per method instead.
Interviewer expectations
Mid-level (L4)
- Parses both row types and applies past refunds to the right payment.
- Sorts by method, then newest first, and fills the refund greedily.
- Uses integer cents, and handles a zero refund and a refund larger than the total.
Senior (L5)
- Asks about the ordering, ties, insufficient funds, and refunds that appear before their payment, before coding.
- Validates input and rejects the whole request instead of returning a short refund.
- Explains why greedy is exactly right here, and states the invariants.
- Moves to the stateful
RefundLedgerfollow-up without rewriting the core.
Staff (L6+)
- Treats the priority rules as configuration, and keeps the allocation separate from parsing and storage.
- Talks about atomic writes, idempotency keys for retried refund requests, and an audit trail of which payment each cent went back to.
- Raises real payment concerns: per-processor refund windows, partial failures at a processor, currency, and reconciling the ledger with the processor's records.