Merchant onboarding CSV validator

0
⑂ RemixPractice

Merchants submit their business details as rows of a simple CSV. Before a merchant can accept payments, every row must pass a set of checks. Write validate_records(data, forbidden_words, ignore_words) -> list[str].

Input

  • data: a list of strings. data[0] is a header with six comma-separated column names: business_name, legal_name, statement_descriptor, short_descriptor, url, product_description. The columns can appear in any order. Each later string is one merchant. You can assume fields contain no commas or quotes.
  • forbidden_words: words or multi-word phrases that may not appear in customer-facing fields.
  • ignore_words: filler words (for example the, inc, llc) that are skipped when comparing the name with the descriptor.

Output

One string per data row, in order: "VERIFIED", or "NOT VERIFIED: <reason>" for the first rule that fails.

Rules (checked in this order; trim fields, compare case-insensitively, and treat any non-alphanumeric character as a word separator)

  1. Every column is non-blank. Reason: missing <column>. A row with the wrong number of cells gets malformed row.
  2. statement_descriptor is 5–22 characters long, and short_descriptor is 2–10. Reason: <column> length.
  3. No forbidden word or phrase appears, as whole words, in business_name, statement_descriptor or short_descriptor. Reason: forbidden word in <column>.
  4. Remove ignore words from business_name and from statement_descriptor. At least half of the name's remaining distinct words must appear in the descriptor. If no words remain in the name, the rule fails. Reason: descriptor does not match business_name.

Example 1

forbidden_words = ["online store"]
ignore_words    = ["llc", "co"]
data = [
  "business_name,legal_name,statement_descriptor,short_descriptor,url,product_description",
  "Juniper Bakery,Juniper Bakery LLC,JUNIPER BAKERY,JUNIPER,juniper.example,Bread",
  "Best Online Store,Best LLC,BEST ONLINE STORE,BEST,best.example,Gadgets",
  "Juniper Bakery,Juniper Bakery LLC,JB,JUNIPER,juniper.example,Bread",
]

Output: ["VERIFIED", "NOT VERIFIED: forbidden word in business_name", "NOT VERIFIED: statement_descriptor length"].

Example 2

Northwind Trading Co with descriptor NORTHWIND PAYMENTS is verified: co is ignored, and 1 of the 2 remaining words matches, which is exactly half. Blue Harbor Fish Market with descriptor HARBOR GOODS fails rule 4, because only 1 of 4 words matches.

Expect follow-ups such as reporting every failed rule, handling quoted CSV fields, and making the rules configurable.

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

StripeTechnical phone screenMediumLast asked Oct 2026

What L5 looks like

  • Matches forbidden phrases on whole words and explains why substring matching is wrong.
  • Gets the "at least half" rule right, including rounding and a name made only of ignore words.
  • Structures the rules so each new part is a small addition, and calls out the split(",") quoting limit.
Interviewer expectations for every level ↓

Approach

Each data row is one merchant. Run a fixed list of rules against it, stop at the first rule that fails, and report VERIFIED or NOT VERIFIED: <reason>. The interview is usually staged: part 1 is "all fields present", and each later part adds one rule. So the real test is whether the code stays easy to extend, not whether any single rule is hard.

Parse once, by header name. Read the header, lowercase and trim its names, and zip each row into a dict. Rules then say rec["statement_descriptor"], not cells[2]. The code keeps working if the columns come in another order, and a row with the wrong number of cells is caught before any rule runs.

Tokenize once, the same way everywhere. words() lowercases and splits on anything that is not a letter or digit. Every word-level rule uses it, so "Juniper-Bakery", "juniper bakery" and "JUNIPER BAKERY," all compare equal. Most bugs in this question come from mixing split(" ") in one rule with in substring checks in another.

Rules, in order:

  1. Completeness. All six required fields are non-blank after trimming.
  2. Length. statement_descriptor is 5–22 characters. short_descriptor is 2–10.
  3. Forbidden words. business_name, statement_descriptor and short_descriptor contain no forbidden entry. Entries can be phrases ("online store"), so match a run of whole tokens, not a substring. A substring check would reject "Testa Foods" for containing "test".
  4. Name match. Drop ignore words (llc, inc, the, ...) from both the business name and the descriptor. At least half of the name's remaining distinct words must appear in the descriptor. If nothing is left of the name, the row fails, because there is nothing to match against.

Brute force vs. better. The obvious version is fine. Forbidden-phrase matching is O(tokens × phrases × phrase length) per field, which is tiny for real descriptors. Say so, then mention the upgrade (index the phrases by first token, or use Aho–Corasick over tokens) only if the forbidden list is huge.

Invariants:

  • Rules run in a fixed order and the first failure wins, so each row gets exactly one deterministic reason.
  • Ignore words matter only in rule 4. A forbidden word is still forbidden even if it is also an ignore word.
  • The header is never validated as data, and an input with only a header returns [].

Code

import re
from typing import Iterable, Optional

REQUIRED = ("business_name", "legal_name", "statement_descriptor",
            "short_descriptor", "url", "product_description")
_WORD = re.compile(r"[a-z0-9]+")


def words(text: str) -> list[str]:
    """Lowercase alphanumeric tokens. Punctuation separates words."""
    return _WORD.findall(text.lower())


def contains_phrase(tokens: list[str], phrase: list[str]) -> bool:
    """True if `phrase` appears as consecutive whole words in `tokens`."""
    k = len(phrase)
    return k > 0 and any(tokens[i:i + k] == phrase
                         for i in range(len(tokens) - k + 1))


class RecordValidator:
    def __init__(self, forbidden_words: Iterable[str],
                 ignore_words: Iterable[str]) -> None:
        self.forbidden = [p for p in (words(w) for w in forbidden_words) if p]
        self.ignore = {t for w in ignore_words for t in words(w)}

    def _check(self, rec: dict[str, str]) -> Optional[str]:
        """Return the first failed rule's reason, or None if all pass."""
        # Rule 1: every required field is present and non-blank.
        for col in REQUIRED:
            if not rec.get(col, "").strip():
                return f"missing {col}"
        # Rule 2: descriptor lengths.
        if not 5 <= len(rec["statement_descriptor"].strip()) <= 22:
            return "statement_descriptor length"
        if not 2 <= len(rec["short_descriptor"].strip()) <= 10:
            return "short_descriptor length"
        # Rule 3: no forbidden word or phrase in the customer-facing fields.
        for col in ("business_name", "statement_descriptor", "short_descriptor"):
            toks = words(rec[col])
            for phrase in self.forbidden:
                if contains_phrase(toks, phrase):
                    return f"forbidden word in {col}"
        # Rule 4: the descriptor must recognisably name the business.
        name = {t for t in words(rec["business_name"]) if t not in self.ignore}
        desc = {t for t in words(rec["statement_descriptor"]) if t not in self.ignore}
        if not name or 2 * len(name & desc) < len(name):
            return "descriptor does not match business_name"
        return None

    def validate(self, data: list[str]) -> list[str]:
        if not data:
            return []
        header = [h.strip().lower() for h in data[0].split(",")]
        out = []
        for line in data[1:]:
            cells = line.split(",")
            if len(cells) != len(header):
                out.append("NOT VERIFIED: malformed row")
                continue
            rec = dict(zip(header, (c.strip() for c in cells)))
            reason = self._check(rec)
            out.append("VERIFIED" if reason is None else f"NOT VERIFIED: {reason}")
        return out


def validate_records(data: list[str], forbidden_words: list[str],
                     ignore_words: list[str]) -> list[str]:
    return RecordValidator(forbidden_words, ignore_words).validate(data)

Complexity

Let n be the number of rows, L the length of a row, F the total number of tokens across the forbidden phrases, and I the number of ignore words.

  • Constructor: O(F + I) to tokenize the lists once.
  • validate: O(n · L) to split and tokenize. Rule 3 is O(t · F) per field for t tokens in the field, and t ≤ 22 for descriptors. Rule 4 is O(t) with sets. Overall about O(n · (L + F)).
  • Space: O(F + I) for the lists, O(L) per row being checked, and O(n) for the output.

Test cases

Forbidden = ["online store", "test"], ignore = ["the", "llc", "inc", "co", "and"], header business_name,legal_name,statement_descriptor,short_descriptor,url,product_description.

  • Juniper Bakery,Juniper Bakery LLC,JUNIPER BAKERY,JUNIPER,juniper.example,Bread and pastries → VERIFIED.
  • Same row with an empty statement_descriptor → NOT VERIFIED: missing statement_descriptor.
  • Descriptor JB (2 chars) → NOT VERIFIED: statement_descriptor length.
  • Descriptor ACME WIDGETS 1234567890123 (26 chars) → NOT VERIFIED: statement_descriptor length.
  • Business name Best Online Store → NOT VERIFIED: forbidden word in business_name (a two-word phrase match).
  • Business name Testa Foods, descriptor TESTA FOODS → VERIFIED. test is matched as a whole word, not a substring.
  • Northwind Trading Co with descriptor NORTHWIND PAYMENTS → VERIFIED. co is ignored, and 1 of the 2 remaining words matches, which is exactly half.
  • Blue Harbor Fish Market with descriptor HARBOR GOODS → NOT VERIFIED: descriptor does not match business_name (1 of 4 words).
  • The Inc LLC → name match fails, because every word is an ignore word.
  • Row a,b,c under a six-column header → NOT VERIFIED: malformed row.
  • Columns in a different order in the header → still validated correctly, by name.
  • [] or a header-only input → [].

Follow-ups

  1. Report every failure, not the first one. Have each rule return a reason or None, collect all of them, and output NOT VERIFIED: a; b. With the rule list kept as data (self.rules = [check_complete, check_length, ...]), this is a one-line change.
  2. Real CSV quoting. Business names contain commas ("Smith, Jones & Co"). Replace line.split(",") with csv.reader, which handles quotes and escaped quotes. Say this out loud early. Interviewers like hearing that split(",") is a known simplification.
  3. Configurable rules per country or product. Load the length bounds, the required columns and the forbidden list from config, keyed by country. Rules become small objects with a check(rec) -> Optional[str] method, and the validator just runs a list of them.
  4. Fuzzy name matching. The descriptor JNPR BAKERY should probably still match. Compare normalized tokens with a prefix rule, or with edit distance below a threshold per token. Keep the "at least half" rule, but count a fuzzy hit as a match. Discuss false positives.
  5. Streaming large files. Read the input with a generator, write results as you go, and keep only one row in memory. If the forbidden list is large, precompile it into a token trie (or Aho–Corasick) once, then scan each field in O(t).

Interviewer expectations

Mid-level (L4)

  • Gets parts 1–3 working with clear helper functions and a header-based record.
  • Trims fields and compares case-insensitively without being told.
  • Writes a few tests, including an empty field and a length boundary.

Senior (L5)

  • Matches forbidden phrases on whole words and explains why substring matching is wrong.
  • Gets the "at least half" rule right, including rounding and a name made only of ignore words.
  • Structures the rules so each new part is a small addition, and calls out the split(",") quoting limit.

Staff (L6+)

  • Moves the rules to data or config and talks about per-country variants, versioning and explainable reasons for support teams.
  • Discusses fuzzy matching trade-offs (false approvals vs. manual-review load) and how to measure them.
  • Covers scale: streaming, a precompiled phrase matcher, and where this sits in an onboarding pipeline (sync check at signup, async re-check when the lists change).

Make it yours

Write each chapter yourself, check it against this answer, then let the coach grill you.