Design refundable transaction ledger and prioritization rules
Company: Airbnb
Role: Software Engineer
Category: Coding & Algorithms
Difficulty: Medium
Interview Round: Onsite
Quick Answer: This question evaluates knowledge of data structures and algorithms for transactional ledgers, including tracking payments and refundable balances, rule-based prioritization for allocating refunds, handling partial and multiple refunds, and ensuring correctness when payments and refunds interleave.
Part 1: Build Ledger Storage Indexes
Constraints
- 0 <= len(payments) <= 100000
- 0 <= len(refund_allocations) <= 100000
- paymentId values are unique positive integers
- amount and refundedAmount are non-negative integers
- Every refund allocation references an existing payment and total allocations per payment do not exceed its amount
Examples
Input: ([[1, 10, 100, 5], [2, 10, 50, 3], [3, 20, 70, 4]], [[1, 30], [3, 70]])
Expected Output: [[[1, 10, 100, 5], [2, 10, 50, 3], [3, 20, 70, 4]], [[1, 70], [2, 50], [3, 0]], [[10, 2, 1], [20, 3]]]
Explanation: Payment 1 has 70 remaining, payment 3 is fully refunded, and user 10's payments are ordered by timestamps 3 then 5.
Input: ([], [])
Expected Output: [[], [], []]
Explanation: Empty input produces empty storage views.
Input: ([[5, 99, 200, 10]], [])
Expected Output: [[[5, 99, 200, 10]], [[5, 200]], [[99, 5]]]
Explanation: A single payment with no refunds remains fully refundable.
Input: ([[1, 1, 100, 1], [2, 1, 50, 2]], [[1, 20], [1, 30], [2, 10]])
Expected Output: [[[1, 1, 100, 1], [2, 1, 50, 2]], [[1, 50], [2, 40]], [[1, 1, 2]]]
Explanation: Multiple allocations against the same payment are accumulated.
Solution
def solution(payments, refund_allocations):
payments_by_id = {}
remaining = {}
payments_by_user = {}
for payment in payments:
payment_id, user_id, amount, timestamp = payment
payments_by_id[payment_id] = [payment_id, user_id, amount, timestamp]
remaining[payment_id] = amount
payments_by_user.setdefault(user_id, []).append((timestamp, payment_id))
for allocation in refund_allocations:
payment_id, refunded_amount = allocation
remaining[payment_id] -= refunded_amount
section_payments = [payments_by_id[payment_id] for payment_id in sorted(payments_by_id)]
section_remaining = [[payment_id, remaining[payment_id]] for payment_id in sorted(remaining)]
section_users = []
for user_id in sorted(payments_by_user):
ordered_ids = [payment_id for timestamp, payment_id in sorted(payments_by_user[user_id])]
section_users.append([user_id] + ordered_ids)
return [section_payments, section_remaining, section_users]Time complexity: O(P log P + R + P log P) overall, dominated by sorting payment IDs and per-user payment lists; P is number of payments and R is number of refund allocations.. Space complexity: O(P) for the payment map, remaining map, and per-user index..
Hints
- A hash map keyed by paymentId gives direct access to original and remaining payment data.
- For the per-user view, group payment IDs by userId and sort each group by timestamp.
Part 2: Simulate Basic Ledger APIs
Constraints
- 0 <= len(operations) <= 100000
- IDs, amounts, and timestamps are integers
- paymentId and refundId values are positive integers
- Refund amounts are non-negative
- A rejected operation must not change ledger state
Examples
Input: ([[1, 1, 10, 100, 1], [3, 10], [2, 101, 10, 30, 2, 1], [3, 10]],)
Expected Output: [1, 100, 1, 70]
Explanation: The payment increases user 10's balance to 100; the refund decreases it to 70.
Input: ([[3, 42]],)
Expected Output: [0]
Explanation: Unknown users have balance 0.
Input: ([[1, 1, 10, 100, 1], [2, 101, 10, 120, 2, 1], [3, 10]],)
Expected Output: [1, 0, 100]
Explanation: The refund is rejected because it exceeds the payment's remaining refundable amount.
Input: ([[1, 1, 10, 100, 1], [1, 1, 10, 50, 2], [2, 101, 10, 40, 3, 1], [2, 101, 10, 10, 4, 1], [3, 10]],)
Expected Output: [1, 0, 1, 0, 60]
Explanation: Duplicate payment and duplicate refund IDs are rejected.
Solution
def solution(operations):
payments = {}
remaining = {}
balances = {}
refund_ids = set()
results = []
for op in operations:
kind = op[0]
if kind == 1:
_, payment_id, user_id, amount, timestamp = op
if payment_id in payments:
results.append(0)
else:
payments[payment_id] = (user_id, amount, timestamp)
remaining[payment_id] = amount
balances[user_id] = balances.get(user_id, 0) + amount
results.append(1)
elif kind == 2:
_, refund_id, user_id, amount, timestamp, payment_ref = op
if refund_id in refund_ids:
results.append(0)
elif payment_ref not in payments:
results.append(0)
elif payments[payment_ref][0] != user_id:
results.append(0)
elif amount > remaining[payment_ref]:
results.append(0)
else:
refund_ids.add(refund_id)
remaining[payment_ref] -= amount
balances[user_id] = balances.get(user_id, 0) - amount
results.append(1)
else:
_, user_id = op
results.append(balances.get(user_id, 0))
return resultsTime complexity: O(N) total for N operations, with O(1) average time per operation using hash maps.. Space complexity: O(P + R + U), where P is accepted payments, R is accepted refund IDs tracked, and U is users with balances..
Hints
- Keep one map from paymentId to its user and another map from paymentId to remaining refundable amount.
- Maintaining a cached balance per user makes getUserBalance constant time.
Part 3: Allocate an Unreferenced Refund by Priority Rules
Constraints
- 0 <= len(payments) <= 100000
- 0 <= refund amount <= 10^12
- paymentId values are unique positive integers
- remainingAmount values are non-negative integers
- If no rules are provided, default to OLDEST_FIRST then ID_ASC
Examples
Input: ([[1, 10, 70, 5], [2, 10, 50, 8], [3, 20, 100, 9]], [10, 80, 10], ['RECENT_FIRST'])
Expected Output: [[2, 50], [1, 30]]
Explanation: Recency-first chooses payment 2 before payment 1.
Input: ([[1, 10, 40, 5], [2, 10, 70, 3], [3, 10, 70, 7]], [10, 100, 9], ['LARGEST_REMAINING_FIRST', 'OLDEST_FIRST'])
Expected Output: [[2, 70], [3, 30]]
Explanation: Payments 2 and 3 tie on remaining amount, so OLDEST_FIRST chooses payment 2 first.
Input: ([[1, 10, 20, 1]], [10, 25, 2], ['OLDEST_FIRST'])
Expected Output: []
Explanation: Only 20 is refundable, so a refund of 25 cannot be fully covered.
Input: ([[1, 10, 20, 1]], [10, 0, 2], [])
Expected Output: []
Explanation: A zero-amount refund needs no allocations.
Input: ([[1, 10, 50, 5], [2, 10, 50, 5]], [10, 60, 5], ['ID_DESC'])
Expected Output: [[2, 50], [1, 10]]
Explanation: ID_DESC chooses payment 2 before payment 1.
Solution
def solution(payments, refund, rules):
refund_user, refund_amount, refund_timestamp = refund
eligible = []
total_available = 0
for payment in payments:
payment_id, user_id, remaining_amount, timestamp = payment
if user_id == refund_user and remaining_amount > 0 and timestamp <= refund_timestamp:
eligible.append(payment)
total_available += remaining_amount
if refund_amount == 0:
return []
if total_available < refund_amount:
return []
effective_rules = list(rules) if rules else ['OLDEST_FIRST', 'ID_ASC']
if 'ID_ASC' not in effective_rules and 'ID_DESC' not in effective_rules:
effective_rules.append('ID_ASC')
def sort_key(payment):
payment_id, user_id, remaining_amount, timestamp = payment
key = []
for rule in effective_rules:
if rule == 'RECENT_FIRST':
key.append(-timestamp)
elif rule == 'OLDEST_FIRST':
key.append(timestamp)
elif rule == 'LARGEST_REMAINING_FIRST':
key.append(-remaining_amount)
elif rule == 'SMALLEST_REMAINING_FIRST':
key.append(remaining_amount)
elif rule == 'ID_ASC':
key.append(payment_id)
elif rule == 'ID_DESC':
key.append(-payment_id)
return tuple(key)
eligible.sort(key=sort_key)
allocations = []
remaining_refund = refund_amount
for payment in eligible:
payment_id, user_id, remaining_amount, timestamp = payment
take = min(remaining_amount, remaining_refund)
if take > 0:
allocations.append([payment_id, take])
remaining_refund -= take
if remaining_refund == 0:
break
return allocationsTime complexity: O(P log P) in the worst case to sort eligible payments; P is the number of current payments.. Space complexity: O(P) for the eligible payment list..
Hints
- Convert each priority rule into a component of a sort key.
- After sorting eligible payments, greedily consume each payment's remaining amount until the refund is covered.
Part 4: Process Multiple Partial Refunds per Payment
Constraints
- 0 <= len(payment_amounts) <= 100000
- 0 <= len(refund_requests) <= 100000
- paymentId values in payment_amounts are unique positive integers
- Payment and refund amounts are non-negative integers
- Rejected requests must not change remaining refundable amounts
Examples
Input: ([[1, 100]], [[1, 30], [1, 50], [1, 25], [1, 20]])
Expected Output: [[1, 30, 70], [1, 50, 20], [0, 0, 20], [1, 20, 0]]
Explanation: The third refund is rejected because only 20 remains; the fourth refund exactly consumes the remainder.
Input: ([], [[1, 10]])
Expected Output: [[-1, 0, 0]]
Explanation: There are no payments, so the referenced payment is unknown.
Input: ([[7, 100]], [[7, 100], [7, 1]])
Expected Output: [[1, 100, 0], [0, 0, 0]]
Explanation: After a full refund, no additional refund is allowed.
Input: ([[1, 0]], [[1, 0]])
Expected Output: [[1, 0, 0]]
Explanation: A zero-amount refund against a zero-amount payment is valid and changes nothing.
Input: ([[1, 50], [2, 30]], [[1, 20], [2, 30], [1, 30]])
Expected Output: [[1, 20, 30], [1, 30, 0], [1, 30, 0]]
Explanation: Remaining refundable amounts are tracked independently per payment.
Solution
def solution(payment_amounts, refund_requests):
remaining = {}
for payment_id, amount in payment_amounts:
remaining[payment_id] = amount
results = []
for payment_id, amount in refund_requests:
if payment_id not in remaining:
results.append([-1, 0, 0])
elif amount <= remaining[payment_id]:
remaining[payment_id] -= amount
results.append([1, amount, remaining[payment_id]])
else:
results.append([0, 0, remaining[payment_id]])
return resultsTime complexity: O(P + R), where P is number of payments and R is number of refund requests.. Space complexity: O(P) for the remaining refundable amount map..
Hints
- Store only the remaining refundable amount for each payment; you do not need to recompute from history.
- A refund is valid exactly when requested amount <= remaining[paymentId].
Part 5: Process Interleaved Payments and Refunds with Allocations
Constraints
- 0 <= len(records) <= 100000
- Payment and refund IDs are positive integers and unique within their own type
- Amounts are positive integers
- An unreferenced refund may use only payments already ingested, with the same userId, timestamp <= refund timestamp, and positive remaining amount
- If rules is empty, default to OLDEST_FIRST then ID_ASC
Examples
Input: ([[1, 1, 10, 100, 1], [2, 101, 10, 30, 2, -1], [1, 2, 10, 50, 3], [2, 102, 10, 80, 4, -1]], ['RECENT_FIRST'])
Expected Output: [[[1, 30]], [[2, 50], [1, 30]]]
Explanation: The first refund uses payment 1. The second refund uses the most recent payment 2 first, then payment 1.
Input: ([[1, 1, 10, 100, 1], [1, 2, 10, 40, 2], [2, 201, 10, 60, 3, 1], [2, 202, 10, 50, 4, 1]], ['OLDEST_FIRST'])
Expected Output: [[[1, 60]], []]
Explanation: The second explicit refund against payment 1 is rejected because only 40 remains.
Input: ([[2, 101, 10, 20, 1, -1], [1, 1, 10, 50, 2], [2, 102, 10, 20, 3, -1]], ['OLDEST_FIRST'])
Expected Output: [[], [[1, 20]]]
Explanation: A refund before any eligible payment is rejected; the later refund can use the ingested payment.
Input: ([], ['RECENT_FIRST'])
Expected Output: []
Explanation: No records means no refund outputs.
Input: ([[1, 1, 10, 30, 1], [2, 101, 10, 50, 2, -1], [2, 102, 10, 20, 3, -1]], [])
Expected Output: [[], [[1, 20]]]
Explanation: The insufficient refund is rejected without changing payment 1, so the later refund can still use it.
Solution
def solution(records, rules):
payments = {}
remaining = {}
refund_outputs = []
effective_rules = list(rules) if rules else ['OLDEST_FIRST', 'ID_ASC']
if 'ID_ASC' not in effective_rules and 'ID_DESC' not in effective_rules:
effective_rules.append('ID_ASC')
def sort_key(payment):
payment_id, user_id, remaining_amount, timestamp = payment
key = []
for rule in effective_rules:
if rule == 'RECENT_FIRST':
key.append(-timestamp)
elif rule == 'OLDEST_FIRST':
key.append(timestamp)
elif rule == 'LARGEST_REMAINING_FIRST':
key.append(-remaining_amount)
elif rule == 'SMALLEST_REMAINING_FIRST':
key.append(remaining_amount)
elif rule == 'ID_ASC':
key.append(payment_id)
elif rule == 'ID_DESC':
key.append(-payment_id)
return tuple(key)
for record in records:
kind = record[0]
if kind == 1:
_, payment_id, user_id, amount, timestamp = record
if payment_id not in payments:
payments[payment_id] = (user_id, amount, timestamp)
remaining[payment_id] = amount
else:
_, refund_id, user_id, amount, timestamp, payment_ref = record
if payment_ref != -1:
if payment_ref in payments and payments[payment_ref][0] == user_id and payments[payment_ref][2] <= timestamp and remaining[payment_ref] >= amount:
remaining[payment_ref] -= amount
refund_outputs.append([[payment_ref, amount]])
else:
refund_outputs.append([])
continue
candidates = []
total_available = 0
for payment_id, payment_data in payments.items():
payment_user, original_amount, payment_timestamp = payment_data
rem = remaining[payment_id]
if payment_user == user_id and rem > 0 and payment_timestamp <= timestamp:
candidates.append([payment_id, payment_user, rem, payment_timestamp])
total_available += rem
if total_available < amount:
refund_outputs.append([])
continue
candidates.sort(key=sort_key)
allocation = []
left = amount
for payment in candidates:
payment_id, payment_user, rem, payment_timestamp = payment
take = min(rem, left)
if take > 0:
allocation.append([payment_id, take])
left -= take
if left == 0:
break
for payment_id, take in allocation:
remaining[payment_id] -= take
refund_outputs.append(allocation)
return refund_outputsTime complexity: O(N * P log P) in the worst case if every unreferenced refund scans and sorts all prior payments. With per-user priority indexes this can be improved, but this direct simulator prioritizes clarity.. Space complexity: O(P + A), where P is stored payments and A is the size of the largest temporary allocation/candidate list..
Hints
- For an unreferenced refund, first collect candidate payments and check that total remaining amount is sufficient before mutating state.
- Use the same sort-key idea from configurable priority rules, then greedily allocate.
Part 6: Estimate Ledger Operation Complexity Costs
Constraints
- 0 <= len(operations) <= 100000
- userId values are positive integers
- For unreferenced refunds, 0 <= touchedPayments <= current number of payments for that user
- The estimator tracks payment counts only; refunds do not remove payments from the count
- All costs fit in a 64-bit signed integer
Examples
Input: ([],)
Expected Output: [0, 0, 0, 0]
Explanation: No operations means both designs have zero cost.
Input: ([[1, 10], [1, 10], [2, 10, 0, 1], [3, 10]],)
Expected Output: [6, 7, -1, 2]
Explanation: For this small workload, indexed insert overhead is not recovered.
Input: ([[1, 10], [1, 10], [1, 10], [1, 10], [1, 10], [2, 10, 0, 0], [2, 10, 0, 0], [2, 10, 0, 0], [2, 10, 0, 0], [2, 10, 0, 0]],)
Expected Output: [35, 19, 1, 5]
Explanation: Repeated unreferenced refunds with zero touched payments are much cheaper under the indexed model.
Input: ([[1, 1], [1, 2], [1, 1], [2, 1, 0, 1], [2, 2, 0, 1]],)
Expected Output: [8, 10, -1, 3]
Explanation: Payment counts are maintained separately per user.
Input: ([[1, 5], [2, 5, 1, 0], [3, 5]],)
Expected Output: [3, 4, -1, 1]
Explanation: A referenced refund is constant time in both designs, so the indexed add cost makes it more expensive here.
Solution
def solution(operations):
def ceil_log2_at_least_2(x):
x = max(2, x)
return (x - 1).bit_length()
payment_counts = {}
total_payments = 0
naive_cost = 0
indexed_cost = 0
for op in operations:
kind = op[0]
if kind == 1:
user_id = op[1]
before = payment_counts.get(user_id, 0)
payment_counts[user_id] = before + 1
total_payments += 1
naive_cost += 1
indexed_cost += 1 + ceil_log2_at_least_2(before + 1)
elif kind == 2:
user_id, has_reference, touched_payments = op[1], op[2], op[3]
current_count = payment_counts.get(user_id, 0)
if has_reference == 1:
naive_cost += 1
indexed_cost += 1
else:
naive_cost += 1 + current_count
indexed_cost += 1 + touched_payments * ceil_log2_at_least_2(current_count)
else:
naive_cost += 1
indexed_cost += 1
if naive_cost < indexed_cost:
recommendation = -1
elif indexed_cost < naive_cost:
recommendation = 1
else:
recommendation = 0
return [naive_cost, indexed_cost, recommendation, total_payments]Time complexity: O(N) to scan N workload operations. The modeled indexed ledger operations are addPayment O(log m), referenced addRefund O(1), unreferenced addRefund O(k log m), and getUserBalance O(1), where m is the user's payment count and k is touchedPayments.. Space complexity: O(U) for this estimator, where U is the number of users. The modeled ledger designs both require O(P) payment storage..
Hints
- Maintain the current number of payments per user while scanning operations once.
- ceil(log2(x)) for integer x >= 1 can be computed with bit_length.