Subscription Ledger: Credits-First Charges With Backdated Events
ValonSoftware Engineer · Technical ScreenLast updated
Implement billing event handlers where credits pay down usage before the subscription fee, then build a ledger that accepts charge and credit events out of chronological order and recomputes only the balances that a backdated event actually invalidates.
You're building a simplified billing ledger for a subscription product. The ledger tracks two running numbers for an account:
- Usage balance: metered usage charges still owed.
- Subscription balance: the recurring subscription fee still owed.
Two kinds of event can hit the ledger:
- A charge event adds its amount to the usage balance.
- A credit event first reduces the usage balance, and only applies any amount left over to the subscription balance.
Part 1 — Single-event handlers
Implement the two per-event handlers below. Each takes the balances before the event and the event's amount, and returns the balances after.
from dataclasses import dataclass
@dataclass
class Balances:
subscription_balance: int
usage_balance: int
def apply_credit(balances: Balances, amount: int) -> Balances:
raise NotImplementedError()
def apply_charge(balances: Balances, amount: int) -> Balances:
raise NotImplementedError()
Part 2 — A ledger that accepts events out of order
In practice, events don't always arrive in the order they should be applied: a correction can be posted today for something that should have been recorded last week. Each event is (kind, amount, sequence), where kind is "CHARGE" or "CREDIT", and sequence is an integer giving the event's correct position in ledger history (standing in for a posting date).
Implement a Ledger class:
class Ledger:
def __init__(self, starting_usage_balance: int = 50000, starting_subscription_balance: int = 0):
...
def record(self, kind: str, amount: int, sequence: int) -> None:
"""Insert this event at its correct position in history, and recompute
every balance that comes after it, without replaying events that
weren't affected."""
def current_balances(self) -> Balances:
"""Balances as of the highest sequence number recorded so far."""
Example
ledger = Ledger(starting_usage_balance=1000, starting_subscription_balance=0)
ledger.record("CHARGE", 200, sequence=10) # usage: 1200
ledger.record("CREDIT", 150, sequence=20) # usage: 1050
ledger.current_balances() # Balances(subscription_balance=0, usage_balance=1050)
# A correction arrives for a charge that should have been posted BEFORE
# the sequence=10 charge above:
ledger.record("CHARGE", 300, sequence=5)
ledger.current_balances() # Balances(subscription_balance=0, usage_balance=1350)
Constraints and Clarifications
- Balances and amounts are integers; amounts are always non-negative.
- The starting balances apply as of "before the lowest possible sequence number," i.e. before any recorded event.
- Up to
10^5calls torecord, andcurrent_balancesmay be called frequently between them.
Clarifying Questions
- If a credit is smaller than the usage balance at that point, does it only reduce usage and leave the subscription balance untouched?
- What should happen if a credit exceeds both balances combined — should the subscription balance go negative, or should the overage be tracked separately?
- Can two events share the same
sequence? If so, which is applied first? - Is
recordexpected to run faster than replaying the full history from scratch every time, given up to10^5calls?
Hints
Hint 1
Get Part 1 exactly right first: a credit touches usage before subscription, and only the leftover (if any) after usage hits zero goes to subscription.
Hint 2
For Part 2, think about what has to change when a backdated event is inserted: everything before it is untouched, but every balance after it is now wrong and needs recomputing — the question is how to do that without replaying from the very beginning every time.
Hint 3
Keeping events sorted by sequence and storing a running balance snapshot after each one lets you find exactly where a correction needs to be reapplied from, instead of starting at sequence zero.
Hint 4
After inserting or reapplying from a point, only the events from that point forward need their balances recomputed — those before it already have correct, unaffected balances.
Follow-up questions
- How would you avoid the O(n) list-insertion cost for backdated events that arrive in a genuinely adversarial order (e.g., always inserted near the front)?
- How would you support deleting or correcting a previously recorded event, not just inserting a new one?
- If two different event types needed different interactions (say a 'waiver' that only affects the subscription balance, never usage), how would you extend apply_credit and apply_charge cleanly?
- How would you make record() safe to call concurrently from multiple threads without recomputing overlapping ranges incorrectly?
Solution walkthrough is for members
Get the full walkthrough with reference code and complexity analysis, plus Kitty Coach at 300 messages a day. Hints, the code runner and community answers stay free.