Loan Ledger: Fee-First Payments and Out-of-Order Event Processing

Read the full interview experience this question came from →

Quick 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.

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

|Home/Software Engineering Fundamentals/Valon
Valon logo
Valon
Sep 24, 2026
mediumSoftware EngineerTechnical ScreenSoftware Engineering Fundamentals
0
0

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 Guidance

  • 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.

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:

balances = Balances(100000, 0)
after_fee = process_fee(balances, 100)
print(after_fee)
# Balances(principal_balance=100000, fee_balance=100)

What This Part Should Cover Guidance

  • 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.

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:

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).

Clarifying Questions for this Part Guidance

  • 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 Guidance

  • 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 Guidance

  • 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 Guidance

  • 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?
Loading comments...