Compute transaction fees from a CSV string
Company: Stripe
Role: Data Engineer
Category: Coding & Algorithms
Difficulty: hard
Interview Round: Technical Screen
You are given a CSV **string** representing a list of payment transactions. Write a function that parses the CSV and returns a CSV string with the **total processing fee per merchant**.
### Input CSV
The input has a header and the following columns:
- `transaction_id` (string)
- `merchant_id` (string) — the “person” you should aggregate fees for
- `status` (string) — one of:
- `payment_completed`, `payment_pending`, `payment_failed`,
- `refund_completed`,
- `dispute_won`, `dispute_lost`
- `payment_provider` (string) — e.g., `card`, `bank_transfer`, `paypal`
- `buyer_country` (string, ISO-2) — e.g., `US`, `DE`, `FR`
- `currency` (string, ISO-3) — e.g., `USD`, `EUR`
- `amount` (decimal) — transaction amount in the given `currency`
Assume:
- The CSV may contain extra whitespace; you should handle it.
- All amounts are non-negative.
### Fee rules
You are also given (as in-memory dictionaries/maps):
1) `base_rate_by_provider[payment_provider] -> decimal`
Used for **all statuses except** `payment_completed`.
2) `completed_rate_by_provider_and_country[(payment_provider, buyer_country)] -> decimal`
Used **only when** `status = 'payment_completed'`.
3) `fx_to_usd[currency] -> decimal`
Multiply an amount in `currency` by this factor to convert to USD.
Fee calculation per transaction:
- If `status = 'refund_completed'`: fee is **0** (no refund fee).
- If `status = 'payment_completed'`:
- Convert `amount` to USD: `amount_usd = amount * fx_to_usd[currency]`.
- `fee_usd = amount_usd * completed_rate + 0.30` where `completed_rate` comes from `completed_rate_by_provider_and_country`.
- For all other statuses (`payment_pending`, `payment_failed`, `dispute_won`, `dispute_lost`):
- Convert `amount` to USD: `amount_usd = amount * fx_to_usd[currency]`.
- `fee_usd = amount_usd * base_rate + 0.30` where `base_rate` comes from `base_rate_by_provider`.
Lookups and fallbacks:
- If `(payment_provider, buyer_country)` is missing for a completed payment, fall back to `base_rate_by_provider[payment_provider]`.
- If a `payment_provider` is unknown, treat its rate as `0` (still apply the `$0.30` fixed fee for non-refunds).
- If `currency` is unknown, assume `fx_to_usd[currency] = 1.0`.
### Required output
Return a CSV **string** with header:
`merchant_id,total_fee_usd`
Where `total_fee_usd` is the sum of `fee_usd` across that merchant’s transactions, rounded to 2 decimals. Order rows by `merchant_id` ascending.
### What to implement
Implement a function (in a language of your choice):
`compute_fees_per_merchant(csv_str, base_rate_by_provider, completed_rate_by_provider_and_country, fx_to_usd) -> csv_str`
Discuss time complexity and how you’d test edge cases (e.g., empty input, malformed rows, missing lookup keys).
Overview: Evaluates CSV parsing, numeric conversions and rounding, map lookups with fallbacks, transactional fee computation and per-merchant aggregation in the Coding & Algorithms category for Data Engineer roles.
Read the full Stripe Data Engineer interview experience this question came from
Part 1: Parse a transaction CSV string and compute each user's fees
Parse a CSV of payment transactions and compute the **total processing fee** owed by each user, returned in **integer cents**.
## What to implement
Implement `solution(csv_string)`. The input is a single CSV string; return a **dictionary mapping each user to their total fee, expressed as a whole number of cents** (an integer).
## Input format
When non-empty, the CSV begins with the exact header line:
```
user,status,payment_provider,amount
```
Each subsequent line is one transaction with these fields:
- **`user`** — the user the transaction belongs to.
- **`status`** — one of `payment_completed`, `payment_failed`, `payment_pending`, `refund_completed`, `dispute_lost`, `dispute_won`.
- **`payment_provider`** — one of `card`, `bank_transfer`, `wallet`.
- **`amount`** — the transaction amount in **US dollars**, a non-negative decimal with at most 2 digits after the decimal point.
## Fee rules
Compute the fee for each transaction based on its `status`:
- **`payment_completed`, `payment_failed`, `payment_pending`** — the fee is a percentage of `amount` plus a fixed **$0.30**:
`fee = amount × provider_rate + 0.30` (USD)
where `provider_rate` depends on the payment provider:
| provider | rate |
|---|---|
| `card` | 2.9% |
| `bank_transfer` | 1.0% |
| `wallet` | 1.5% |
- **`dispute_lost`** — a flat **$15.00** fee.
- **`refund_completed`** — **$0** (no fee).
- **`dispute_won`** — **$0** (no fee).
## Rounding and accumulation
- Round **each transaction's fee** to the nearest cent using **half-up** rounding, then convert it to an integer number of cents **before** adding it to that user's running total.
- A user's returned value is the **sum of their per-transaction cent fees**.
## Output and edge cases
- Return a **dictionary** mapping `user` → total fee **in cents** (integer).
- **Include every user that appears** in the CSV, even if their total fee is `0` (for example, a user with only a `refund_completed` or `dispute_won` transaction still appears, mapped to `0`).
- **Ignore blank lines** (and any row with no user).
- If the input string is **empty or whitespace-only**, return an **empty dictionary** `{}`.
## Examples
A `card` `payment_completed` of `100.00` produces `100.00 × 0.029 + 0.30 = 3.20` USD → **320** cents.
A `wallet` `payment_completed` of `0.99` produces `0.99 × 0.015 + 0.30 = 0.31485` USD, which rounds half-up to `0.31` USD → **31** cents.
Constraints
- 0 <= number of transactions <= 100000
- The CSV has the exact header user,status,payment_provider,amount when non-empty
- amount is a non-negative decimal number with at most 2 digits after the decimal point
- status is one of payment_completed, payment_failed, payment_pending, refund_completed, dispute_lost, dispute_won
- payment_provider is one of card, bank_transfer, wallet
Examples
Input: "user,status,payment_provider,amount\nalice,payment_completed,card,100.00\nbob,payment_failed,bank_transfer,50.00\nalice,refund_completed,card,20.00\nbob,dispute_lost,wallet,80.00"
Expected Output: {"alice": 320, "bob": 1580}
Explanation: alice pays 100.00 * 2.9% + 0.30 = 3.20 => 320 cents. Her refund adds 0. bob pays 50.00 * 1.0% + 0.30 = 0.80 => 80 cents, plus a lost dispute fee of 1500 cents.
Input: "user,status,payment_provider,amount\nalice,payment_pending,wallet,10.00\nalice,payment_completed,wallet,0.99\ncarol,dispute_won,card,42.00"
Expected Output: {"alice": 76, "carol": 0}
Explanation: alice pays 45 cents for 10.00 via wallet and 31 cents for 0.99 via wallet after rounding, totaling 76. carol appears in the CSV, so she is included with fee 0.
Hints
- Use the CSV header to access fields by name instead of relying on column positions.
- Avoid floating-point errors by using Decimal or by converting each rounded fee into cents before summing.
Part 2: Compute fees in USD for completed transactions using country-specific rates
Compute the total payment-processing fee charged to each user, in **whole USD cents**, given a CSV of transactions and a currency-to-USD exchange-rate table.
## Function
```
solution(csv_string, exchange_rates)
```
## Inputs
- **`csv_string`** — a CSV string whose header (when non-empty) is exactly:
`user,status,payment_provider,buyer_country,currency,amount`
- **`exchange_rates`** — a dictionary where `exchange_rates[currency]` is the USD value of **1 unit** of that currency.
## Output
Return a dictionary mapping each **`user`** to their **total fee in USD cents** (an integer).
Every user that appears in the CSV must be present in the result — **even if their total fee is `0`**. If `csv_string` is empty or contains only whitespace, return `{}`. (Key order in the returned dictionary does not matter.)
## How to process each transaction
**1. Convert the amount to USD:**
```
converted_usd = amount * exchange_rates[currency]
```
**2. Compute the transaction fee in USD based on `status`:**
### `payment_completed`
Fee = `converted_usd * rate + 0.30`, where `rate` depends on the **provider** and **buyer_country**:
| Provider | US / CA | GB | DE / FR / AT | All other countries |
|-----------------|---------|-------|--------------|---------------------|
| `card` | 2.9% | 2.5% | 2.3% | 3.1% |
| `bank_transfer` | 1.2% | 1.1% | 1.0% | 1.4% |
| `wallet` | 1.8% | 1.8% | 1.8% | 1.8% |
(`wallet` is **1.8%** for any country.)
### `payment_failed` and `payment_pending`
Use the **provider-only** (country-independent) rates from Part 1 on the converted USD amount:
| Provider | Rate |
|-----------------|-------|
| `card` | 2.9% |
| `bank_transfer` | 1.0% |
| `wallet` | 1.5% |
Fee = `converted_usd * rate + 0.30`.
### Other statuses
- `refund_completed` → fee = **0 USD**
- `dispute_won` → fee = **0 USD**
- `dispute_lost` → fee = **15.00 USD flat** (no conversion, no percentage, no fixed add-on)
**3. Round and accumulate:**
Round **each transaction's fee** to the nearest cent using **half-up rounding** (e.g. 0.005 rounds up to 0.01), then add the resulting integer cents to that user's running total. Round per transaction — **before** adding to the total, not after summing.
## Notes
- **Skip any row whose `user` field is empty** (after trimming surrounding whitespace), including fully blank lines.
- Even users whose only transactions are `refund_completed`, `dispute_won`, or otherwise sum to `0` must still appear in the result with a total of `0`.
## Constraints
- `0 <= number of transactions <= 100000`
- The CSV has the exact header `user,status,payment_provider,buyer_country,currency,amount` when non-empty.
- `exchange_rates` contains every currency used in the CSV.
- `amount` is a non-negative decimal number.
- `payment_provider` is one of `card`, `bank_transfer`, `wallet`.
- `status` is one of `payment_completed`, `payment_failed`, `payment_pending`, `refund_completed`, `dispute_lost`, `dispute_won`.
Constraints
- 0 <= number of transactions <= 100000
- The CSV has the exact header user,status,payment_provider,buyer_country,currency,amount when non-empty
- exchange_rates contains every currency used in the CSV
- amount is a non-negative decimal number
- payment_provider is one of card, bank_transfer, wallet
- status is one of payment_completed, payment_failed, payment_pending, refund_completed, dispute_lost, dispute_won
Examples
Input: ("user,status,payment_provider,buyer_country,currency,amount\nalice,payment_completed,card,DE,EUR,100.00\nbob,payment_pending,bank_transfer,US,USD,50.00\nalice,dispute_lost,wallet,GB,GBP,80.00", {"USD": 1.0, "EUR": 1.10, "GBP": 1.25})
Expected Output: {"alice": 1783, "bob": 80}
Input: ("user,status,payment_provider,buyer_country,currency,amount\nu1,payment_completed,card,JP,JPY,10000\nu1,payment_completed,wallet,FR,EUR,10.00\nu2,refund_completed,card,US,USD,9.99", {"USD": 1.0, "EUR": 1.10, "JPY": 0.0068})
Expected Output: {"u1": 291, "u2": 0}
Approach
The solution computes per-user fees in a single pass over the CSV, using Python's Decimal everywhere so currency math and half-up rounding are exact (floats would mis-round at the cent boundary).
Setup. If csv_string is empty or whitespace, it returns {}. Otherwise it builds two static rate tables: base_rates (provider-only rates for failed/pending), and completed_rates, a provider → country → rate map where each provider has a "*" entry for "all other countries". FX rates are converted to Decimal once via Decimal(str(rate)) (stringifying first avoids inheriting float noise).
Per-row processing. A csv.DictReader yields one dict per line. After stripping fields, rows with an empty user are skipped; every other user is registered with totals.setdefault(user, 0) so they appear even with a 0 total. Then it branches on status:
- dispute_lost: add a flat 1500 cents.
- refund_completed / dispute_won: contribute 0 (user still recorded).
- Otherwise convert: amount_usd = Decimal(amount) * fx[currency].
- payment_completed: pick the rate with country_table.get(country, country_table["*"]) so unknown countries fall back to *; fee = amount_usd * rate + 0.30.
- payment_failed / payment_pending: fee = amount_usd * base_rates[provider] + 0.30.
Rounding. to_cents multiplies by 100 and quantizes with ROUND_HALF_UP, so each transaction is rounded to a whole cent before being summed — matching the spec exactly (rounding the running total would differ).
Finally dict(sorted(totals.items())) returns users in sorted order. Correctness rests on the * fallback, per-transaction rounding, and exact Decimal arithmetic.
Time complexity: O(n), where n is the number of CSV rows. Each row does O(1) dict lookups and one Decimal arithmetic/quantize. The final sort costs O(u log u) over u distinct users (u <= n), which is dominated by, or comparable to, the O(n) scan.
Space complexity: O(u + c), where u is the number of distinct users (the `totals` map) and c is the number of currencies in the `fx` map. The rate tables are constant size; the CSV is streamed row by row via DictReader, so no O(n) buffering of parsed rows.
Hints
- Use one lookup table for currency conversion and another lookup table for the payment_completed country-specific rates.
- Only payment_completed changes from Part 1. The other statuses keep the same logic after converting the amount to USD.
Community answers
Answer by Maximus
#include
using namespace std;
class Solution {
public:
unordered_map solution(const string& csvString) {
unordered_map mp;
unordered_map providerRate({{"card",0.029},{"bank_transfer",0.01},{"wallet",0.015}});
stringstream ss(csvString);
vector temp;
string str;
while(getline(ss,str,'\n')){
temp.push_back(str);
}
for(int i=1;i<temp.size();i++){
stringstream ss(temp[i]);
string user,status,paymentProvider,amount;
getline(ss,user,',');
getline(ss,status,',');
getline(ss,paymentProvider,',');
getline(ss,amount,',');
if(user.empty()) continue;
if(status == "payment_completed" || status == "payment_failed" || status == "payment_pending"){
mp[user] += round(100 ((stod(amount) providerRate[paymentProvider]) + 0.3));
}else if(status == "dispute_lost"){
mp[user] += round(100 * 15.0);
}else{
mp[user] += 0;
}
}
return mp;
}
};