Loan Ledger: Fee-First Payments and Out-of-Order Event Processing
Company: Valon
Role: Software Engineer
Category: Software Engineering Fundamentals
Difficulty: medium
Interview Round: Technical Screen
You are implementing a simplified loan-accounting component. Loan accounting tracks the state of a loan over time, and this version tracks only two fields:
- **Principal balance**: the outstanding principal that must be paid back.
- **Fee balance**: the total fee balance on the loan.
Two kinds of event can occur:
- A **fee** event increments the fee balance by its amount.
- A **payment** event first decrements the fee balance if possible, and then decrements the principal balance with any remaining amount.
The exercise has two steps. The first is a pair of simple balance handlers. The second carries most of the weight: a ledger that accepts events out of order and keeps the loan's history correct.
### Constraints and Clarifications
- Balances and amounts are integers.
- A loan starts with a principal balance of `100000` and a fee balance of `0`.
- Each event is `(event_type, amount, accounting_sequence)`. `event_type` is `"PAYMENT"` or `"FEE"`, `amount` is the value of the payment or fee, and `accounting_sequence` is an integer giving the order in which the event should appear on the accounting ledger. It stands in for the accounting date.
### Clarifying Questions
- If a payment is smaller than the current fee balance, does it pay the fee down partially and leave the principal untouched?
- What should happen when a payment exceeds the combined fee and principal balances: let the principal go negative, cap it and record the overpayment separately, or reject the event?
- Are amounts always positive, and are they in a minor currency unit such as cents?
- Can two events share an `accounting_sequence`? If so, which one is applied first?
- After a backdated event, should `self.balances` hold the state after the highest accounting sequence, or the state after the most recently received event?
### Part 1 — Balances and event handlers
Implement the two handlers below. Each takes the current balances and an amount, and returns the balances after the event.
```python
from dataclasses import dataclass
@dataclass
class Balances:
principal_balance: int
fee_balance: int
def process_payment(balances: Balances, amount: int) -> Balances:
# Implement payment processing logic
raise NotImplementedError()
def process_fee(balances: Balances, amount: int) -> Balances:
# Implement fee processing logic
raise NotImplementedError()
```
Example:
```python
balances = Balances(100000, 0)
after_fee = process_fee(balances, 100)
print(after_fee)
# Balances(principal_balance=100000, fee_balance=100)
```
```hint Payment allocation
Trace a payment against a fee balance that is larger than the payment, and then against one that is smaller. Then ask whether your handler should change the object it was given, knowing that the next step keeps earlier states around.
```
#### What This Part Should Cover
- Fee-first allocation of a payment, including a payment smaller than the fee balance
- Whether the handlers return new balances or modify their input, and why that matters for Part 2
- Validation of amounts and of the resulting balances
### Part 2 — A ledger that accepts out-of-order events
Build a method that processes one event and updates the loan state accordingly.
```python
class LoanLedger:
def __init__(self):
self.balances = Balances(principal_balance=100000, fee_balance=0)
def process_event(self, event_type, amount, accounting_sequence) -> list[LedgerEntry]:
...
```
`process_event` returns a list of entries that represent the history of the loan, ordered by accounting sequence. Each `LedgerEntry` has the fields `accounting_sequence`, `principal_balance` and `fee_balance`, which give the balances right after that event is applied. Define `LedgerEntry` yourself. The examples show each entry as an `(accounting_sequence, principal_balance, fee_balance)` tuple.
Important context:
- Events may arrive out of order. For example, a backdated fee may be received after several payments have already been processed.
- The stream represents events occurring in real time, so events must be processed in the order they are received.
- The ledger should not have to re-process all events every time an out-of-order event comes in.
Example:
```python
loan_ledger = LoanLedger()
loan_ledger.process_event(event_type="PAYMENT", amount=1000, accounting_sequence=1)
# [(1, 99000, 0)]
loan_ledger.process_event(event_type="PAYMENT", amount=500, accounting_sequence=3)
# [(1, 99000, 0), (3, 98500, 0)]
loan_ledger.process_event(event_type="FEE", amount=100, accounting_sequence=2)
# [(1, 99000, 0), (2, 99000, 100), (3, 98600, 0)]
```
The fee with sequence `2` arrives last but belongs between the two payments. The payment with sequence `3` now pays the 100 fee first and applies only the remaining 400 to principal, so its entry changes from `(3, 98500, 0)` to `(3, 98600, 0)`.
```hint What is still valid
When a backdated event arrives, work out which entries of the existing history it cannot affect and which it can. Then ask what you would need to have stored to continue from its position rather than from the start of the loan.
```
#### Clarifying Questions for this Part
- Must every call return the full history, or would returning only the entries that changed be acceptable?
- How far back do backdated events usually land: close to the most recent entry, or anywhere in the history?
#### What This Part Should Cover
- Keeping events ordered by accounting sequence as they arrive, with a stated rule for ties
- Updating every later entry the new event affects without replaying the whole history
- Cost per call in terms of the number of events and how far back the new event lands
- Keeping `self.balances` consistent with the returned history
### What a Strong Answer Covers
- A correct Part 1 written quickly, leaving most of the time for Part 2
- A walk-through of the example that explains why the later payment's entry changes
- Edge cases: an event earlier than all others, duplicate sequences, and an overpayment that appears only after a backdated insert
- Honest complexity: what the update itself costs versus what returning the history costs
- Readable, testable code with explicit errors for unknown event types and invalid amounts
### Follow-up Questions
- If `process_event` only had to return the current balances, or the balances as of a given accounting sequence, how could a backdated event be absorbed faster than by replaying every later event?
- How would you support reversing an earlier event, such as waiving a fee or returning a bounced payment, so that later balances are corrected?
- If the stream can deliver the same event more than once, how would you make `process_event` idempotent?
Overview: Implement loan accounting handlers in Python where fees increase the fee balance and payments pay down fees before principal, then build a ledger that accepts payment and fee events out of order. It tests keeping events ordered by accounting sequence, updating later balances without reprocessing the whole history, handling ties and overpayments, and analyzing per-call cost.
Read the full Valon Software Engineer interview experience this question came from