Card authorization report with fraud rules

0
Remix

A payment platform receives authorization requests: a card holder's intent to pay a merchant. Write a function that decides each request and returns a report.

Each request is a CSV string:

<timestamp_seconds>,<request_id>,<amount>,<card_number>,<merchant>

The input is well-formed. Fields contain no commas or spaces. Timestamps are positive integers. Amounts are positive decimals. Request ids are unique. The list is not guaranteed to be in time order.

Part 1

Approve every request. Return one line per request, sorted by timestamp (ties keep input order), in the format:

<timestamp> <request_id> <amount with 2 decimals> <APPROVE|REJECT>

Write tests that show it works.

Part 2

Add a list of rules. Each rule is "<field>,<op>,<value>", where field is amount, card or merchant, and op is one of >, >=, <, <=, ==, !=. Reject a request that matches any rule.

Part 3

Add fraud reports, each "<timestamp>,<request_id>", which say an earlier request was fraudulent. From the report's timestamp on (inclusive), reject every request made with that request's card. Earlier decisions do not change. Ignore reports for unknown ids.

Example

requests = ["30,r3,12.5,4000111122223333,cafe_one",
            "10,r1,250,4000999988887777,bike_shop",
            "20,r2,7.25,4000111122223333,book_nook"]

Part 1 gives ["10 r1 250.00 APPROVE", "20 r2 7.25 APPROVE", "30 r3 12.50 APPROVE"].

With the rule amount,>,100, r1 becomes REJECT.

With the fraud report 25,r2 (no rules), r3 becomes REJECT, because it uses the same card as r2 and comes after the report. r2 itself stays APPROVE.

Reconstructed from the reported question; details may differ from what interviewers ask.

StripeCodingMedium

What L5 looks like

  • Structures the code as parse, then order, then decide, then format, so Part 2 and Part 3 drop in with few changes.
  • Sees that fraud reports make decisions depend on time. Builds one merged timeline with an explicit tie-break rule and states its invariant.
  • Asks about boundaries early: inclusive report timestamps, unknown ids, and > versus >=.
Interviewer expectations for every level ↓

Approach

Clarify first. Each request is timestamp,id,amount,card,merchant. The input is already validated, but it is not guaranteed to be in time order. The report has one line per request, sorted by timestamp, in the form timestamp id amount DECISION, with the amount shown to two decimal places. Ask how to order two requests with the same timestamp. The safe answer is input order, so the sort must be stable. Ask whether amounts can carry cents. They can, so parse them as Decimal, never float.

Part 1: approve everything. Parse each line into a small record, sort by (timestamp, input position), and format each one with APPROVE. The interviewer is not testing the algorithm here. They are checking for clean parsing, a typed record instead of index juggling, correct money handling, and tests. Build the structure now that later parts can plug into: parse, then order, then decide, then format.

Part 2: static rules. The interviewer adds rules such as "reject any amount over 500" or "reject merchant X". Model each rule as (field, operator, value) and keep the operators in a table (>, >=, <, <=, ==, !=). A request that matches any rule is rejected. Compare amounts as Decimal and compare card and merchant as strings. Each rule is a pure check on one request, so it does not depend on the order of the requests.

Part 3: fraud reports change state over time. A report timestamp,request_id says an earlier request was fraudulent. From the report's timestamp on, every request on that card is rejected. The decision now depends on history, so you cannot decide requests independently. Merge reports and requests into one timeline and sweep through it in time order, keeping a set of blocked cards.

  • Tie rule. At the same timestamp, a report is applied before a request. Encode this in the sort key (timestamp, kind, position) with reports as kind 0. The choice is arbitrary, so state it out loud and test it.
  • Invariant. When the sweep reaches a request at time t, blocked_cards holds exactly the cards named by reports with timestamp <= t. So each decision uses only information that existed at that moment.
  • A report does not rewrite the decision already made for the reported request. The report is history, and past report lines stay as they were.
  • Ignore a report for an unknown id, or ask the interviewer whether it should raise an error.

Code

from __future__ import annotations

from dataclasses import dataclass
from decimal import Decimal
import operator

APPROVE, REJECT = "APPROVE", "REJECT"
OPS = {">": operator.gt, ">=": operator.ge, "<": operator.lt,
       "<=": operator.le, "==": operator.eq, "!=": operator.ne}


@dataclass(frozen=True)
class Request:
    timestamp: int
    request_id: str
    amount: Decimal
    card: str
    merchant: str
    seq: int  # input position, breaks timestamp ties


@dataclass(frozen=True)
class Rule:
    field: str  # "amount" | "card" | "merchant"
    op: str
    value: str

    def matches(self, req: Request) -> bool:
        left = getattr(req, self.field)
        right = Decimal(self.value) if self.field == "amount" else self.value
        return OPS[self.op](left, right)


def parse_request(line: str, seq: int) -> Request:
    ts, rid, amount, card, merchant = line.split(",")
    return Request(int(ts), rid, Decimal(amount), card, merchant, seq)


def parse_rule(line: str) -> Rule:
    field, op, value = line.split(",")
    if field not in {"amount", "card", "merchant"} or op not in OPS:
        raise ValueError(f"bad rule: {line}")
    return Rule(field, op, value)


def process(requests: list[str], rules: list[str] | None = None,
            fraud_reports: list[str] | None = None) -> list[str]:
    """Return one report line per request, in timestamp order.

    Part 1: no rules, no reports -> everything is approved.
    Part 2: a request matching any rule is rejected.
    Part 3: "<timestamp>,<request_id>" reports block the reported card
            from that timestamp on (inclusive).
    """
    reqs = sorted((parse_request(s, i) for i, s in enumerate(requests)),
                  key=lambda r: (r.timestamp, r.seq))
    parsed_rules = [parse_rule(r) for r in rules or []]
    by_id = {r.request_id: r for r in reqs}

    # Event list: reports sort before requests at the same timestamp.
    events: list[tuple[int, int, int, object]] = []
    for i, line in enumerate(fraud_reports or []):
        ts, rid = line.split(",")
        events.append((int(ts), 0, i, rid))
    for r in reqs:
        events.append((r.timestamp, 1, r.seq, r))
    events.sort(key=lambda e: e[:3])

    blocked_cards: set[str] = set()
    out: list[str] = []
    for _, kind, _, payload in events:
        if kind == 0:
            reported = by_id.get(payload)  # unknown ids are ignored
            if reported is not None:
                blocked_cards.add(reported.card)
            continue
        req = payload
        bad = req.card in blocked_cards or any(
            rule.matches(req) for rule in parsed_rules)
        decision = REJECT if bad else APPROVE
        out.append(f"{req.timestamp} {req.request_id} "
                   f"{req.amount.quantize(Decimal('0.01'))} {decision}")
    return out

Complexity

Let n be the number of requests, m the number of fraud reports and k the number of rules.

  • Parsing: O(n + m + k) time.
  • Sorting the timeline: O((n + m) log(n + m)) time.
  • The sweep: O((n + m) + n * k) time. Each request checks every rule, and the blocked-card lookup is O(1).
  • Space: O(n + m) for the records, the id index and the output, plus O(c) for blocked cards.

Test cases

  • Part 1, out of order: ["30,r3,12.5,4000111122223333,cafe_one", "10,r1,250,4000999988887777,bike_shop", "20,r2,7.25,4000111122223333,book_nook"] gives ["10 r1 250.00 APPROVE", "20 r2 7.25 APPROVE", "30 r3 12.50 APPROVE"].
  • Empty input gives [].
  • Same timestamp keeps input order: ["5,b,1,c1,m", "5,a,2,c2,m"] gives ["5 b 1.00 APPROVE", "5 a 2.00 APPROVE"].
  • Rule amount,>,100 on the first input rejects only r1.
  • Rules merchant,==,book_nook and amount,>=,250: r1 (boundary, >=) and r2 are rejected. r3 is approved.
  • Report 25,r2: r1 and r2 are approved, and r3 (same card, time 30) is rejected.
  • Report 30,r2: r3 at time 30 is rejected, because a report at the same timestamp applies first.
  • Report 31,r2: r3 is approved, because the report arrives after it.
  • Report for an unknown id 5,zzz: no change.

Follow-ups

  1. Block merchants too. After a merchant has F reported frauds within a window, reject its requests. Keep a per-merchant deque of report timestamps and drop the ones older than the window on each check. That is amortized O(1) per event.
  2. Velocity rules. Reject a card that makes more than N requests, or spends more than X, within W seconds. Keep a per-card sliding window (a deque of (timestamp, amount) plus a running sum). Decide whether rejected requests count toward the limit, and make it a parameter.
  3. Streaming instead of batch. Requests arrive one at a time and slightly out of order. Buffer events in a min-heap and release them only when they are older than a watermark (now - max_delay). The sweep body stays the same. A request arriving after the watermark has to be decided late or rejected by policy.
  4. Rule priority and allow-lists. Add ALLOW rules that override REJECT rules, as many fraud systems do. Evaluate the rules in priority order and stop at the first match. Then report which rule fired on each output line so the report is explainable.
  5. Retroactive reports. If a report should also cover requests that were approved before it arrived, decisions are no longer final. Either run a second pass that flags affected requests as REVIEW, or keep each card's request list and re-emit the affected lines.

Interviewer expectations

Mid-level (L4)

  • Parses the CSV into a named record and sorts by timestamp with a stable tie-break.
  • Formats amounts correctly with Decimal, not float.
  • Writes runnable tests, including empty input and same-timestamp cases.

Senior (L5)

  • Structures the code as parse, then order, then decide, then format, so Part 2 and Part 3 drop in with few changes.
  • Sees that fraud reports make decisions depend on time. Builds one merged timeline with an explicit tie-break rule and states its invariant.
  • Asks about boundaries early: inclusive report timestamps, unknown ids, and > versus >=.

Staff (L6+)

  • Discusses streaming with late events, watermarks, and what "final" means for a decision.
  • Designs the rule engine for priority, allow-lists, explainability, and rules that can change without a redeploy.
  • Considers what happens at scale: shard the state by card, and discuss the trade-off between false positives and fraud loss when choosing windows and thresholds.

Make it yours

Remix it: write each part yourself, grade it against this answer, then let the coach ask you follow-ups.