Analytics Fundamentals
For a Payments PM, "coding" means SQL — not LeetCode. The interview tests whether you can read payment data, decompose AAR, build cohort cuts, and not embarrass yourself in a live editor.
Why SQL, not algorithmic LeetCode
You will not be asked to invert a binary tree. You may be asked to write or read SQL on a payments event table — to compute AAR by issuer, find the retry-success rate, or reconcile a settlement file. The bar is: can you do real analytical SQL fluently on a payments dataset.
Comfortable with CTEs, window functions, conditional aggregates, and joins between event streams. That's it.
A canonical payments schema (assume this)
-- One row per attempt (a payment intent can have many attempts)
auth_attempts (
attempt_id UUID PRIMARY KEY,
payment_intent_id UUID, -- stable across retries
attempt_seq INT, -- 1, 2, 3...
user_id UUID,
rail TEXT, -- 'card','upi','pix','sepa_inst','fps','ob_pis'
geo TEXT, -- ISO country code
psp_name TEXT,
acquirer TEXT,
bin TEXT,
issuer_country TEXT,
issuer_name TEXT,
mcc TEXT,
scheme TEXT, -- 'visa','mc','elo','rupay','mada'...
amount_cents BIGINT,
currency CHAR(3),
status TEXT, -- 'approved','soft_decline','hard_decline','timeout','3ds_pending'
decline_reason TEXT,
decline_family TEXT, -- 'soft','hard','stepup','system'
three_ds_result TEXT, -- 'frictionless','challenge_passed','failed','not_attempted'
network_token BOOLEAN,
fee_interchange_c INT,
fee_scheme_c INT,
fee_psp_c INT,
fee_xborder_c INT,
fee_fx_c INT,
auth_latency_ms INT,
created_at TIMESTAMP,
responded_at TIMESTAMP
);
settlements (
settlement_id UUID,
payment_intent_id UUID,
settled_at TIMESTAMP,
settled_amount_c BIGINT,
psp_name TEXT
);
disputes (
dispute_id UUID,
payment_intent_id UUID,
filed_at TIMESTAMP,
reason_code TEXT,
amount_c BIGINT,
status TEXT -- 'open','won','lost','accepted'
);
Every example below assumes this schema. Be ready to ask the interviewer for the schema if it's not given.
Funnel by rail
-- Funnel: intents → first attempt → approved → settled, by rail, last 30d
WITH intents AS (
SELECT payment_intent_id, MIN(rail) AS rail
FROM auth_attempts
WHERE created_at >= NOW() - INTERVAL '30 days'
GROUP BY payment_intent_id
),
first_attempt AS (
SELECT DISTINCT ON (payment_intent_id)
payment_intent_id, status
FROM auth_attempts
ORDER BY payment_intent_id, attempt_seq ASC
),
final AS (
SELECT payment_intent_id,
BOOL_OR(status='approved') AS ever_approved
FROM auth_attempts
WHERE created_at >= NOW() - INTERVAL '30 days'
GROUP BY payment_intent_id
)
SELECT
i.rail,
COUNT(*) AS intents,
COUNT(*) FILTER (WHERE f.ever_approved) AS approved_eventually,
COUNT(*) FILTER (WHERE fa.status='approved') AS approved_first_attempt,
COUNT(s.payment_intent_id) AS settled
FROM intents i
LEFT JOIN first_attempt fa USING (payment_intent_id)
LEFT JOIN final f USING (payment_intent_id)
LEFT JOIN settlements s USING (payment_intent_id)
GROUP BY i.rail
ORDER BY intents DESC;
AAR with confounders — the issuer × BIN × MCC × geo cut
-- First-attempt AAR by issuer × BIN × MCC × issuer_country, last 14 days
WITH first_attempts AS (
SELECT DISTINCT ON (payment_intent_id)
payment_intent_id, issuer_name, bin, mcc, issuer_country, status
FROM auth_attempts
WHERE created_at >= NOW() - INTERVAL '14 days'
ORDER BY payment_intent_id, attempt_seq ASC
)
SELECT
issuer_name, bin, mcc, issuer_country,
COUNT(*) AS attempts,
COUNT(*) FILTER (WHERE status='approved') AS approved,
ROUND(100.0 * COUNT(*) FILTER (WHERE status='approved') / COUNT(*), 2) AS aar_pct
FROM first_attempts
GROUP BY issuer_name, bin, mcc, issuer_country
HAVING COUNT(*) >= 200
ORDER BY aar_pct ASC;
-- The bottom of this list is your AAR investigation queue.
Retry decision modeling
Does retrying a soft decline actually help? Measure it.
-- Retry success rate by decline_family on first attempt, last 30d
WITH first AS (
SELECT payment_intent_id,
MIN(attempt_seq) FILTER (WHERE status='approved') AS approved_seq,
MAX(attempt_seq) AS max_seq,
FIRST_VALUE(decline_family) OVER (
PARTITION BY payment_intent_id ORDER BY attempt_seq
) AS first_decline_family,
FIRST_VALUE(status) OVER (
PARTITION BY payment_intent_id ORDER BY attempt_seq
) AS first_status
FROM auth_attempts
WHERE created_at >= NOW() - INTERVAL '30 days'
)
SELECT
first_decline_family,
COUNT(DISTINCT payment_intent_id) AS first_declined,
COUNT(DISTINCT payment_intent_id) FILTER (WHERE approved_seq IS NOT NULL) AS recovered,
ROUND(100.0 * COUNT(DISTINCT payment_intent_id) FILTER (WHERE approved_seq IS NOT NULL)
/ NULLIF(COUNT(DISTINCT payment_intent_id), 0), 2) AS recovery_pct
FROM first
WHERE first_status <> 'approved'
GROUP BY first_decline_family;
Strong recovery on soft and system; near-zero on hard (good — that's scheme-compliant).
Cost-per-success including retries
-- All-in cost per successful payment, last 30d, by rail + geo
WITH per_intent AS (
SELECT
payment_intent_id,
MIN(rail) AS rail,
MIN(geo) AS geo,
BOOL_OR(status='approved') AS approved,
SUM(fee_interchange_c + fee_scheme_c + fee_psp_c + fee_xborder_c + fee_fx_c) AS total_fees_c
FROM auth_attempts
WHERE created_at >= NOW() - INTERVAL '30 days'
GROUP BY payment_intent_id
)
SELECT
rail, geo,
COUNT(*) FILTER (WHERE approved) AS successes,
SUM(total_fees_c) FILTER (WHERE approved) AS total_cost_c,
ROUND(SUM(total_fees_c) FILTER (WHERE approved)::numeric
/ NULLIF(COUNT(*) FILTER (WHERE approved), 0), 2) AS cost_per_success_c
FROM per_intent
GROUP BY rail, geo
ORDER BY rail, geo;
Daily settlement reconciliation
-- Compare expected settlement vs actual, by PSP, by settlement date
WITH expected AS (
SELECT
DATE(responded_at + INTERVAL '1 day') AS expected_settle_date,
psp_name,
SUM(amount_cents) AS expected_amount_c,
COUNT(*) AS expected_count
FROM auth_attempts
WHERE status='approved'
AND responded_at >= NOW() - INTERVAL '14 days'
GROUP BY 1, 2
),
actual AS (
SELECT
DATE(settled_at) AS settle_date,
psp_name,
SUM(settled_amount_c) AS actual_amount_c,
COUNT(*) AS actual_count
FROM settlements
WHERE settled_at >= NOW() - INTERVAL '14 days'
GROUP BY 1, 2
)
SELECT
COALESCE(e.expected_settle_date, a.settle_date) AS d,
COALESCE(e.psp_name, a.psp_name) AS psp_name,
e.expected_amount_c, a.actual_amount_c,
e.expected_count, a.actual_count,
(a.actual_amount_c - e.expected_amount_c) AS delta_amount_c
FROM expected e
FULL OUTER JOIN actual a
ON e.expected_settle_date = a.settle_date AND e.psp_name = a.psp_name
ORDER BY d DESC, psp_name;
Cohort analysis with payment-method partitioning
-- Repeat-deposit retention by first-deposit rail (weekly cohort), 8 weeks
WITH first_deposit AS (
SELECT
user_id,
DATE_TRUNC('week', MIN(created_at)) AS cohort_wk,
(ARRAY_AGG(rail ORDER BY created_at))[1] AS first_rail
FROM auth_attempts
WHERE status='approved'
GROUP BY user_id
),
subsequent AS (
SELECT
a.user_id,
fd.cohort_wk,
fd.first_rail,
DATE_TRUNC('week', a.created_at) AS active_wk
FROM auth_attempts a
JOIN first_deposit fd USING (user_id)
WHERE a.status='approved'
)
SELECT
cohort_wk,
first_rail,
active_wk,
COUNT(DISTINCT user_id) AS active_users
FROM subsequent
WHERE active_wk >= cohort_wk
AND cohort_wk >= NOW() - INTERVAL '8 weeks'
GROUP BY cohort_wk, first_rail, active_wk
ORDER BY cohort_wk, first_rail, active_wk;
Window functions you should know cold
| Function | Use case |
|---|---|
ROW_NUMBER() | Pick the first attempt per intent |
LAG / LEAD | Week-over-week deltas |
SUM() OVER | Running totals (e.g. cumulative TPV) |
PERCENTILE_CONT | p50, p95, p99 latency |
FIRST_VALUE / LAST_VALUE | First-rail used; last status |
FILTER (WHERE ...) | Conditional aggregates without CASE |
Live SQL — interview tips
- Talk through your skeleton before typing. "I'd build a CTE for first-attempt, then aggregate, then filter HAVING count."
- Ask about schema. Confirm column names and what constitutes a "successful" payment.
- Watch your denominator. AAR over attempts vs intents differs.
- Use FILTER + boolean. Cleaner than CASE WHEN... THEN 1 ELSE 0.
- NULL-safe.
NULLIF(denominator, 0)before dividing. - Comment your CTE intent. One-line, in line.