Coding sample solution

Coding: check transactions against rules

How to talk through this coding question, from a first idea to working code, in an interview.

Coding interview · Sample solution in Python · Independent, not affiliated with Stripe

The coding question

You're given a list of transactions, each with attributes like amount and country, and a list of deny rules like 'amount > 1000'. Decide whether each transaction is accepted or denied. Then support rules combined with AND and OR, and then nested rules with parentheses.

Ask a few questions first

AskWhat we'll assume
What does a rule look like?field, operator, value, with spaces between them. Operators are >, <, >=, <=, == and !=. Values are numbers or words, with optional quotes.
When is a transaction denied?If any rule matches it. We'll also say which rule did it, since that's handy for debugging.
What if a transaction doesn't have the field?The condition is false. A missing country can't match 'country == RU'.
Does AND bind tighter than OR?No. The question says no precedence, so we read left to right: 'a OR b AND c' means '(a OR b) AND c'. Always ask this one.
What about a broken rule?Raise an error when the rules are loaded, not halfway through checking transactions.

A couple of examples

InputOutput
t1 = amount 500, US. t2 = amount 1500, US. t3 = amount 200, RU. Rules: ['amount > 1000'][('ACCEPT', None), ('DENY', 'amount > 1000'), ('ACCEPT', None)]
Same transactions. Rules: ['amount > 100 AND country == RU'][('ACCEPT', None), ('ACCEPT', None), ('DENY', 'amount > 100 AND country == RU')]
Same transactions. Rules: ['country == US AND (amount > 1000 OR amount < 100)'][('ACCEPT', None), ('DENY', 'country == US AND (amount > 1000 OR amount < 100)'), ('ACCEPT', None)]

Start with brute force

For part 1, split each rule on spaces and compare, for every transaction and every rule. That's fine. The trap is part 2: people start splitting strings on ' OR ' and ' AND ', and that falls apart the moment brackets show up in part 3. You end up rewriting everything.

Cost: O(T × R) for T transactions and R rules, which is fine. The real cost is the rewrite you'll need later.

A better way

Parse each rule once into a small tree of objects that all have a matches(transaction) method. A Condition compares one field. An And or Or holds two smaller rules. Checking a transaction is just calling matches on the top of the tree. That's object-oriented design and trees working together.

Write a tiny parser with two functions. An expression is a term, followed by any number of 'AND term' or 'OR term', built up left to right. A term is either a condition or a bracketed expression. Brackets come almost for free, because a term can call expression again.

This also maps neatly onto the three parts. Part 1 only needs Condition. Part 2 adds And, Or and the loop. Part 3 adds the bracket branch, a handful of lines.

  1. Split the rule text into tokens, padding brackets with spaces first.
  2. expression(): read a term, then while the next token is AND or OR, read another term and join them into an And or Or node.
  3. term(): if the next token is '(', read an expression and expect ')'. Otherwise read field, operator, value and build a Condition.
  4. Parse every rule once up front, so a broken rule fails straight away.
  5. For each transaction, find the first rule whose tree matches. If there is one, deny and report it. Otherwise accept.

The code

solution.pypython
OPS = {
    ">": lambda a, b: a > b,
    "<": lambda a, b: a < b,
    ">=": lambda a, b: a >= b,
    "<=": lambda a, b: a <= b,
    "==": lambda a, b: a == b,
    "!=": lambda a, b: a != b,
}


# Part 1: one condition, like "amount > 1000".
class Condition:
    def __init__(self, field, op, value):
        self.field, self.op, self.value = field, op, value

    def matches(self, txn):
        if self.field not in txn:
            return False
        actual = txn[self.field]
        if isinstance(self.value, str) != isinstance(actual, str):
            return False  # never compare a number with a word
        return OPS[self.op](actual, self.value)


# Part 2: two rules joined by AND or OR.
class And:
    def __init__(self, left, right):
        self.left, self.right = left, right

    def matches(self, txn):
        return self.left.matches(txn) and self.right.matches(txn)


class Or:
    def __init__(self, left, right):
        self.left, self.right = left, right

    def matches(self, txn):
        return self.left.matches(txn) or self.right.matches(txn)


def tokenize(text):
    # Pad brackets with spaces so "(amount > 5)" splits cleanly.
    return text.replace("(", " ( ").replace(")", " ) ").split()


def parse_value(token):
    if token[0] in ("'", '"'):
        return token[1:-1]
    try:
        return float(token)
    except ValueError:
        return token


class Parser:
    def __init__(self, text):
        self.tokens = tokenize(text)
        self.pos = 0

    def peek(self):
        return self.tokens[self.pos] if self.pos < len(self.tokens) else None

    def take(self):
        token = self.peek()
        if token is None:
            raise ValueError("rule ended early")
        self.pos += 1
        return token

    def parse(self):
        rule = self.expression()
        if self.peek() is not None:
            raise ValueError("unexpected " + self.peek())
        return rule

    # expression = term (AND|OR term)*, read left to right with no precedence
    def expression(self):
        rule = self.term()
        while self.peek() is not None and self.peek().upper() in ("AND", "OR"):
            joiner = self.take().upper()
            right = self.term()
            rule = And(rule, right) if joiner == "AND" else Or(rule, right)
        return rule

    # term = ( expression ) | field op value
    def term(self):
        if self.peek() == "(":  # Part 3: brackets
            self.take()
            rule = self.expression()
            if self.take() != ")":
                raise ValueError("missing )")
            return rule
        field, op, value = self.take(), self.take(), self.take()
        if op not in OPS:
            raise ValueError("unknown operator " + op)
        return Condition(field, op, parse_value(value))


def check(transactions, rule_texts):
    # Parse every rule once, up front, so a bad rule fails fast.
    rules = [(text, Parser(text).parse()) for text in rule_texts]
    results = []
    for txn in transactions:
        hit = next((text for text, rule in rules if rule.matches(txn)), None)
        results.append(("DENY", hit) if hit else ("ACCEPT", None))
    return results
CostWhy
ParseO(L)Each token in a rule of length L is read once
CheckO(T × S)Every transaction against rules with S conditions in total, in the worst case
SpaceO(S)One node per condition and joiner

Test it

Walk through one of the examples out loud, then check the cases that tend to break things:

  • No precedence: 'country == RU OR amount > 400 AND country == US' denies t1 and t2 and accepts t3, because it reads as '(RU OR amount > 400) AND US'.
  • A missing field, like 'currency == EUR', matches nothing, so everything's accepted.
  • Extra brackets, like '((amount > 1000))', work the same as none.
  • Comparing a word with a number, like 'country > 5', is false instead of crashing.
  • Broken rules like 'amount >', '(amount > 1' or 'amount > 1 )' raise an error when loading.
  • No rules at all means everything's accepted.

Common mistakes

  • Splitting strings on ' AND ' and ' OR ' in part 2. It can't handle brackets, so part 3 becomes a rewrite.
  • Quietly applying normal precedence (AND before OR) when the question said there isn't any. Ask.
  • Comparing values as strings, so '900' > '1000' comes out true.
  • Parsing every rule again for every transaction, instead of once up front.

Follow-up questions

What if AND should bind tighter than OR?

Split expression into two levels: an or-expression is and-expressions joined by OR, and an and-expression is terms joined by AND. It's one extra small function.

How would you add NOT, or 'country in (RU, KP)'?

NOT is a new node type with one child, handled in term. 'in' is a new operator whose value is a set. The tree design means neither touches the existing classes.

What if there are thousands of rules?

Index rules by the fields they use, so you skip rules that can't match. Or merge rules sharing a condition so it's checked once.

How the time adds up

MinutesWhat you're doing
0–5Ask questions and agree on the rule format
5–10Examples, and plan the tree and parser
10–20Part 1: Condition and check
20–35Part 2: And, Or and the left-to-right loop
35–45Part 3: brackets
45–55Test the examples and edge cases
55–60Follow-ups

Key concepts used here

We wrote this sample solution ourselves. Practice Loop is independent and isn't affiliated with or endorsed by Stripe.