Integration Q2 - Stripe Integration Round (Build the Settlement Endpoint)

How the integration round actually works

The shape candidates consistently report:

  • You choose your language and IDE beforehand, share your screen, and clone a small service the interviewer gives you. It already runs; something is just missing or wired up wrong.
  • You are given an API to integrate with (here, a payment processor's test API) and a short spec: fetch some data, transform it a particular way, and send the result to a specific endpoint.
  • The repo ships with a suite of failing tests that encode the spec. Expect on the order of 10 to 15 failures. Some pass already (the scaffolding works), which tells you the plumbing you can trust.
  • It runs about 45 to 60 minutes. Getting one clean end-to-end call working, then refining the transform, beats half-building three things.
  • The single hard rule: fix the code, never the tests. The tests are the spec. Editing one to force it green is an instant fail signal.

The method that is actually being graded

Say this loop out loud, then run it for every failing test. This is what they grade:

  1. Run the suite first. The red/green map tells you what is missing and gives you a way to verify every change.
  2. Print the raw API responses before you transform anything. You cannot reshape data whose shape you are guessing at. Five print calls now save twenty minutes of wrong assumptions later.
  3. Take one failing test at a time. Read what it asserts; that is the spec. Reproduce, read the real values.
  4. Make the smallest change that matches the failure, then re-run. Narrate what you changed and why.
  5. Get to one green end-to-end call early, then tighten the details (signs, exponents, validation).

The repo you are handed

paykit-settle is a small service that produces an account's daily settlement. Once a day it should pull every balance transaction from the processor, roll them up per currency, normalize each currency into the account's settlement currency, and POST the result back. Amounts are in integer minor units (cents), the way real payment APIs represent money. Read README.md and the tests/ first: they are the spec, and fake_processor.py is a real in-process HTTP server, so your client makes genuine network calls against it.

paykit-settle: the repository (read the README and tests first)

Explorer

README.mdREADME.md
1# paykit-settle
2 
3A small service that produces the **daily settlement** for a connected account.
4Once a day it pulls every balance transaction for the account, rolls them up
5into a per-currency summary, normalizes each currency into the account's
6settlement currency, and posts the result back to the processor.
7 
8```
9 GET /v1/balance_transactions (paginated)
10client -> GET /v1/fx/rates?base=usd
11 POST /v1/settlements (the result)
12```
13 
14## Layout
15 
16```
17settle/
18 client.py HTTP client for the processor API (GET / POST, JSON)
19 rates.py FX rates provider + minor-unit exponent helpers
20 models.py BalanceTransaction
21 settlement.py build_settlement / run_settlement (the roll-up + transform)
22 service.py tiny HTTP router that exposes the settlement endpoint
23 errors.py
24fake_processor.py the processor's test API (paginated txns, fx rates, settlements)
25data/ the fixtures the fake processor serves
26tests/ the suite (do not edit these)
27run_tests.py runs the suite without pytest installed
28```
29 
30## Running it
31 
32```
33python3 run_tests.py # run the suite
34python3 fake_processor.py # poke the test API by hand on :8787
35```
36 
37## The task
38 
39The /settlements/run endpoint does not exist yet, the outbound POST does not
40go through, and the roll-up is wrong. Make the suite green without touching the
41files under tests/. A good first move is to print the raw API responses so you
42can see the exact shape of the data before you start transforming it.

Three things are wrong or missing, and the README says so plainly: the /settlements/run endpoint does not exist yet, the outbound POST does not go through, and the roll-up is incorrect. Everything else (pagination on the GET, the FX/exponent helper, the models) already works and is yours to lean on.

Step 1: Run the suite and read the map

Never start reading source top to bottom. Run the tests and let the failures point you:

$ python3 run_tests.py
...FF....F.FFFFF.FFF

FAILED tests.test_client::test_post_sends_json_and_is_accepted
    urllib.error.HTTPError: HTTP Error 400: Bad Request
FAILED tests.test_client::test_post_response_is_parsed_and_body_roundtrips
    urllib.error.HTTPError: HTTP Error 400: Bad Request
FAILED tests.test_settlement::test_excludes_pending_transactions
    AssertionError
FAILED tests.test_settlement::test_usd_line_separates_charges_from_refunds
    AssertionError
FAILED tests.test_settlement::test_usd_net_subtracts_refunds_and_fees
    AssertionError
FAILED tests.test_settlement::test_eur_line_is_normalized_to_settlement_currency
    AssertionError
FAILED tests.test_settlement::test_jpy_zero_decimal_line_is_normalized
    AssertionError
FAILED tests.test_settlement::test_total_net_is_the_sum_in_settlement_currency
    AssertionError
FAILED tests.test_service::test_run_route_is_registered
    AssertionError
FAILED tests.test_service::test_run_settles_end_to_end_and_posts_result
    AssertionError

11 failed, 9 passed in 8.67s

Now you have a map: 11 red, 9 green across three modules. Say it out loud: "The client's POST is 400ing, so my outbound call is malformed. The settlement roll-up is wrong in several ways. And the run endpoint is not even registered. Nine pass, so the GET client, the FX helper, and the models all work. I'll go: fix the POST, fix the transform, then add the endpoint that ties them together." That ordering, plumbing then transform then wiring, is itself a strong-hire signal.

Step 2: Print the raw data before you touch it

This is the habit that separates a smooth integration from a guessing game. Before reshaping anything, start the fake processor and print exactly what the API returns. Open a REPL or write a throwaway script:

import json
from settle.client import ProcessorClient

client = ProcessorClient("http://127.0.0.1:8787")     # python3 fake_processor.py in another shell
page = client.get("/v1/balance_transactions?account=acct_123&limit=4")
print(json.dumps(page, indent=2))
{
  "object": "list",
  "data": [
    { "id": "txn_01", "type": "charge", "amount": 20000, "currency": "usd", "fee": 610, "status": "available" },
    { "id": "txn_02", "type": "charge", "amount": 5000,  "currency": "usd", "fee": 175, "status": "available" },
    { "id": "txn_03", "type": "refund", "amount": -3000, "currency": "usd", "fee": 0,   "status": "available" },
    { "id": "txn_04", "type": "charge", "amount": 10000, "currency": "eur", "fee": 320, "status": "available" }
  ],
  "has_more": true
}

Three things jump out the moment you print it, and every one is a trap if you had guessed:

for t in page["data"]:
    print(t["id"], t["type"], t["amount"], t["currency"], t["status"])
txn_01 charge 20000 usd available
txn_02 charge 5000 usd available
txn_03 refund -3000 usd available     <- refunds are NEGATIVE, not a separate positive number
txn_04 charge 10000 eur available     <- more than one currency in the same account

"Refund amounts are already signed negative. There are multiple currencies. And has_more is true, so I have to page." Page all the way through and you also find a pending row (not yet settled, must be excluded) and a jpy row (yen has no decimal places, so 150000 means 150000 yen, not 1500.00). You only know all of this because you printed it. Say so: "Before I transform, I want to see the real data, signs, currencies, statuses, so I don't bake in a wrong assumption."

🌐 Group 1: Make the POST actually go through

Two tests fail with HTTP Error 400. The processor is rejecting the settlement POST outright, so nothing downstream can work until the outbound call is correct. Look at client.post: it stringifies the body with str(...) (Python's repr, not JSON), never sets Content-Type: application/json, and returns the raw bytes instead of parsed JSON.

settle/client.py

# before
def post(self, path, body):
    data = str(body).encode("utf-8")
    req = urllib.request.Request(self.base_url + path, data=data, method="POST")
    req.add_header("Authorization", f"Bearer {self.api_key}")
    with urllib.request.urlopen(req, timeout=5) as resp:
        return resp.read()

# after
def post(self, path, body):
    data = json.dumps(body).encode("utf-8")
    req = urllib.request.Request(self.base_url + path, data=data, method="POST")
    req.add_header("Authorization", f"Bearer {self.api_key}")
    req.add_header("Content-Type", "application/json")
    return self._send(req)

Say this: "Three problems in one method. str(body) produces Python repr with single quotes, which is not valid JSON, so the server 400s. There is no Content-Type, so even valid JSON would be ambiguous. And it returns raw bytes, so the caller cannot read resp["id"]. I serialize with json.dumps, declare the content type, and route through _send, which already parses the response and raises on errors." Reusing the existing _send (instead of writing a second parse-and-error path) is exactly the instinct they want to see.

settle/client.py fixed: yellow marks the changed lines; click any file to browse

Explorer

client.pymodifiedsettle/client.py
1import json
2import urllib.error
3import urllib.request
4 
5from .errors import ProcessorError
6 
7 
8class ProcessorClient:
9 """Thin HTTP client for the processor API.
10 
11 Every successful call returns a parsed JSON object (a dict). Non-2xx
12 responses raise ProcessorError with the status and the raw body.
13 """
14 
15 def __init__(self, base_url, api_key="sk_test_paykit"):
16 self.base_url = base_url.rstrip("/")
17 self.api_key = api_key
18 
19 def get(self, path):
20 req = urllib.request.Request(self.base_url + path, method="GET")
21 req.add_header("Authorization", f"Bearer {self.api_key}")
22 return self._send(req)
23 
24 def post(self, path, body):
25 data = json.dumps(body).encode("utf-8")
26 req = urllib.request.Request(self.base_url + path, data=data, method="POST")
27 req.add_header("Authorization", f"Bearer {self.api_key}")
28 req.add_header("Content-Type", "application/json")
29 return self._send(req)
30 
31 def _send(self, req):
32 try:
33 with urllib.request.urlopen(req, timeout=5) as resp:
34 raw = resp.read().decode("utf-8")
35 except urllib.error.HTTPError as e:
36 raise ProcessorError(e.code, e.read().decode("utf-8"))
37 return json.loads(raw)

💱 Group 2: The settlement roll-up

This is the heart of the question: the transform. Six tests describe exactly how the roll-up should behave, and the skeleton gets two things wrong. Both are the classic money-integration mistakes.

It throws away the sign on refunds

test_usd_line_separates_charges_from_refunds expects charges and refunds tracked separately (charged 25000, refunded -3500). The code does b["charged"] += abs(t.amount), which folds refunds and negative adjustments into charges as if they were income.

settle/settlement.py

# before
for t in available:
    b = buckets.setdefault(t.currency, {"charged": 0, "refunded": 0, "fees": 0})
    b["charged"] += abs(t.amount)
    b["fees"] += t.fee

# after
for t in available:
    b = buckets.setdefault(t.currency, {"charged": 0, "refunded": 0, "fees": 0})
    if t.amount >= 0:
        b["charged"] += t.amount
    else:
        b["refunded"] += t.amount
    b["fees"] += t.fee

Say this: "abs() is the tell. A refund is negative on purpose; taking its absolute value turns money going out into money coming in, so the net is overstated. I split on the sign: positives are charges, negatives are refunds and adjustments."

It sums different currencies as if they were the same

test_total_net_is_the_sum_in_settlement_currency expects one total in USD. The skeleton sets net_settlement = net (no conversion) and adds raw minor units across currencies, so 150000 yen gets added as if it were 150000 cents ($1500.00). The FX helper to fix this, rates.convert, already exists and handles exponents; the bug is that the transform never calls it.

settle/settlement.py

# before
for currency in sorted(buckets):
    b = buckets[currency]
    net = b["charged"] - b["fees"]          # refunds never subtracted
    net_settlement = net                    # no FX: raw minor units
    lines.append({ ... })
    total_net += net_settlement             # adds jpy minor units to usd minor units

# after
for currency in sorted(buckets):
    b = buckets[currency]
    net = b["charged"] + b["refunded"] - b["fees"]
    net_settlement = rates.convert(net, currency, settlement_currency)
    lines.append({ ... })
    total_net += net_settlement             # every line normalized before summing

Say this: "You can never add two currencies directly, and minor units do not even share a scale: yen has zero decimal places, dollars have two. I convert each currency's net into the settlement currency with the helper that is already here, which divides out the exponents and applies the rate, then sum those." Pointing out that 150000 JPY is $1000.00, not $1500.00, shows you actually understand the exponent trap.

settle/settlement.py fixed: yellow marks the changed lines; click any file to browse

Explorer

settlement.pymodifiedsettle/settlement.py
1from .models import BalanceTransaction
2 
3PAGE_SIZE = 4
4 
5 
6def _fetch_all_transactions(client, account, date):
7 """Page through every balance transaction for the account on a given date."""
8 txns = []
9 starting_after = None
10 while True:
11 path = f"/v1/balance_transactions?account={account}&created={date}&limit={PAGE_SIZE}"
12 if starting_after is not None:
13 path += f"&starting_after={starting_after}"
14 payload = client.get(path)
15 for row in payload["data"]:
16 txns.append(BalanceTransaction.from_dict(row))
17 if not payload.get("has_more"):
18 break
19 starting_after = payload["data"][-1]["id"]
20 return txns
21 
22 
23def build_settlement(client, rates, account, date, settlement_currency):
24 """Roll the available transactions up into one settlement payload.
25 
26 Per currency we separate money that came in (charges) from money that went
27 back out (refunds and negative adjustments), subtract the fees, and then
28 normalize every currency's net into the settlement currency before summing.
29 """
30 txns = _fetch_all_transactions(client, account, date)
31 available = [t for t in txns if t.status == "available"]
32 
33 buckets = {}
34 for t in available:
35 b = buckets.setdefault(t.currency, {"charged": 0, "refunded": 0, "fees": 0})
36 if t.amount >= 0:
37 b["charged"] += t.amount
38 else:
39 b["refunded"] += t.amount
40 b["fees"] += t.fee
41 
42 lines = []
43 total_net = 0
44 for currency in sorted(buckets):
45 b = buckets[currency]
46 net = b["charged"] + b["refunded"] - b["fees"]
47 net_settlement = rates.convert(net, currency, settlement_currency)
48 lines.append({
49 "currency": currency,
50 "charged": b["charged"],
51 "refunded": b["refunded"],
52 "fees": b["fees"],
53 "net": net,
54 "net_settlement": net_settlement,
55 })
56 total_net += net_settlement
57 
58 return {
59 "account": account,
60 "date": date,
61 "settlement_currency": settlement_currency,
62 "lines": lines,
63 "total_net": total_net,
64 }
65 
66 
67def run_settlement(client, rates, account, date, settlement_currency="usd"):
68 """Build the settlement and post it back to the processor."""
69 payload = build_settlement(client, rates, account, date, settlement_currency)
70 response = client.post("/v1/settlements", payload)
71 return {
72 "settlement_id": response["id"],
73 "status": response["status"],
74 "total_net": payload["total_net"],
75 "currency": settlement_currency,
76 }

🧩 Group 3: Build the new endpoint

Now wire it together. The service router answers /healthz but has no /settlements/run, so every request to it 404s. Add the route: parse the JSON request body, validate the required fields, call run_settlement (which builds the settlement and POSTs it), and return the response.

settle/service.py

# before
def handle(self, method, path, body=None):
    if method == "GET" and path == "/healthz":
        return 200, {"status": "ok"}

    return 404, {"error": f"no route for {method} {path}"}

# after
def handle(self, method, path, body=None):
    if method == "GET" and path == "/healthz":
        return 200, {"status": "ok"}

    if method == "POST" and path == "/settlements/run":
        req = json.loads(body) if isinstance(body, (str, bytes)) else (body or {})
        account = req.get("account")
        date = req.get("date")
        if not account or not date:
            return 400, {"error": "account and date are required"}
        result = run_settlement(
            self.client, self.rates, account, date,
            req.get("settlement_currency", "usd"),
        )
        return 200, result

    return 404, {"error": f"no route for {method} {path}"}

Say this: "The endpoint reads the request body with json.loads, guards the required fields with a 400 so a bad request fails loudly instead of deep in the transform, runs the settlement, and returns it. run_settlement already makes the outbound POST and returns the processor's id and status, so the handler just passes that back." Validating input before doing real work is a small touch that reads as production-minded.

settle/service.py fixed: yellow marks the changed lines; click any file to browse

Explorer

service.pymodifiedsettle/service.py
1import json
2 
3from .settlement import run_settlement
4 
5 
6class Service:
7 """The settlement service's tiny HTTP router.
8 
9 'handle' takes a method, a path, and a request body (a JSON string or an
10 already-decoded dict) and returns a (status_code, response_dict) tuple.
11 """
12 
13 def __init__(self, client, rates):
14 self.client = client
15 self.rates = rates
16 
17 def handle(self, method, path, body=None):
18 if method == "GET" and path == "/healthz":
19 return 200, {"status": "ok"}
20 
21 if method == "POST" and path == "/settlements/run":
22 req = json.loads(body) if isinstance(body, (str, bytes)) else (body or {})
23 account = req.get("account")
24 date = req.get("date")
25 if not account or not date:
26 return 400, {"error": "account and date are required"}
27 result = run_settlement(
28 self.client,
29 self.rates,
30 account,
31 date,
32 req.get("settlement_currency", "usd"),
33 )
34 return 200, result
35 
36 return 404, {"error": f"no route for {method} {path}"}

The result: green, with the tests untouched

The POST serializes and parses, the roll-up tracks signs and normalizes currencies, and the endpoint exists. Here is the transform on the real fixture, the thing the end-to-end test checks:

currency   charged   refunded   fees       net        -> usd (settlement)
eur          14000          0    465      13535            14712
gbp           8000          0    280       7720             9772
jpy         150000          0      0     150000           100000   (150000 yen = $1000.00)
usd          25000      -3500    785      20715            20715
                                                  total =      145199   ($1451.99)

The service builds that payload, POSTs it to /v1/settlements, gets back { "id": "setl_0001", "status": "created" }, and returns it. Three files changed, every change a few lines, the tests byte-for-byte identical to when you started:

paykit-settle after the integration (three files modified, tests untouched)

Explorer

service.pymodifiedsettle/service.py
1import json
2 
3from .settlement import run_settlement
4 
5 
6class Service:
7 """The settlement service's tiny HTTP router.
8 
9 'handle' takes a method, a path, and a request body (a JSON string or an
10 already-decoded dict) and returns a (status_code, response_dict) tuple.
11 """
12 
13 def __init__(self, client, rates):
14 self.client = client
15 self.rates = rates
16 
17 def handle(self, method, path, body=None):
18 if method == "GET" and path == "/healthz":
19 return 200, {"status": "ok"}
20 
21 if method == "POST" and path == "/settlements/run":
22 req = json.loads(body) if isinstance(body, (str, bytes)) else (body or {})
23 account = req.get("account")
24 date = req.get("date")
25 if not account or not date:
26 return 400, {"error": "account and date are required"}
27 result = run_settlement(
28 self.client,
29 self.rates,
30 account,
31 date,
32 req.get("settlement_currency", "usd"),
33 )
34 return 200, result
35 
36 return 404, {"error": f"no route for {method} {path}"}
$ python3 run_tests.py
....................

20 passed in 7.72s

A correct outbound call, a transform that respects signs and currencies, and a clean endpoint on top: that is the entire integration round in miniature.

What earns a Strong Hire

Your game plan, start to finish

  1. Run the suite immediately and read the summary as a to-do list: plumbing, transform, wiring.
  2. Start the fake API and print the raw responses. Confirm the shape before you reshape it.
  3. Get the outbound call correct first (serialize the body, set the header, parse the response), so a real POST can succeed.
  4. Fix the transform in small steps, signs first, then currency normalization, re-running after each.
  5. Add the endpoint last, parse and validate the request, return the result, and confirm the end-to-end test is green.

Practice this loop, read an API, print it, reshape it, post it back, against any public test API in your strongest language until "print first, transform carefully, verify each step" is automatic. Do that, and Stripe's integration round becomes a demonstration of exactly the work they are hiring you to do. 🚀

Was this page helpful?