Katkat

Subscription Ledger: Credits-First Charges With Backdated Events

ValonSoftware Engineer · Technical Screen

Last 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^5 calls to record, and current_balances may 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 record expected to run faster than replaying the full history from scratch every time, given up to 10^5 calls?

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?